Webhooks

Webhooks tell your server when something happens in Spondo Billing: an invoice is paid, a checkout completes, a customer runs out of credits. Use them to keep your product in step, for example to unlock a plan's features once its checkout completes, or to warn a customer whose credits ran out.

Events

Each event is a webhook of its own, with its own URL, which you switch on separately. All are off until you enable them.

EventSent whendata holds
billing-invoice-paidAn invoice is paid in full: Stripe collects it, payments allocated to it reach its total, or it's marked paid in the dashboard. An invoice with nothing to pay (a credit, or a total of zero) sends it when posted.invoice
billing-invoice-postedAn invoice is posted to the customer, by the billing run or from the dashboard, or Stripe finalises one.invoice
billing-invoice-refundedMoney paid on an invoice is refunded, as a credit note: through the API or the dashboard, or in Stripe's dashboard. Each credit note sends one.creditNote
billing-checkout-createdA checkout session opens: on Stripe Checkout, or on the checkout page.checkout
billing-checkout-completedA checkout session completes: the customer paid on Stripe Checkout, or started on an invoice. Its order is now active.checkout
billing-checkout-expiredA checkout session passes its expiry unpaid. Each session sends one.checkout
billing-payment-succeededA payment succeeds: Stripe collects an invoice, a payment is recorded, or an invoice is marked paid.payment
billing-payment-failedStripe fails to collect an invoice. Each failed attempt sends one, with its failureReason.payment
billing-credits-depletedA signal takes a customer's credit balance from above zero to zero or below. It fires again only after they're given more credits.credits
billing-overage-incurredThe first signal that goes past a customer's credit balance since their latest grant (a period's allowance or a top-up), so overage is now being charged.overage
billing-credit-cap-reachedA customer unit's spend reaches its cap: once per cap window, for each capped unit above the signal's unit. The unit's usage after that is held.breachedUnit and cap

The credit events name the orderId and the signalId that caused them. Signals replayed or backfilled more than a week after they happened don't send credit events.

invoice, creditNote and payment are the same objects Get an invoice, Get a credit note and Get a payment return, and checkout is the checkout session Get a checkout session returns. The API reference documents each event's full payload under Webhooks.

The delivery

Spondo Billing sends a POST to the webhook's URL with a JSON body:

{
  "id": "whd_9Lm2Qx7Rt4Vb1Nc8",
  "event": "billing-invoice-paid",
  "timestamp": "2026-11-10T09:15:22.114Z",
  "isTest": false,
  "data": {
    "invoice": {
      "id": "inv_3Rk8Wq1Zp6Ty0Xm2",
      "customerId": "cus_Q2x9vB7mK4pT1sLw",
      "status": "posted",
      "paymentStatus": "paid",
      "invoiceTotal": 11988,
      "currency": "USD"
    }
  }
}
FieldWhat it is
idThe delivery's ID. It stays the same when a delivery is retried, so use it to ignore repeats.
eventThe event name.
timestampWhen the event happened.
isTesttrue for deliveries from Send a test.
dataThe event's object, under the key in the table above.

Answer with any 2xx status within 5 seconds. Save the event and answer straight away, then do the work in the background.

Retries

A delivery that doesn't get a 2xx in time (an error status, a timeout, a redirect, or no connection) is tried again 1 minute, 5 minutes, 30 minutes, 2 hours and 12 hours after the attempt before: six attempts in all, after which it's dropped. Each attempt goes to the webhook's current URL, with the same body and id and a fresh signature, and switching the webhook off drops the retries still to come. So:

  • Handle a delivery more than once safely: skip an id you've already processed.
  • Don't rely on order: a retried delivery can arrive after a later event.

lastSentAt and lastStatus on each webhook show how its latest attempt went.

Set up a webhook

Set the URL and enable the webhook with PATCH /webhooks/{name}:

curl https://billing.getspondo.com/api/v1/webhooks/billing-invoice-paid \
  --request PATCH \
  --header "Authorization: Bearer $SPONDO_API_KEY" \
  --header "Content-Type: application/json" \
  --data '{ "url": "https://documind.example/webhooks/spondo", "enabled": true }'
await spondo.request("PATCH", "/webhooks/billing-invoice-paid", {
  body: { url: "https://documind.example/webhooks/spondo", enabled: true },
});

The URL must use https and be a public host: localhost, private and reserved IP ranges and names such as .internal are refused with 400, both when you set the URL and on every delivery. To receive webhooks on your laptop, use a tunnel with a public HTTPS address.

GET /webhooks lists all eleven with their URL, whether each is enabled, and the result of the last delivery (lastSentAt, lastStatus). To stop one, send { "enabled": false }.

Verify signatures

Every delivery is signed with your account's signing secret, so you can check it came from Spondo Billing and wasn't changed. One secret signs all eleven webhooks. It starts whsec_.

The first time you enable a webhook, the response includes the secret as signingSecret. That is the only time it's shown; GET /webhooks says only whether one is set (signingSecretConfigured). If you didn't keep it, make a new one with POST /webhooks/rotate-secret, which returns it:

curl https://billing.getspondo.com/api/v1/webhooks/rotate-secret \
  --request POST \
  --header "Authorization: Bearer $SPONDO_API_KEY"
{ "signingSecret": "whsec_5xk4llAWibM2TBw2vTgB-VtXORJcFgEV" }

Rotating replaces the secret at once: deliveries from then on are signed with the new one only. Update your server's secret straight after rotating, or accept both secrets for a few minutes while you switch.

The signature

The X-Webhook-Signature header holds a timestamp and a signature:

X-Webhook-Signature: t=1794302122118,s=QYeJY4dQpUQPKZWCI0MfQbQw6BCkRcLKv6kej3c5FV8=
  • t is when the delivery was signed, in milliseconds since the Unix epoch.
  • s is the Base64 HMAC-SHA256 of <t>.<raw body>, keyed with the whole secret, whsec_ prefix included.

To verify a delivery:

  1. Split the header on ,, and each part on its first = (the signature itself can end in =).
  2. Reject the delivery if t is more than five minutes from now. This stops an old delivery being replayed.
  3. Compute the HMAC-SHA256 of t, a ., and the body exactly as it arrived, with your secret.
  4. Compare it with s in constant time.
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifySpondoWebhook(rawBody: string, header: string, secret: string, toleranceMs = 5 * 60 * 1000) {
  const parts: Record<string, string> = {};
  for (const part of header.split(",")) {
    const i = part.indexOf("=");
    parts[part.slice(0, i)] = part.slice(i + 1);
  }
  const t = Number(parts.t);
  if (!Number.isFinite(t) || Math.abs(Date.now() - t) > toleranceMs) return false;
  const expected = createHmac("sha256", secret).update(`${parts.t}.${rawBody}`).digest();
  const received = Buffer.from(parts.s ?? "", "base64");
  return received.length === expected.length && timingSafeEqual(received, expected);
}

Verify the raw body, before any JSON parsing: re-serialising the JSON changes the bytes and the signature won't match. With Express, give the route express.raw():

import express from "express";

app.post("/webhooks/spondo", express.raw({ type: "application/json" }), async (req, res) => {
  const ok = verifySpondoWebhook(req.body.toString("utf8"), req.get("x-webhook-signature") ?? "", process.env.SPONDO_WEBHOOK_SECRET!);
  if (!ok) return res.sendStatus(400);

  const { event, data } = JSON.parse(req.body.toString("utf8"));
  await saveEvent(event, data); // then handle it in the background
  res.sendStatus(200);
});

Test a webhook

POST /webhooks/{name}/test sends a signed test delivery to the webhook's URL. Enable the webhook first: testing a disabled one fails with 400. A test is sent once and never retried. Its body has "isTest": true and placeholder data, such as { "invoice": { "id": "invoice_test", "customerId": "cus_test", "externalCustomerId": "test_customer" } }. The response says whether your server answered 2xx in time:

{ "success": true }