Skip to content

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.

Delivery
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.

HeaderValue
webhook-idThe event's ID. It stays the same across retries, so use it to skip duplicates.
webhook-timestampSeconds since the Unix epoch. Refuse deliveries more than five minutes old.
webhook-signaturev1, 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.

Verify a delivery
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.

  1. Event (signed delivery) → Your endpoint (answers in 15 seconds)
  2. Your endpoint (answers in 15 seconds) → Delivered (any 2xx)
  3. Your endpoint (answers in 15 seconds) → Retry (up to 7 more times)
  4. Retry (up to 7 more times) → Failed (resend it any time)
  5. 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

FieldMeaning
idThe event's ID, the same as the webhook-id header.
typeWhat happened, such as payment.succeeded.
subjectThe kind and id of the object it's about.
dataThe subject as it was when the event happened. Fetch it again for its state now.
actorWho 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.
modetest or live, for the kind of key behind the event. Always live during the beta, when test and live keys share data.
api_versionThe version of the event's shape, v1 today.
testOnly on test deliveries, and then true.
created_atWhen 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

SymptomLikely cause
Every signature failsThe body was parsed and re-serialized before verifying. Sign the raw bytes you received.
Some signatures failYour server's clock is off, or you rotated the secret and still check only the new one.
Events arrive twiceA slow response was retried. Skip any webhook-id you've already handled.
Nothing arrivesThe 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.