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.
{
"id": "evt_01HGKM4Z7WQ4X",
"type": "payment.confirmed",
"livemode": true,
"created_at": "2026-05-15T18:23:00.000Z",
"data": {
"paymentId": "pay_9fX0a2E1",
"checkoutId": "chk_2hM1tQ",
"status": "confirmed",
"amountMinor": 4999,
"chain": "base",
"token": "USDC",
"fullySettled": true,
"settledUsdMinor": 4999
}
}As of the latest API, payment.confirmed is no longer ID-only — it carries status, amountMinor, chain, token, fullySettled, and settledUsdMinor alongside paymentId + checkoutId. You can gate fulfilment off the signed event without a follow-up GET. fullySettledis the “did they pay enough” signal — true only when the on-chain amount met or exceeded the expected target. A dust-band underpayment can auto-credit (status: "confirmed") yet read fullySettled: false, so gate access on fullySettled, not on status alone. settledUsdMinor is the on-chain value in USD cents (exact for the 1:1 USD-pegged stablecoins; null when not derivable). The leaner payment.pending / payment.failed events stay ID-only, and full merchant metadata stays off every event — GET the payment for that.
Matching events to your own records: your system knows checkoutId the moment you create the checkout, but paymentId is minted later — when the on-chain payment is first detected — so a pending row you stored at checkout time has no paymentId to match on yet. Correlate incoming payment.* events by data.checkoutId, then persist data.paymentIdfrom the first event you see — it's the stable key for refunds and reversals later.
This is the only shape we send to your endpoint. The event name is always at type (never event or eventType), and the resource is always at data (never object, payload, or data.object). You do not need to defensively parse alternate envelope shapes.
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. This catalog is rendered from the same published enum the API validates against and the dashboard's event picker reads, so it cannot drift out of sync.
allowance.depleted— Autopay spending cap exhausted (published; not yet emitted)
checkout.created— Hosted checkout session createdcheckout.expired— Checkout expired before paymentcheckout.succeeded— Checkout paid and settled
commission.accrued— Affiliate commission recorded on a settled salecommission.adjusted— Affiliate commission reduced after a partial refundcommission.paid— Affiliate commission marked paid outcommission.voided— Affiliate commission canceled (full refund or re-org)
customer.created— New customer record createdcustomer.deleted— Customer record deletedcustomer.updated— Customer details changed
entitlement.granted— Access manually granted to a customerentitlement.revoked— A manual access grant was revoked
invoice.created— Invoice draftedinvoice.paid— Invoice marked paidinvoice.past_due— Invoice passed its due date unpaidinvoice.reminder_sent— Payment reminder emailed to the customerinvoice.sent— Invoice delivered to the customerinvoice.voided— Invoice voided
payment.confirmed— Settlement finalized on-chainpayment.failed— Transaction reverted or underpaidpayment.pending— Transaction detected, awaiting confirmationspayment.refunded— Payment fully refundedpayment.reorg_suspected— Confirming block may have re-orged — hold fulfilmentpayment.reorged— Payment invalidated by a chain re-orgpayment.reversed— A previously-confirmed payment was reversed
price.created— New price createdprice.updated— Price details changed
product.created— New product createdproduct.updated— Product details changed
refund.broadcast— Refund transaction broadcast on-chainrefund.confirmed— Refund confirmed on-chainrefund.initiated— Refund created by the merchant
subscription.canceled— Subscription ended by user or merchantsubscription.created— New subscription activatedsubscription.past_due— Renewal missed — dunning activesubscription.paused— Subscription paused by the merchantsubscription.payment_failed— A renewal charge failedsubscription.plan_changed— Subscription moved to a different plansubscription.renewed— Recurring charge succeededsubscription.resumed— Paused subscription resumedsubscription.trial_ended— Trial period ended
wallet.connected— Settlement wallet addedwallet.removed— Settlement wallet removedwallet.verified— Settlement wallet ownership verified
webhook.endpoint.created— A webhook endpoint was createdwebhook.endpoint.test— Test event dispatched to an endpoint
The four commission.* events are affiliate-ledger signals. The affiliate program is inertuntil a merchant enables it, so these fire only once you've turned it on — and no funds move through OpenSettle when they do (the commission ledger is non-custodial). allowance.depleted is published but not yet emitted (its ingest path is not wired).
Payload shapes
The envelope is identical for every event; only data differs. The notable shapes:
payment.confirmed—paymentId+checkoutIdplusstatus,amountMinor,chain,token,fullySettled,settledUsdMinor(see the envelope example above; gate fulfilment onfullySettled).payment.pending— ID-only.payment.failed— ID-only + failureReason.payment.refunded— full Payment underdata.payment.payment.reorg_suspected— carries reorg context (chain reader saw head divergence at the relevant depth; reversible — may transition back to confirmed).payment.reorged— carries reorg context (settlement no longer canonical; emitted by an operator action).payment.reversed— fired alongsidepayment.reorg_suspectedby the reorg-afterglow sweep; treat as the authoritative rollback signal. If your business already shipped goods, this is your trigger to recover.subscription.renewed—data = { subscription, invoice, subscriptionId, nextBillingDate }(full subscription + the paid invoice; legacysubscriptionId/nextBillingDatekept additively; only fires on a confirmed renewal payment).checkout.expiredandcustomer.deletedare ID-only.- Most other events embed the full resource under a named key (
data.invoice,data.subscription,data.checkout,data.customer,data.wallet).
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.
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);
}Reject timestamps outside a ±300 second tolerance (the value used above, and the default in the official SDK's verifyWebhook helper). This bounds replay of a captured delivery while tolerating normal clock skew between your server and ours.
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 500 characters).
- One-click Replay on every row — re-enqueues the delivery; idempotency means double-clicking is safe.