> ## Documentation Index
> Fetch the complete documentation index at: https://help.trykarat.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks

> Subscribe to Karat Payout API events, verify webhook signatures with your signing secret, and react to payout and tax form updates in real time.

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

<Steps>
  <Step title="Discover available events">
    Call [`GET /events`](/api-reference/webhooks/list-event-types) to list the event
    types you can subscribe to.
  </Step>

  <Step title="Create a subscription">
    Call [`POST /events/subscription`](/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.
  </Step>

  <Step title="Acknowledge deliveries">
    Respond with a `2xx` status so Karat knows the event was received.
  </Step>
</Steps>

```bash theme={null}
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](#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 theme={null}
{
  "type": "url_verification",
  "challenge": "a3f8b2e..."
}
```

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

```json theme={null}
{
  "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 theme={null}
{
  "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 theme={null}
{
  "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:

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

To verify a delivery:

<Steps>
  <Step title="Look up the secret">
    Use `X-Karat-Subscription-Id` to find the `secret` you stored when creating
    the subscription.
  </Step>

  <Step title="Check the timestamp">
    Reject deliveries where `X-Karat-Webhook-Timestamp` is outside a ±300 second
    window to protect against replays.
  </Step>

  <Step title="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.
  </Step>

  <Step title="Deduplicate">
    Use `X-Karat-Webhook-Id` (and the `id` in the body) to ignore duplicate
    deliveries.
  </Step>
</Steps>

```javascript theme={null}
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);
}
```

<Warning>
  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.
</Warning>

## 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`](/api-reference/webhooks/list-webhook-subscriptions).
* Remove one with [`DELETE /events/subscription/{subscription_id}`](/api-reference/webhooks/delete-a-webhook-subscription).
