Webhooks

How you find out that something happened, whether or not anyone is looking.

Verify every one

ts
const event = await sp.webhooks.constructEvent(
  rawBody,
  request.headers.get("Secure-Processing-Signature")!,
  process.env.SECURE_PROCESSING_WEBHOOK_SECRET!,
);
Pass the raw body, as a string, exactly as it arrived. A body that has been parsed and re-serialised has different bytes, so the signature cannot match. This is the most common reason a first webhook integration fails.

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

EventWhen
payment_intent.createdAn intent exists. Nothing has been charged yet.
payment_intent.processingThe customer has been sent to the bank’s page.
payment_intent.requires_action3-D Secure has the cardholder. Wait, do not retry.
payment_intent.succeededThe money is yours. Fulfil here.
payment_intent.payment_failedThe card was refused. The customer can try another.
payment_intent.amount_capturable_updatedA hold was placed and is waiting for capture.
payment_intent.canceledA hold was released, or the payment was abandoned.
checkout.session.completedA checkout session was paid.
checkout.session.expiredNobody paid within 24 hours.
refund.createdA refund was accepted and is being attempted at the bank.
refund.succeededMoney went back — including refunds made at the bank.
refund.failedThe bank refused the refund. No money moved.
customer.createdA customer record was created.
payment_method.attachedA customer saved a card.
payment_method.detachedA saved card was removed and will not be charged again.
webhook_endpoint.disabledWe 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.

ts
const endpoint = await sp.webhookEndpoints.rotateSecret(id, {
  expires_in_hours: 12,
});
// endpoint.secret is the new one, shown once

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

ts
const missed = await sp.events.list({ limit: 100 });