## Webhooks

Webhooks let you receive notifications when payout activity happens — without polling. You subscribe a callback URL to an event type, and Karat sends an HTTP `POST` to that URL whenever the event occurs.

## Subscribe to an event

1. **Discover available events**  
   Call [`GET /events`](https://help.trykarat.com/api-reference/webhooks/list-event-types) to list the event types you can subscribe to.

2. **Create a subscription**  
   Call [`POST /events/subscription`](https://help.trykarat.com/api-reference/webhooks/create-a-webhook-subscription) with the `event_name` and your `callback_url`. The response includes a `secret` — store it to verify future deliveries.

3. **Acknowledge deliveries**  
   Respond with a `2xx` status so Karat knows the event was received.
   
   ```
curl https://payouts.api.trykarat.com/events/subscription \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "event_name": "payout.updated",
    "callback_url": "https://example.com/api/webhooks/callback"
  }'
```

## Secret generation

Karat generates a unique signing secret when you create a subscription. The secret is returned **only once** in the create subscription response — store it securely so you can [verify deliveries](https://help.trykarat.com/developers/webhooks#verify-webhook-signatures).

## Handshake verification

When creating a subscription, the API verifies ownership of the callback URL by performing a challenge-response handshake. Your server must handle this before the subscription is accepted. The API sends a `POST` request to your `callback_url` with a JSON body:

```json
{
  "type": "url_verification",
  "challenge": "a3f8b2e..."
}
```

Your server must respond within **30 seconds** with HTTP status `200` and a JSON body echoing the challenge:

```json
{
  "challenge": "a3f8b2e..."
}
```

If verification succeeds, the subscription is created. If it fails, the API returns a `400` error with one of these messages:

| Message                                                          | Cause                                                                                     |
|------------------------------------------------------------------|-------------------------------------------------------------------------------------------|
| `Callback URL verification failed: could not reach the URL`      | The URL was unreachable, timed out, or the connection was refused                         |
| `Callback URL verification failed: received status {code}`       | Your server responded with a non-`2xx` status code                                        |
| `Callback URL verification failed: invalid JSON response`        | The response body was not valid JSON                                                      |
| `Callback URL verification failed: challenge mismatch`           | The `challenge` value in the response did not match the one sent                          |

## Event types

| Event             | When it fires                         |
|------------------|---------------------------------------|
| `payout.updated` | A payout’s status changes            |
| `tax_form.created` | A recipient completes a tax form   |

## Payload

Every delivery shares the same envelope — `id`, `event`, `created_at`, `subscription_id`, and an event-specific `data` object.

### `payout.updated`

```json
{
  "id": "a2b6f9c8-7d5f-4c2b-bd7a-1c2f9b5c44b9",
  "event": "payout.updated",
  "created_at": "2026-03-02T12:34:56Z",
  "subscription_id": "7a6b5c4d-3e2f-1a0b-9c8d-7e6f5a4b3c2d",
  "data": {
    "id": "8e0c87c1-948c-4e57-9b48-9b5ab4c0d7e8",
    "status": "created",
    "reason": null,
    "reference_id": "6ae5b965-ad32-4d21-bff9-f1e661f05ca3",
    "payout_intent_id": "05545ccf-0798-4661-8b36-2ad7c0bf5375",
    "date": "2025-09-30T14:59:37.055Z"
  }
}
```

### `tax_form.created`

```json
{
  "id": "78ce3584-0761-40ab-bcb1-ef7c5bffe095",
  "event": "tax_form.created",
  "created_at": "2026-03-10T23:45:07.878Z",
  "subscription_id": "48582de1-c2fb-4fd3-bdf3-39a7138d1164",
  "data": {
    "id": "ffc96bee-3ce9-4bfc-a436-c08cc2a3d33e",
    "recipient": {
      "id": "db783652-823b-46f8-88e2-93ae37442232",
      "firstName": "John",
      "lastName": "Doe",
      "nickname": null
    },
    "createdAt": "2026-03-10T23:45:07.868Z",
    "url": "https://url.com/pdf"
  }
}
```

## Verify webhook signatures

Every delivery is signed so you can confirm it came from Karat. Verify the signature before processing the payload. Each request includes these headers:

| Header                               | Description                                            |
|--------------------------------------|-------------------------------------------------------|
| `X-Karat-Signature`                 | The signature, in the format `v1=<base64>`.          |
| `X-Karat-Webhook-Timestamp`         | Unix epoch seconds when the event was sent.           |
| `X-Karat-Subscription-Id`           | The subscription the event belongs to — use it to look up the right `secret`. |
| `X-Karat-Webhook-Id`                | Unique delivery ID, for deduplication.                |

The signature is an **HMAC-SHA256** of `timestamp + "." + raw_body`, keyed with your subscription `secret`, then base64-encoded:

```javascript
signed_payload   = X-Karat-Webhook-Timestamp + "." + raw_request_body
signature_base64 = base64(HMAC_SHA256(secret, signed_payload))
```

To verify a delivery:

1. **Look up the secret**  
   Use `X-Karat-Subscription-Id` to find the `secret` you stored when creating the subscription.

2. **Check the timestamp**  
   Reject deliveries where `X-Karat-Webhook-Timestamp` is outside a ±300 second window to protect against replays.

3. **Recompute and compare**  
   Recompute the signature from the **raw** request body and compare it to the value in `X-Karat-Signature` using a constant-time comparison.

4. **Deduplicate**  
   Use `X-Karat-Webhook-Id` (and the `id` in the body) to ignore duplicate deliveries.

```javascript
import crypto from "crypto";

function verifyKaratWebhook({ headers, rawBody }, secret) {
  const timestamp = headers["x-karat-webhook-timestamp"];

// Replay protection: reject deliveries older than 5 minutes.
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

// Build the signed payload from raw bytes so the body is never re-encoded.
  const signedPayload = Buffer.concat([
    Buffer.from(`${timestamp}.`),
    Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(rawBody),
  ]);
  const expected = crypto
    .createHmac("sha256", secret)
    .update(signedPayload)
    .digest("base64");

const provided = (headers["x-karat-signature"] ?? "").replace(/^v1=/, "");
  const a = Buffer.from(expected);
  const b = Buffer.from(provided);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
```

Compute the signature over the exact raw bytes you received, before any JSON parsing or re-serialization — reformatting the body will change the signature. If verification fails, return a non-`2xx` response and do not process the event.

## Delivery

- Deliveries are sent asynchronously and retried on failure.
- Always return a `2xx` response quickly; do heavy processing out of band.

## Manage subscriptions

- List your subscriptions with [`GET /events/subscription`](https://help.trykarat.com/api-reference/webhooks/list-webhook-subscriptions).
- Remove one with [`DELETE /events/subscription/{subscription_id}`](https://help.trykarat.com/api-reference/webhooks/delete-a-webhook-subscription).
