Card payments are asynchronous — 3-D Secure completes in a browser you don't control, refunds settle later, subscription charges happen on a schedule. Webhooks are how you find out, without polling.
Register your endpoint
POST /v1/webhook_endpoints with your receiving URL and the event types you want. HTTPS only, and it must accept POST and return a 2xx within 10 seconds.
Verify the signature
Every delivery includes a Paayed-Signature header with an HMAC-SHA256 of the body, signed with your endpoint's own secret. Always verify before processing.
Process asynchronously
Acknowledge with a 2xx quickly and do heavy work in the background. Our delivery worker retries on timeout, and duplicates are expected under at-least-once delivery — treat the event id as an idempotency key on your side.
Names are noun then past-tense verb, dot separated, and stable forever.
payment.created, payment.requires_action, payment.authorized, payment.captured, payment.succeeded, payment.declined, payment.failed, payment.cancelled, payment.expired
refund.created, refund.succeeded, refund.failed
hosted_session.completed, hosted_session.expired
customer.created, customer.updated, customer.deleted, payment_method.attached, payment_method.detached, payment_method.expiring
subscription.created, subscription.charged, subscription.charge_failed, subscription.paused, subscription.cancelled
dispute.opened, dispute.updated, dispute.closed, payout.paid, payout.failed — names reserved now, delivered in phase two.
Every event ships the full object, not a thin notification — a snapshot at the moment the event fired, including what changed:
{
"id": "evnt_01JBQ7YM9C1XB6TZQ4",
"object": "event",
"type": "payment.succeeded",
"api_version": "2026-09-01",
"created_at": "2026-09-04T10:31:07Z",
"livemode": true,
"account": "merch_9Hs2Kd41Qm",
"data": {
"object": { "id": "pay_01JBQ7X2F9KDNW3M8T", "object": "payment", "status": "succeeded", "amount": 42.50 },
"previous_attributes": { "status": "authorized" }
}
}signed_payload = timestamp + "." + raw_request_body
signature = hex( hmac_sha256( endpoint_secret, signed_payload ) )Paayed-Signature: t=1757001600,v1=8d5c3ab1...c41fRead the raw body before parsing JSON — a re-serialised body won't match, and this is the single most common integration mistake. Reject a timestamp more than five minutes old to stop replay of a captured request.
During a secret rotation both the old and new secret are sent, so you can deploy the new secret with no downtime:
Paayed-Signature: t=1757001600,v1=<new secret>,v1=<old secret>| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 2 hours |
| 5 | 6 hours |
| 6 | 12 hours |
| 7–8 | 24 hours |
Roughly 48 hours of cover, with jitter so a recovering endpoint isn't hit with every backlogged delivery at once. After 24 hours of continuous failure the endpoint is marked degraded; after 7 days it's disabled and every administrator is emailed. Re-enabling offers a replay of everything missed within the 30-day retention window.
If you ever suspect a missed delivery, GET /v1/events is the backstop — retained for 30 days regardless of webhook status.
Check the endpoint is publicly reachable over HTTPS. Send a test event per event type from the dashboard's delivery log, and check the recorded response there — status code, timing, and the first kilobyte of your response are all shown.
Use the raw request body for verification, not a parsed-and-re-serialised version. Confirm you're using the endpoint's own signing secret, not your API key.
Ordering isn't guaranteed. Use created_at and the event id to detect and discard anything stale.