Webhooks

Event catalog, signatures, retries and secret rotation.

Event catalog

Everything we can send you.

These are the exact events spuke delivers to app webhook endpoints today. Anything not listed here is never sent — subscribe only to what exists.

How to think about events

Concept Explanation
Event = past-tense fact payment_intent.succeeded means it already happened. You react, you never approve.
One action can fire several events A completed checkout fires payment_intent.succeeded and checkout.session.completed. Use the checkout event as your fulfilment trigger.
At-least-once delivery The same event can arrive twice. Deduplicate on the event id.
No ordering guarantee Events can arrive out of order. If order matters, re-fetch the object from the API.
Unknown types are safe to ignore We add events without a breaking change. Never crash on an unfamiliar type.
Subscription is per endpoint Unsubscribed events are not delivered and not stored. * and checkout.* wildcards are supported.

The envelope

Every delivery has the same outer shape; only type and data.object differ.

{
  "id": "evt_9f1c2ab34d5e6f70",
  "object": "event",
  "type": "checkout.session.completed",
  "environment": "live",
  "installation": "3f0c…-uuid",
  "created": 1755423672,
  "data": { "object": { "…": "resource payload" } }
}
Field What it is Why it matters
id Unique event ID, prefix evt_ Your idempotency key. Store it and skip duplicates.
object Always "event" Lets you store raw payloads generically.
type What happened The only field you should branch on.
environment test or live Never let a sandbox event touch production data.
installation The installation this event belongs to Routes the event to the right tenant in your app.
created Unix timestamp in seconds (not RFC 3339) Use it to resolve out-of-order arrivals.
data.object The resource payload at the time of the event Shape depends on type — see Event payloads.

Delivery headers: Spuke-Signature, Spuke-Event-Id, Spuke-Event-Type, Spuke-Environment, Spuke-Delivery-Attempt.


Checkout

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
}

See Signatures & retries.

Event payloads

What each event means and what it carries.

Every delivery uses the same envelope; only type and data.object differ. created is a Unix timestamp in seconds.

{ "id": "evt_9f1c2ab34d5e6f70", "object": "event", "type": "checkout.session.completed",
  "environment": "live", "installation": "3f0c…-uuid", "created": 1755423672,
  "data": { "object": { … } } }

Checkout session payload

All checkout.session.* events carry the same base object, plus a few event-specific fields.

{
  "id": "b6b1…-uuid",
  "object": "checkout.session",
  "status": "complete",
  "mode": "live",
  "amount": 2500,
  "currency": "eur",
  "reference": "cs_2f81c0",
  "metadata": { "order_id": "1042" },
  "payment_intent": "pi_3Nk91x…",
  "setup_intent": null,
  "subscription": null,
  "charge": "ch_3Nk91x…"
}
Field Meaning
id spuke checkout session ID (UUID). Your order key.
status open, complete, expired, failed, canceled.
mode live or test — same meaning as the envelope environment.
amount Total in minor units (2500 = €25.00).
currency Lowercase ISO-4217.
reference Short human-readable session reference shown to the shopper.
metadata Key/value map set when the session was created — your safest link to your own order.
payment_intent Payment reference at the processor, null for setup-only sessions.
setup_intent Set instead of payment_intent when the session only saved a payment method.
subscription Subscription ID when the session started a recurring plan.
charge Charge reference once money moved; needed to match refunds/disputes.

Event-specific extras

Event Extra fields
checkout.session.async_payment_pending payment_status: "processing", status: "open"
checkout.session.async_payment_failed failure_code, failure_message
checkout.session.canceled cancellation_reason
checkout.session.refunded amount_refunded (minor units), fully_refunded (boolean)
{ "type": "checkout.session.refunded",
  "data": { "object": { "id": "b6b1…", "object": "checkout.session", "status": "complete",
    "amount": 2500, "currency": "eur", "amount_refunded": 500, "fully_refunded": false } } }

Payment intent payloads

payment_intent.succeeded

{ "id": "pi_3Nk91x", "amount": 2500, "amount_received": 2500, "currency": "eur",
  "status": "succeeded", "customer": "cus_8f21", "latest_charge": "ch_3Nk91x",
  "payment_method_types": ["card"], "metadata": { "order_id": "1042" } }
Field Meaning
amount Amount requested, minor units.
amount_received Amount actually captured — compare both for partial captures.
status Processor status, e.g. succeeded, requires_action, canceled.
customer Processor customer reference, may be null for guest checkout.
latest_charge The charge that carries the money; use it to match refunds and disputes.
payment_method_types Methods that were allowed on this payment.
metadata Whatever the merchant/app set at creation.

payment_intent.payment_failed replaces amount_received/latest_charge with:

{ "id": "pi_3Nk91x", "amount": 2500, "currency": "eur", "status": "requires_payment_method",
  "customer": null, "failure_code": "card_declined", "decline_code": "insufficient_funds",
  "failure_message": "Your card has insufficient funds.", "metadata": {} }
Field Meaning
failure_code 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.

Signatures & retries

Verify every payload, survive every retry.

Signature header

Spuke-Signature: t=1755423672,v1=6f1c…9ab2

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

  1. Answer 2xx within 10 seconds. Do the work asynchronously.
  2. Be idempotent: store event.id and drop duplicates.
  3. Do not rely on ordering. Compare timestamps or re-fetch the object.
  4. 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.

Testing webhooks

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
  • event.id deduplicated for at least 24 hours
  • Handler returns 2xx in under 10 seconds
  • Unknown event types ignored without erroring
  • Alerting on delivery failure rate