Skip to main content
DeveloperWebhooks

Webhooks.

Every state change in your account can be delivered to your server as a signed, idempotent webhook. Retries use exponential backoff over up to ten attempts. All events are available via replay from the dashboard.

Event envelope

Every webhook request body is a JSON object with the same five top-level keys. dataholds the event-specific payload. The dashboard's GET /v1/events response is similar but uses createdAt and payload instead — the webhook wire is the snake-cased version.

payment.confirmed (actual wire shape)
{
  "id": "evt_01HGKM4Z7WQ4X",
  "type": "payment.confirmed",
  "livemode": true,
  "created_at": "2026-05-15T18:23:00.000Z",
  "data": {
    "paymentId": "pay_9fX0a2E1",
    "checkoutId": "chk_2hM1tQ"
  }
}

For ID-only events like payment.confirmed, fetch the full object via GET /v1/payments/{paymentId} to read amount, chain, token, and transaction hash. Most other events embed the full resource under a named key (data.invoice, data.subscription, data.checkout, data.customer, data.wallet).

Event types

Subscribe to any of the following. Names are stable; new types are additive.

  • payment.pending — ID-only
  • payment.confirmed — ID-only
  • payment.failed — ID-only + failureReason
  • payment.refunded — full Payment under data.payment
  • payment.reorg_suspected — ID-only (chain reader saw head divergence at the relevant depth; reversible — may transition back to confirmed)
  • payment.reorged — ID-only (settlement no longer canonical)
  • payment.reversed — fired with payment.reorged; treat as authoritative rollback signal. If your business already shipped goods, this is your trigger to recover.
  • refund.initiated · refund.broadcast · refund.confirmed
  • checkout.created · checkout.succeeded · checkout.expired (last is ID-only)
  • invoice.created · invoice.sent · invoice.paid · invoice.voided · invoice.past_due · invoice.reminder_sent
  • subscription.created · subscription.paused · subscription.resumed · subscription.canceled · subscription.past_due · subscription.payment_failed · subscription.plan_changed · subscription.trial_ended
  • subscription.reneweddata = { subscription, invoice, subscriptionId, nextBillingDate } (full subscription + the paid invoice; legacy subscriptionId/nextBillingDate kept additively; only fires on a confirmed renewal payment)
  • customer.created · customer.updated · customer.deleted (last is ID-only)
  • wallet.connected · wallet.verified · wallet.removed
  • product.created · product.updated
  • price.created · price.updated
  • allowance.depleted
  • webhook.endpoint.created · webhook.endpoint.test — endpoint lifecycle (the latter fires from the dashboard's “Send test event” button)

Verifying signatures

Every webhook request includes an X-OpenSettle-Signature header of the form t=<unix>,v1=<hex> where v1 is the HMAC-SHA256 of `${t}.${body}` using the endpoint's signing secret.

verify.ts
import crypto from "node:crypto";

export function verify(body: string, header: string, secret: string) {
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.split("=") as [string, string]),
  );
  const ts = parts.t;
  const sig = parts.v1;
  if (!ts || !sig) throw new Error("Malformed signature header");

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${ts}.${body}`)
    .digest("hex");

  const a = Buffer.from(sig, "hex");
  const b = Buffer.from(expected, "hex");
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    throw new Error("Invalid signature");
  }
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
    throw new Error("Timestamp outside tolerance");
  }
  return JSON.parse(body);
}

Retries

We retry with exponential backoff over up to ten attempts if your endpoint doesn't return a 2xx within 15 seconds. Delays between attempts step up as: 1m, 5m, 15m, 1h, then 6h for every remaining attempt. After the final attempt the delivery is marked failed and is replayable from the dashboard.

Idempotency

Webhook delivery can send the same event twice — for example, if your server took 16 seconds to respond. Each event has a stable id (prefixed evt_) — use it as your dedup key. Storing processed event IDs for 30 days is sufficient.

Dashboard delivery log

The dashboard at /app/webhooks carries a per-endpoint delivery log with:

  • Per-delivery status pill (pending / delivering / succeeded / failed / dead), attempt count, HTTP status code, latency in ms, time since attempt.
  • Live updates via Server-Sent Events — the log auto-refreshes when a delivery transitions state, no manual reload needed. A green-dot "Live" indicator at the top right reflects the stream connection.
  • Status filter (all / pending / delivering / succeeded / failed / dead) for triage.
  • Click any row to expand a detail panel with the delivery ID, event ID, next-attempt timestamp, last error, and a response-body preview (capped at a kilobyte).
  • One-click Replay on every row — re-enqueues the delivery; idempotency means double-clicking is safe.
On GitHub