Webhooks

Receive real-time events with signed HTTP requests.

How webhooks work

How spuke delivers webhooks, retries, and how to configure endpoints.

How webhooks work

Webhooks let spuke notify your server whenever something happens on your account — a payment succeeds, an invoice is paid, a dispute is opened.

Configure endpoints

Dashboard → Developers → Webhooks → Add endpoint

  • url — must be HTTPS.
  • description — free-form.
  • enabled_events — pick the events you want. Use * for everything.
  • modetest or live. Test-mode endpoints only receive events from test keys.

On save spuke returns a signing secret (whsec_…) — copy it now, it's shown only once.

Delivery guarantees

  • HTTP POST with Content-Type: application/json.
  • 2xx = success. Any non-2xx (or timeout > 20 s) triggers exponential backoff retries for up to 72 hours (roughly 15 attempts).
  • Delivery attempts are visible per-event under the endpoint.
  • Events can arrive out of order. Use data.id + type for idempotency.

Envelope

{
  "id": "evt_01H…",
  "object": "event",
  "type": "checkout.session.completed",
  "created": 1735689600,
  "livemode": true,
  "data": {
    "object": { /* the resource — checkout session, invoice, charge, dispute, payout, … */ }
  }
}

The resource always lives at data.object. See Event reference for the exact shape per event type.

Testing

  • Use sk_test_… keys and a test-mode endpoint. spuke test cards work end-to-end.
  • The Developers → Webhooks → Test button sends a synthetic ping.test event.

Verify signatures

HMAC-SHA256 signing scheme — verify before you trust an event.

Verify signatures

Every webhook is signed with your endpoint's whsec_… secret. Verify the signature before parsing the body.

Header

Spuke-Signature: t=1735689600,v1=6a76…f3
  • t — Unix timestamp of when spuke generated the signature.
  • v1 — HMAC-SHA256 of "{t}.{raw_body}" using your whsec_… secret, hex-encoded.

Steps

  1. Extract t and v1 from the header.
  2. Reject if |now - t| > 300 seconds (replay protection).
  3. Compute expected = HMAC_SHA256(secret, t + "." + rawBody).
  4. Compare expected with v1 in constant time.

Node.js example

import crypto from "node:crypto";

export function verify(rawBody, header, secret) {
  const parts = Object.fromEntries(header.split(",").map(p => p.split("=")));
  const t = Number(parts.t);
  if (Math.abs(Date.now()/1000 - t) > 300) throw new Error("expired");
  const expected = crypto.createHmac("sha256", secret)
    .update(`${t}.${rawBody}`).digest("hex");
  const ok = crypto.timingSafeEqual(
    Buffer.from(expected, "hex"),
    Buffer.from(parts.v1, "hex")
  );
  if (!ok) throw new Error("bad_signature");
}

PHP example

[$t, $v1] = [null, null];
foreach (explode(',', $_SERVER['HTTP_SPUKE_SIGNATURE']) as $p) {
  [$k, $v] = explode('=', $p, 2);
  if ($k === 't')  $t  = (int)$v;
  if ($k === 'v1') $v1 = $v;
}
if (abs(time() - $t) > 300) http_response_code(400);
$expected = hash_hmac('sha256', $t.'.'.$raw, $secret);
if (!hash_equals($expected, $v1)) http_response_code(400);

Always use the raw request body — parsing to JSON first will change whitespace and invalidate the signature.

Event reference

Every event spuke can send, with the payload shape.

Event reference

Checkout Sessions

Event When
checkout.session.completed Buyer completed payment successfully.
checkout.session.async_payment_succeeded Async method (SEPA, Klarna) cleared.
checkout.session.async_payment_pending Async payment awaiting clearing.
checkout.session.async_payment_failed Async payment failed after auth.
checkout.session.expired Session was not paid within its window.
checkout.session.canceled Merchant or buyer cancelled the session.

Payments & refunds

Event When
payment.succeeded Payment captured.
payment.failed Attempt failed.
charge.refunded Full or partial refund issued.

Disputes

Event When
charge.dispute.created Chargeback opened. Status: needs_response.
charge.dispute.updated Evidence uploaded or spuke changed status.
charge.dispute.closed Case closed. Payload includes outcome: won | lost | warning_closed.

Invoices

Event When
invoice.finalized Invoice sent to customer.
invoice.paid Customer paid.
invoice.payment_succeeded Renewal charge for a subscription succeeded.
invoice.payment_failed Payment attempt on the invoice failed. Advances dunning.
invoice.voided Merchant voided the invoice.

Subscriptions

Event When
customer.subscription.created Subscription created (usually incomplete until first payment).
customer.subscription.updated Status, price, quantity, or cancel_at_period_end changed.
customer.subscription.deleted Subscription ended (immediately or at period end).
customer.subscription.trial_will_end Fires 3 days before a trial ends.
customer.subscription.paused Collection was paused.
customer.subscription.resumed Collection resumed after a pause.

Dunning stages

When a renewal fails, spuke advances the subscription through three dunning stages before giving up. Each stage triggers invoice.payment_failed with a dunning_stage in metadata:

Stage Trigger Merchant action
notice 1st failure Notify the customer, update payment method.
warning 2nd failure Second reminder. Subscription becomes past_due.
final 3rd failure Final notice. Subscription moves to unpaid if retries are exhausted.

Payouts

Event When
payout.paid Bank payout landed.
payout.failed Bank payout was rejected — investigate bank details.

Sample payload

{
  "id": "evt_01H…",
  "object": "event",
  "type": "checkout.session.completed",
  "created": 1735689600,
  "livemode": true,
  "data": {
    "object": {
      "id": "cs_01H…",
      "object": "checkout.session",
      "amount": 4990,
      "currency": "eur",
      "status": "complete",
      "reference": "ORDER-12345",
      "customer_name": "Jane Doe",
      "customer_email": "jane@example.com",
      "payment_method": "card",
      "metadata": {}
    }
  }
}

The resource always lives at data.object — its shape mirrors the object created by the corresponding API endpoint.