The checkout session is the object your app should treat as the order.
Event
What it means
What you should do
checkout.session.completed
The shopper paid and the session flipped to complete
Fulfil the order here. Fired exactly once per session.
checkout.session.expired
The session timed out unpaid
Release reserved stock, optionally send a recovery mail.
checkout.session.canceled
The shopper or the merchant cancelled before payment
Close the order; cancellation_reason may be set.
checkout.session.async_payment_pending
A delayed method (e.g. bank debit) is processing
Show "payment pending"; do not ship yet.
checkout.session.async_payment_failed
The delayed payment failed
failure_code / failure_message explain why.
checkout.session.refunded
The related charge was refunded
amount_refunded and fully_refunded tell you how much.
Payments (payment intents)
Lower-level than checkout — use these only if you need the payment view.
Event
What it means
What you should do
payment_intent.succeeded
Funds captured
Mark paid. Prefer checkout.session.completed for order fulfilment.
payment_intent.payment_failed
Declined; failure_code, decline_code, failure_message explain it
Offer a retry; never retry silently.
payment_intent.requires_action
Waiting on the shopper (3-D Secure, app confirmation)
Nothing to do server-side; the shopper continues in checkout.
payment_intent.canceled
The payment was cancelled or abandoned
Free reserved inventory.
Billing
Event
What it means
What you should do
invoice.payment_failed
A subscription invoice could not be collected; dunning continues
Warn the customer — don't cut access on the first failure.
customer.subscription.deleted
A subscription ended
Revoke entitlements.
There are currently no invoice.created / invoice.paid / subscription.created app events. Poll /v1/invoices and /v1/subscriptions if you need that state.
Not available yet
Standalone refund.*, dispute.*, payout.* and customer.* events are not delivered to apps. Read that data through the App API instead:
You want
Use
Refund state
checkout.session.refunded event + GET /v1/refunds
Dispute state
GET /v1/disputes (no dispute events are pushed to apps)
Payouts
GET /v1/payouts (restricted scope)
Customers
fields on the payment/invoice objects
Minimal handler
export async function POST(req) {
const raw = await req.text();
if (!verifySignature(raw, req.headers.get("spuke-signature"), SECRET)) {
return new Response("bad signature", { status: 400 });
}
const event = JSON.parse(raw);
if (await seen(event.id)) return new Response("ok"); // dedupe
await store(event.id);
switch (event.type) {
case "checkout.session.completed": await fulfil(event.data.object); break;
case "checkout.session.refunded": await refund(event.data.object); break;
default: break; // ignore unknown types
}
return new Response("ok"); // 2xx within 10s
}
Coarse reason (card_declined, expired_card, …). Branch on this.
decline_code
Issuer's detailed reason. Show it to the merchant, not the shopper.
failure_message
Human-readable text, already customer-safe.
payment_intent.requires_action and payment_intent.canceled carry the same base fields (id, amount, currency, status, customer, metadata).
Billing payloads
invoice.payment_failed and customer.subscription.deleted carry the processor invoice/subscription object. Always read status before changing entitlements, and treat unknown fields as additive.
Delivery guarantees
At-least-once and unordered. Deduplicate on the event id; when order matters, compare created or re-fetch the object from the App API.
Signed payload = "{t}.{raw body}", HMAC-SHA256 with your endpoint secret (whsec_…), hex encoded.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(raw: string, header: string, secret: string, toleranceSec = 300) {
const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!Number.isFinite(age) || age > toleranceSec) return false;
const expected = createHmac("sha256", secret).update(`${parts.t}.${raw}`).digest("hex");
const a = Buffer.from(expected), b = Buffer.from(String(parts.v1 ?? ""));
return a.length === b.length && timingSafeEqual(a, b);
}
Verify against the raw body — parsing first and re-serializing breaks the signature.
Rules for your endpoint
Answer 2xx within 10 seconds. Do the work asynchronously.
Be idempotent: store event.id and drop duplicates.
Do not rely on ordering. Compare timestamps or re-fetch the object.
Reject requests whose signature fails — never "process anyway and log".
Retries
Failed or slow deliveries retry with exponential backoff for 24 hours: roughly 1 min, 5 min, 30 min, 2 h, 6 h, 12 h. After that the delivery is marked failed and stays visible in the logs for manual replay.
An endpoint failing continuously for 72 hours is auto-disabled and you are notified.
Secret rotation
Rotating issues a second active secret; both validate during a 24-hour overlap so you can deploy without dropped events. Old secrets are then destroyed. Secrets are stored encrypted (AES-GCM) and shown once.
Replay
Any delivery can be replayed from the logs with the original payload — the retry counter and signature timestamp are refreshed.
Local development, replays and common failure modes.
Local development
Expose your local server (ngrok, cloudflared) and register the HTTPS URL as a test endpoint. http://localhost is only accepted for OAuth redirects, never for webhooks.
Trigger events
from the environment's ledger tools (real state changes), or
with the event simulator (synthetic payloads for any event type).
Read the logs
Each delivery shows request headers, raw body, your response code and body, latency and every retry. Filter by event type or status to find a bad handler quickly.
Common failures
Symptom
Cause
Signature mismatch
Body parsed before verification, or wrong endpoint secret
Duplicate side effects
No idempotency on event.id
Timeouts
Work done inline instead of queued
Missing events
Event type not subscribed on that endpoint
403 from your own WAF
Our delivery IPs blocked, or bot protection on the path
Checklist before launch
Signature verified against the raw body, with a 5-minute tolerance