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
Discover available events
CallGET /eventsto list the event types you can subscribe to.Create a subscription
CallPOST /events/subscriptionwith theevent_nameand yourcallback_url. The response includes asecret— store it to verify future deliveries.Acknowledge deliveries
Respond with a2xxstatus 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:
Look up the secret
UseX-Karat-Subscription-Idto find thesecretyou stored when creating the subscription.Check the timestamp
Reject deliveries whereX-Karat-Webhook-Timestampis outside a ±300 second window to protect against replays.Recompute and compare
Recompute the signature from the raw request body and compare it to the value inX-Karat-Signatureusing a constant-time comparison.Deduplicate
UseX-Karat-Webhook-Id(and theidin 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
- Deliveries are sent asynchronously and retried on failure.
- Always return a
2xxresponse quickly; do heavy processing out of band.
Manage subscriptions
- List your subscriptions with
GET /events/subscription. - Remove one with
DELETE /events/subscription/{subscription_id}.