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",
    "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
  • allowance.depletedAutopay spending cap exhausted (published; not yet emitted)
checkout
  • checkout.createdHosted checkout session created
  • checkout.expiredCheckout expired before payment
  • checkout.succeededCheckout paid and settled
commission
  • commission.accruedAffiliate commission recorded on a settled sale
  • commission.adjustedAffiliate commission reduced after a partial refund
  • commission.paidAffiliate commission marked paid out
  • commission.voidedAffiliate commission canceled (full refund or re-org)
customer
  • customer.createdNew customer record created
  • customer.deletedCustomer record deleted
  • customer.updatedCustomer details changed
entitlement
  • entitlement.grantedAccess manually granted to a customer
  • entitlement.revokedA manual access grant was revoked
invoice
  • invoice.createdInvoice drafted
  • invoice.paidInvoice marked paid
  • invoice.past_dueInvoice passed its due date unpaid
  • invoice.reminder_sentPayment reminder emailed to the customer
  • invoice.sentInvoice delivered to the customer
  • invoice.voidedInvoice voided
payment
  • payment.confirmedSettlement finalized on-chain
  • payment.failedTransaction reverted or underpaid
  • payment.pendingTransaction detected, awaiting confirmations
  • payment.refundedPayment fully refunded
  • payment.reorg_suspectedConfirming block may have re-orged — hold fulfilment
  • payment.reorgedPayment invalidated by a chain re-org
  • payment.reversedA previously-confirmed payment was reversed
price
  • price.createdNew price created
  • price.updatedPrice details changed
product
  • product.createdNew product created
  • product.updatedProduct details changed
refund
  • refund.broadcastRefund transaction broadcast on-chain
  • refund.confirmedRefund confirmed on-chain
  • refund.initiatedRefund created by the merchant
subscription
  • subscription.canceledSubscription ended by user or merchant
  • subscription.createdNew subscription activated
  • subscription.past_dueRenewal missed — dunning active
  • subscription.pausedSubscription paused by the merchant
  • subscription.payment_failedA renewal charge failed
  • subscription.plan_changedSubscription moved to a different plan
  • subscription.renewedRecurring charge succeeded
  • subscription.resumedPaused subscription resumed
  • subscription.trial_endedTrial period ended
wallet
  • wallet.connectedSettlement wallet added
  • wallet.removedSettlement wallet removed
  • wallet.verifiedSettlement wallet ownership verified
webhook
  • webhook.endpoint.createdA webhook endpoint was created
  • webhook.endpoint.testTest 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 + checkoutId plus status, amountMinor, chain, token, fullySettled, settledUsdMinor (see the envelope example above; gate fulfilment on fullySettled).
  • payment.pending — ID-only.
  • payment.failed — ID-only + failureReason.
  • payment.refunded — full Payment under data.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 alongside payment.reorg_suspected by 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; legacy subscriptionId/nextBillingDate kept additively; only fires on a confirmed renewal payment).
  • checkout.expired and customer.deleted are 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.

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);
}

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.
On GitHub