Webhooks
Signed events for every change, retried until your server answers.
Webhooks tell your server when something changes, so you never poll. Add an endpoint in the dashboard or with POST /webhooks, choose the event types it receives, and save its signing secret. The secret starts with whsec_, the Standard Webhooks prefix, and is shown once.
POST /webhooks/402pay HTTP/1.1Content-Type: application/jsonwebhook-id: evt_JxmQNXpkZZN8mpqEwebhook-timestamp: 1790457795webhook-signature: v1,Usi/akbM9PxJChbtzMSCZXf783T+z0FaQRv68dL2fwQ=Verify deliveries
Deliveries are signed with the Standard Webhooks scheme, so any library that implements it can check them. Each request carries three headers.
| Header | Value |
|---|---|
webhook-id | The event's ID. It stays the same across retries, so use it to skip duplicates. |
webhook-timestamp | Seconds since the Unix epoch. Refuse deliveries more than five minutes old. |
webhook-signature | v1, then a base64 HMAC-SHA256 of {id}.{timestamp}.{body}, keyed with the base64-decoded part of the secret after whsec_. |
The body above is formatted to read. Your server receives it compact, and the signature covers those exact bytes, so verify the raw body before you parse it.
import crypto from "node:crypto"; // secret is the endpoint's whsec_ value; body is the raw request body.export function verifyWebhook(secret, headers, body) { const id = String(headers["webhook-id"] ?? ""); const timestamp = String(headers["webhook-timestamp"] ?? ""); const signatures = String(headers["webhook-signature"] ?? ""); // Refuse replays: the delivery must be less than five minutes old. const sent = Number(timestamp); if (!Number.isInteger(sent) || Math.abs(Date.now() / 1000 - sent) > 300) return false; const key = Buffer.from(secret.slice("whsec_".length), "base64"); const expected = crypto .createHmac("sha256", key) .update(`${id}.${timestamp}.${body}`) .digest("base64"); // During a rotation the header carries one signature per secret. return signatures.split(" ").some((entry) => { const [version, signature = ""] = entry.split(","); return ( version === "v1" && signature.length === expected.length && crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected)) ); });}After you rotate a secret, the old one keeps signing alongside the new one for 24 hours, separated by a space, so you can switch without dropping a delivery.
Responses and retries
Answer with any 2xx status within 15 seconds. Anything else, or no answer, is tried again up to 7 more times, after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours and 10 hours. Do slow work after you respond, and handle each webhook-id once. Delivery is at least once and not in order: a retry can arrive after a later event, so read the object's current state before you act on one. Failed deliveries can be resent from the dashboard or with POST /webhook-deliveries/{id}/resend.
- Event (signed delivery) → Your endpoint (answers in 15 seconds)
- Your endpoint (answers in 15 seconds) → Delivered (any 2xx)
- Your endpoint (answers in 15 seconds) → Retry (up to 7 more times)
- Retry (up to 7 more times) → Failed (resend it any time)
- Retry (up to 7 more times) → Your endpoint (answers in 15 seconds): waits longer each time
Turning an endpoint off stops new deliveries, and drops any retry that comes due while it's off, so nothing piles up to arrive at once when you turn it back on. Deleting an endpoint removes its deliveries, pending retries included.
Event fields
| Field | Meaning |
|---|---|
id | The event's ID, the same as the webhook-id header. |
type | What happened, such as payment.succeeded. |
subject | The kind and id of the object it's about. |
data | The subject as it was when the event happened. Fetch it again for its state now. |
actor | Who caused it: user (a person in the dashboard), api_key, customer (at checkout; name is their email when they gave one) or system (402pay, such as a payment confirming), with an id and name where there is one. |
mode | test or live, for the kind of key behind the event. Always live during the beta, when test and live keys share data. |
api_version | The version of the event's shape, v1 today. |
test | Only on test deliveries, and then true. |
created_at | When it happened. |
Test deliveries
POST /webhooks/{id}/test sends a signed sample of an event type the endpoint receives. Its payload has test: true and a made-up subject, such as pmt_test_wcKbRA2NP8gXhlmr, so check for test before you act on an event. Test deliveries go out once and aren't retried.
Troubleshooting
| Symptom | Likely cause |
|---|---|
| Every signature fails | The body was parsed and re-serialized before verifying. Sign the raw bytes you received. |
| Some signatures fail | Your server's clock is off, or you rotated the secret and still check only the new one. |
| Events arrive twice | A slow response was retried. Skip any webhook-id you've already handled. |
| Nothing arrives | The endpoint is disabled, not subscribed to the event type, or not reachable over public HTTPS. Read each attempt and your server's answer in the dashboard or with GET /webhook-deliveries. A local 402pay records deliveries without sending them. |
Event types
Every type an endpoint can subscribe to, what its data holds and the order a payment's events arrive in are in the webhook event catalog.