Webhooks

Settlane tells you what happened by POSTing a signed JSON body to a URL you own. This page is the reference: the delivery shape, the signature algorithm written out in full, a worked example you can check your implementation against, the retry ladder, and every event type.

The delivery

Every delivery is a POST with Content-Type: application/json and four headers that matter:

HeaderWhat it carries
Settlane-Signaturet=<unix seconds>,v1=<hex> — see below. During a secret rotation there is more than one v1.
Settlane-Event-IdThe event id, also in the body as id. Stable across every retry of the same event.
Settlane-Event-TypeThe event type, also in the body as type.
Content-TypeAlways application/json.

The body is the event object, identical to what GET /v1/events/{id} returns:

{
  "id": "evt_01K6H8Z4Q2N7VJ3M5R9T1XB0CD",
  "type": "invoice.paid",
  "livemode": true,
  "object_kind": "invoice",
  "object_id": "inv_01K6H8Z3F5P2M8W4Y6R0T7QA1B",
  "payload": { "...": "the object this event is about" },
  "created_at": "2026-09-30T00:00:00.000Z"
}

livemode is false for a test-mode event. An endpoint has no mode of its own — test and live events go to the same URL, and this field is the only thing that tells them apart. Branch on it before you touch a real ledger.

payload is the object the event is about. For an invoice.* event it is the invoice as GET /v1/invoices lists it: the fields of GET /v1/invoices/{id} without the payment details (deposit_address, token_contract, payment_uri, min_confirmations, estimated_arrival). Two invoice events carry the money movement instead of the invoice: invoice.counterparty_flagged and invoice.settled_payment_reorged. Every payout.* payload is the payout. The API reference lists each event's fields under Webhooks.

The signature algorithm

Header format. One t and one or more v1, comma-separated:

Settlane-Signature: t=<unix seconds>,v1=<hex hmac-sha256>

To verify, in five steps.

  1. Take the raw request body, byte for byte. Not a re-serialised object. JSON.parse followed by JSON.stringify produces a different string — different key order, different whitespace — and it will not verify. This is the single most common cause of a bad_signature on a genuine delivery.
  2. Split the header on ,, then each segment on its first =. Collect the one t value and every v1 value. A header with two t segments, or with no v1, is malformed — refuse it rather than guessing which one was meant.
  3. Build the signed string: the timestamp, a single ASCII full stop, then the raw body. signed = t + "." + rawBody. Nothing else, no trailing newline.
  4. Compute HMAC-SHA256(key = endpoint secret as UTF-8 bytes, message = signed as UTF-8 bytes) and render it as lower-case hex, 64 characters.
  5. Compare your digest against each v1 value with a constant-time comparison, and accept if any one matches. Then check that |now − t| is within your tolerance — 300 seconds is what Settlane assumes, and it is what the quickstart's verifySettlaneSignature defaults to.

Why several v1 values. Rotating an endpoint secret gives you an overlap window: Settlane signs each delivery once per currently-active secret and puts one v1 per secret in the same header, under a single shared t. A receiver holding either the old or the new secret still verifies, so you can roll the secret without dropping a delivery. Check your one secret against all the v1 values present.

Why the timestamp is inside the HMAC. It is signed, so it cannot be moved. Without the tolerance check a captured delivery could be replayed at any time in the future; with it, a replay window is bounded by the clock skew you are willing to accept. Reject stale — do not widen the tolerance to make an intermittent failure go away, because an intermittent stale is a clock problem on your side.

A worked example

These four values are checked by a test on every change. The test reads them off this page, recomputes the HMAC from the secret, the timestamp and the body, and checks it equals the signature printed below. Then it runs the quickstart's verifySettlaneSignature on the same four values. Use them to check your own implementation.

secret whsec_2f8Qd6hK1wRzYv4mBn7pLtXc9sJgAe3U
timestamp 1790726400
body {"id":"evt_01K6H8Z4Q2N7VJ3M5R9T1XB0CD","type":"invoice.paid","livemode":true,"object_kind":"invoice","object_id":"inv_01K6H8Z3F5P2M8W4Y6R0T7QA1B","payload":{"id":"inv_01K6H8Z3F5P2M8W4Y6R0T7QA1B","amount_cents":"1999","status":"paid"},"created_at":"2026-09-30T00:00:00.000Z"}
signature t=1790726400,v1=f03c3593ea9e7294bdbb803e3507f026a5e2e88a8cee5ed7b234285358554b72

The signed string for that example begins 1790726400.{"id":"evt_01K6H8Z4Q2N7V… and ends with the body's closing brace. If your digest differs, compare your signed string against that prefix first — the mistake is almost always in the body, not the HMAC.

Verifying it by hand, with nothing but a shell:

printf '%s' '1790726400.{"id":"evt_01K6H8Z4Q2N7VJ3M5R9T1XB0CD",...}' \
  | openssl dgst -sha256 -hmac 'whsec_2f8Qd6hK1wRzYv4mBn7pLtXc9sJgAe3U'

(That shell line is an illustration with the body elided — it is the one snippet on this page a test does not run. The four values above are the executed ones.)

Checking it in code

Copy verifySettlaneSignature from the quickstart. It follows the five steps above, uses only node:crypto, and a test checks that it gives the same answer as Settlane's own check on every edge case. It returns { ok: true } or { ok: false, reason }:

reasonWhat it meansWhose problem
missingNo Settlane-Signature header at all.Yours — usually a proxy stripping headers.
malformedThe header is not t=…,v1=… shaped. Nothing Settlane sends looks like this.Yours — wiring.
staleWell-formed, but t is outside the tolerance.Usually a clock.
bad_signatureWell-formed and wrong for this body and secret.A re-serialised body, the wrong secret, or a forgery.

Answer 400 on any of the four, never 5xx. A retry cannot make a bad signature good, and a 5xx puts Settlane on the retry ladder for a day over your own configuration.

Retries, and what they mean for your handler

A delivery is successful when your endpoint answers 2xx within 10 seconds. Anything else — including a redirect, which is never followed — is a failure and is retried on a backoff ladder.

So your handler must be idempotent on event.id. A delivery you answered slowly, a 500 you recovered from, or an operator replaying a delivery from the dashboard all arrive as the same event.id. Record the ids you have handled and return 2xx for a repeat without re-doing the work — that is what the quickstart's handler does.

Three consecutive failed attempts move the endpoint's own status to failing. An event emitted while it is failing is kept, not dropped: its delivery waits as held. Settlane tries the endpoint with one waiting delivery at a time — a minute after it went failing, then at doubling intervals up to fifteen minutes, and never sooner than that delivery's own retry ladder allows — so a destination that is down takes one request at a time. When one succeeds the endpoint is enabled again and the held deliveries go back to the queue: one never tried is sent at once, one already tried at its next retry time. A test event or a replay tries the endpoint straight away, and enabling it yourself sends everything held at once. Each delivery keeps its own retry ladder: one that has failed eight times is abandoned, and can be replayed from the dashboard. Only you disable an endpoint; a disabled one receives nothing more (an attempt already under way when you disable it, or change its URL, finishes as it began). The status is visible on the endpoint in the dashboard and through GET /v1/webhook_endpoints.

Every event type

typeWhat it means
invoice.payment_detectedA payment has been seen on the chain. Nothing is credited yet — do not ship on this.
invoice.paidSettled in full and on time. This is the one to credit the customer on.
invoice.underpaidMoney was credited and it is not enough. The invoice stays open.
invoice.expiredThe invoice passed expires_at with nothing credited.
invoice.paid_lateSettled in full, after expires_at but inside the late window. Credit it or refund it — the decision is yours.
invoice.cancelledThe invoice was cancelled.
invoice.counterparty_flaggedThe address that paid this invoice was flagged by screening after the fact.
invoice.settled_payment_reorgedA payment Settlane had confirmed was taken back by a deep reorg. THE INVOICE STAYS paid — this event is the only thing that tells you the money is not there.
payout.pending_approvalA payout needs a person.
payout.queuedA payout was approved and is waiting for the executor.
payout.broadcastA payout was signed and sent to the chain.
payout.completedA payout settled.
payout.failedA payout attempt failed.
payout.cancelledA payout was cancelled.
payout.heldA payout is waiting out a time-based rule (a destination nobody has vouched for yet). An operator's approval releases it early.
payout.refusedScreening, or the signer, refused a payout. No transaction was ever built; refund your customer.
customer.kyc.updatedA customer's identity verification reached a decision.
customer.funds_receivedMoney reached a customer's own deposit address and no open invoice took it; it is now that customer's funds. Create an invoice for what it buys and the funds pay it.
customer.counterparty_flaggedThe address that sent money to a customer's own deposit address was flagged by screening, and the flag cannot be tied to one invoice: no invoice took the money, or the screening only answered later. The money is in the customer's funds. A flag found when the movement went to an invoice is reported as invoice.counterparty_flagged on that invoice instead.
wallet.low_gasA hot wallet is running out of native currency and will stop being able to send.
wallet.unexpected_fundsMoney arrived somewhere it was not expected — past an invoice's late window, or to an address with no open invoice.
screening.refusedA screening refused an address.

An endpoint receives every type by default. Pass event_types when you create it to narrow that, and be aware that narrowing is how people miss invoice.settled_payment_reorged.

Creating an endpoint

POST /v1/webhook_endpoints requires a live-mode key. An endpoint is a destination for real money events, so a sk_test_ key is refused with LIVE_MODE_REQUIRED (403) — create it with your live key, or in the dashboard. The secret comes back exactly once, on the create that actually ran: a create replayed from its Idempotency-Key answers the stored body, which omits it. Store it then, or rotate it from the dashboard.

POST /v1/webhook_endpoints/{id}/test sends a synthetic event to one endpoint, bypassing its own event_types filter, so you can prove your handler is reachable before any money is involved.

Next