Webhooks
Verify every one
const event = await sp.webhooks.constructEvent(
rawBody,
request.headers.get("Secure-Processing-Signature")!,
process.env.SECURE_PROCESSING_WEBHOOK_SECRET!,
);The signature header looks like t=1740000000,v1=abc123…. The timestamp is part of what was signed, so an old request cannot be replayed at you — we reject anything more than five minutes old by default.
The events
| Event | When |
|---|---|
| payment_intent.created | An intent exists. Nothing has been charged yet. |
| payment_intent.processing | The customer has been sent to the bank’s page. |
| payment_intent.requires_action | 3-D Secure has the cardholder. Wait, do not retry. |
| payment_intent.succeeded | The money is yours. Fulfil here. |
| payment_intent.payment_failed | The card was refused. The customer can try another. |
| payment_intent.amount_capturable_updated | A hold was placed and is waiting for capture. |
| payment_intent.canceled | A hold was released, or the payment was abandoned. |
| checkout.session.completed | A checkout session was paid. |
| checkout.session.expired | Nobody paid within 24 hours. |
| refund.created | A refund was accepted and is being attempted at the bank. |
| refund.succeeded | Money went back — including refunds made at the bank. |
| refund.failed | The bank refused the refund. No money moved. |
| customer.created | A customer record was created. |
| payment_method.attached | A customer saved a card. |
| payment_method.detached | A saved card was removed and will not be charged again. |
| webhook_endpoint.disabled | We switched an endpoint off after repeated failures. |
Retries
Any response outside the 2xx range is a failure and we try again: after 30 seconds, then 2 minutes, 10 minutes, 30 minutes, an hour, and on out to about 35 hours in total. Answer 200 as soon as you have stored the event and do the work afterwards — a slow handler becomes a timeout, and a timeout becomes a retry.
Delivery is at-least-once, so handle events idempotently: key on event.id and ignore one you have already processed. Events can also arrive out of order, so treat the object in the event as the current truth rather than assuming a sequence.
Rotating a secret
Rotating gives you a new secret and keeps the old one working for a window you choose. We sign with both during the overlap, so you can deploy the change without dropping events.
const endpoint = await sp.webhookEndpoints.rotateSecret(id, {
expires_in_hours: 12,
});
// endpoint.secret is the new one, shown onceIf you miss some
Every event is readable from the API for as long as you need it. After an outage, read forward from the last event you processed rather than waiting for retries.
const missed = await sp.events.list({ limit: 100 });