Webhooks - Karat Help Center

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 to list the event types you can subscribe to.

  2. Create a subscription
    Call POST /events/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:

{
  "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

{
  "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

{
  "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:

  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.

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

Manage subscriptions