Webhooks
Receive, verify and retry event deliveries.
Webhooks push platform events to your own HTTPS endpoint as they happen, so you don't have to poll for state changes.
Endpoints
Manage endpoints via /v1/webhook-endpoints — create, list, fetch, update, delete,
and rotate the signing secret. Two policies are enforced on every endpoint URL:
- HTTPS only. Plain-
http://URLs are rejected at registration time. - No redirects, 10-second timeout. Delivery attempts don't follow
3xxresponses — a redirect counts as a failed attempt, the same as a non-2xxstatus or a timeout.
The signing secret is returned exactly once: on creation, and again on each rotation. During a rotation window the previous secret stays valid alongside the new one, so in-flight verification doesn't break.
curl -X POST https://api.rigid.fi/v1/webhook-endpoints \
-H "Authorization: Bearer $RIGID_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"url": "https://example.com/webhooks/rigid",
"event_types": ["card.created", "authorization.decided", "cardholder.updated"]
}'Reference: Create a webhook endpoint.
Verifying signatures
Every delivery carries an X-Platform-Signature header:
X-Platform-Signature: t=1610000000,v1=<hex hmac-sha256>t is the Unix timestamp the delivery was signed at. Each v1 is the hex
HMAC-SHA256 of `${t}.${rawBody}` (the exact bytes of the delivered body, not
a re-serialization), keyed by one of the endpoint's active secrets. During a
secret rotation you'll see two v1 values — accept the delivery if any of
them matches.
const crypto = require("node:crypto");
function verifySignature(secret, rawBody, header, toleranceSeconds = 300) {
const parts = header.split(",").map((p) => p.trim());
let t;
const signatures = [];
for (const part of parts) {
const eq = part.indexOf("=");
if (eq === -1) continue;
const scheme = part.slice(0, eq);
const value = part.slice(eq + 1);
if (scheme === "t") t = Number(value);
else if (scheme === "v1" && value !== "") signatures.push(value);
}
if (t === undefined || signatures.length === 0) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false;
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const expectedBuf = Buffer.from(expected, "hex");
return signatures.some((candidate) => {
const candidateBuf = Buffer.from(candidate, "hex");
return (
candidateBuf.length === expectedBuf.length &&
crypto.timingSafeEqual(candidateBuf, expectedBuf)
);
});
}Compute the HMAC over the raw request body — parse it for your own use only after verification, since re-serializing JSON can change the byte sequence the signature was computed over.
Retries
A delivery gets one immediate attempt, then up to five retries at fixed offsets
from that first attempt: 1 minute, 5 minutes, 30 minutes, 2 hours, and 24
hours. If the final retry still fails, the platform stops attempting that
delivery — it does not keep retrying indefinitely, and nothing tells you it gave
up: no webhook event is delivered for the final failure, and the event log
(GET /v1/events) lists events, not delivery
outcomes. Use redelivery to send an event again.
Delivery guarantees
Delivery is at-least-once, with no ordering guarantee. Build your endpoint for both.
Deduplicate on the event id. The same event can reach you more than once: an
attempt your endpoint processed but did not acknowledge in time is retried, and a
redelivery sends the event again. Every delivery of one event
carries the same id, a UUID, whatever caused it — record the ids you have
processed and skip repeats.
Don't rely on arrival order. Retries run on their own schedule and a
redelivery arrives whenever you ask for it, so an older event can land after a
newer one. The envelope can't tell you that happened: it carries no sequence
number, and created_at is when the platform recorded the change, not when the
delivery reached you. (The exceptions are narrow: programme events carry a
per-programme data.version, and the sequence on
clearing.posting_instruction.created numbers presentments within one ARN, not
events.)
The API is the source of truth. An event tells you that something changed. To
know how things stand now, read the resource it names — for example
GET /v1/cards/{id},
GET /v1/cardholders/{id},
GET /v1/kyc/checks/{id} or
GET /v1/transactions/{id} — and do the
same after any gap. At-least-once holds while retries last: a delivery that
exhausts its retries stops, and an event our own publishing fails to
hand over never reaches your endpoint or GET /v1/events at all. Neither loses
the change itself; the resource still has it.
Never compute a balance by summing webhook deltas. Duplicates and reordering
break a running total, and some balance movements publish no hold event at all: a
hold that lapses, a hold lowered by a partial reversal, and a hold raised by an
incremental authorization. Read the balance from
GET /v1/cards/{id}/balance. A figure an
event does carry, such as data.available, is the balance as of that event, not
necessarily now.
Event catalog
Every delivery is wrapped in the same envelope:
{
"id": "0198e2c1-4b5d-7c42-b8d1-6a2f5c0e77b3",
"type": "card.created",
"created_at": "2026-08-07T12:00:00Z",
"data": { "...": "event-specific payload" }
}See the full event catalog for every event type you can
subscribe to and the payload shape of each. A handful of catalogued types are
marked planned rather than live — they're a committed contract (schema and
docs are final) but no producer emits them yet, so subscribing to one is safe but
will never deliver until it ships; each type's own page states which it is.
authorization.decided's decline_reason field is a closed vocabulary — see the
Decline reasons guide for every value it can
carry and safe copy to show a customer for each.
Money flow
An authorization is a promise, not a payment. Which events you receive tells you whether money has
actually moved yet: a decision alone moves nothing, and the hold events below say what happened to
the reservation. They say what each event means, not the order deliveries arrive in — two events
about one sale can reach you in either order (Delivery guarantees) — and
data.available on an event is the balance as of that event. Read the balance now from
GET /v1/cards/{id}/balance.
Managed mode (default): a hold, then a capture or a release
In Managed mode, Rigid holds the cardholder's balance for you. A card being used produces one decision event and, on an approval, a reservation event — never a posting by itself:
What happens next depends on what the merchant's acquirer does with the sale:
- The sale clears —
ledger.hold.capturedfires, carryingtransfer_idandsettlement_account_id: this is the one event that means money actually left the card. It usually arrives within about a minute of the sale clearing on the network. - The sale is reversed —
ledger.hold.releasedfires instead: the reservation ends andavailablereturns to what it was, with nothing ever taken. - The sale never clears and the hold simply lapses — no event fires. A hold carries an
expiry, and once it passes, the reservation stops counting against
availableon its own. Nothing is emitted to your endpoint when that happens, so a webhook consumer waiting for one waits forever. Read the outcome from the card's balance instead: once the hold lapses,GET /v1/cards/{id}/balancestops counting it inheld, andavailablerises by its amount.
A refund is different from both: crediting a cardholder isn't a reservation, so it posts
immediately as an ordinary ledger movement — authorization.decided (decision: "approved") for
the refund request itself, and ledger.entry.posted for the credit, in whichever order they
arrive. There is no hold event for a refund.
{
"id": "0198e2c1-7e8f-7c42-b8d1-6a2f5c0e77b3",
"type": "ledger.hold.captured",
"created_at": "2026-08-21T12:00:03Z",
"data": {
"hold_id": "0198e2c1-9f3a-7c42-b8d1-6a2f5c0e77b3",
"account_id": "acct_0198e2c1-a1b2-7c42-b8d1-6a2f5c0e77b3",
"authorization_id": "0198e2c1-b2c3-7c42-b8d1-6a2f5c0e77b3",
"transfer_id": "0198e2c1-c3d4-7c42-b8d1-6a2f5c0e77b3",
"settlement_account_id": "acct_0198e2c1-d4e5-7c42-b8d1-6a2f5c0e77b3",
"amount": "42.50",
"asset": "USD",
"available": "157.50",
"occurred_at": "2026-08-21T12:00:03Z"
}
}Gateway mode (coming soon)
Gateway mode is for a programme that keeps its own ledger and only wants Rigid to relay the network decision — Rigid never reserves or moves money for it. It's built but not yet enabled for any programme; the shape below is worth knowing ahead of time if you're planning an integration around it.
authorization.decided still fires exactly as in Managed mode, but there is no ledger.hold.*
event — nothing was reserved. Instead, an approval (full or partial) also emits
authorization.posting_instruction.issued, carrying the approved amount and the correlation
anchors (approval_code, network_ref) you need to post the movement in your own books. A decline
emits no posting instruction at all — there's nothing to record.
{
"id": "0198e2c1-6d7e-7c42-b8d1-6a2f5c0e77b3",
"type": "authorization.posting_instruction.issued",
"created_at": "2026-08-21T12:00:00Z",
"data": {
"type": "purchase",
"direction": "debit",
"authorization_id": "0198e2c1-b2c3-7c42-b8d1-6a2f5c0e77b3",
"card_id": "0198e2c1-e5f6-7c42-b8d1-6a2f5c0e77b3",
"amount": "42.50",
"currency": "USD",
"approval_code": "A1B2C3",
"network_ref": "000000418027",
"mcc": "5411",
"merchant": { "name": "Corner Grocer", "country": "US" },
"occurred_at": "2026-08-21T11:59:59Z"
}
}Later, when the sale clears on the network, clearing.posting_instruction.created confirms the
closure — its hold_id is always null on Gateway mode, since nothing was ever reserved on Rigid's
side.
Stand-in and declared balances
Your own endpoint's live answer always wins — but only when it's a well-formed answer we can act on. Rigid falls back to a stand-in decision whenever your endpoint doesn't produce one: a timeout, a connection failure, or a response that answers but fails our validation (a body we can't relay to the acquirer, a partial that isn't legal for this transaction, and so on). A response we successfully validate — approve or decline — is the one case that skips stand-in entirely; a response we receive but can't use is treated the same as no response at all, not as "answered."
Three independent ceilings bound a stand-in approval. All three must allow it; any one of them refusing is a decline.
- A flat per-transaction cap (
max_amount) you configure per programme. No single stand-in may exceed it. - A cumulative exposure budget for the outage (
max_total_exposure), also per programme. We add up every stand-in we approve on your behalf and stop once that running total would exceed the budget — so the ceiling bounds the money we put at risk while you're unreachable, not how many times we were asked. The total resets when the outage ends, not at midnight: the first successful call back to your endpoint closes the session and zeroes the sum. A long outage therefore declines once the budget is spent, and keeps declining until you're reachable again. One other thing zeroes it — see the currency note below. (This replaced an earlier per-day count of stand-ins, which bounded the wrong thing. If you configuredmax_count_per_day, it is still stored and still returned when you read your policy back, but it no longer influences any decision.) - A declared balance you register per card. If you can tell us your own read on a card's current balance, we'll use it as a second, tighter ceiling on any stand-in we approve for that card. If no balance has ever been declared for a card, stand-in is off for that card — we decline rather than fall back to the flat programme cap alone. There's no expiry on a declared balance: once you write one, it stays in effect until you overwrite it with a new figure. If your own systems are also the ones down during the outage that triggers stand-in, the balance we fall back to is necessarily whatever you last told us — not a live figure. Plan your integration around that; we don't smooth it over with a timer that quietly reverts to "no balance" for you.
Set both max_amount and max_total_exposure. A policy missing either one cannot stand in
at all — we decline every stand-in against it, rather than guess at an unspecified ceiling. Both
are optional on the wire, so a policy carrying only one of them stores successfully and then
declines everything during your first real outage. If a programme stood in for nothing it should
have approved, check that both are present before looking anywhere else.
Knowing when an outage ended.
authorization.stip_outage.finished
is delivered when the first delegated call reaches you and answers usably again, after we have
been standing in for you. It carries the programme and the instant the outage ended, and nothing
else. That same call also attempts to zero your stand-in exposure budget — normally it succeeds,
but the two are separate operations, so receiving this event is not proof your budget reset. If
stand-ins decline unexpectedly soon after a recovery, a budget that failed to clear is the first
thing to check with us.
It tracks connectivity, not exposure — including outages in which we approved nothing. The
outage is recorded the moment we fall back to stand-in, before your policy is consulted, so it does
not matter what the stand-in then decided. A programme with mode: decline_all, or an outage where
every stand-in was refused by your caps, your MCC list, a currency mismatch or a missing declared
balance, is covered on the same terms as one where we approved.
It is emitted once, on the transition — not on every healthy authorization afterwards — and
concurrent calls arriving as you recover cannot make it emit twice. Emitted once is not delivered
once: like every event it can reach you more than once, so deduplicate it on its id
(Delivery guarantees).
It is best-effort, not a guarantee, and it is worth knowing why before you depend on it. Recording an outage is a write we make while already degraded; if that write fails we tell ourselves loudly, but the outage can then end without an event. Your own endpoint receiving traffic again is the signal that cannot fail this way, because it does not depend on any bookkeeping of ours succeeding — so treat this event as the convenient notification and your own traffic as the authority. Nothing about your money is affected either way: your caps and declared balances are enforced from separate state that this does not touch.
Three things it is not. It is not a delivery guarantee for the decisions made during the outage
— it tells you the window closed, not what happened inside it. It does not imply we approved
anything on your behalf: an outage we declined our way through produces an identical event, which
is deliberate, since what you want to know is that your endpoint is reachable again. The
exposure budget is the separate question of how much we risked
while you were unreachable. And it is not the only signal available to you: your own endpoint
receiving traffic again is the same fact observed from your side, worth building on too since it
does not depend on our delivery succeeding. If push delivery is failing, do not treat
GET /v1/events as a complete replay: read current state from the API, as
Delivery guarantees describes, and use redelivery to send a
known event again.
There is a customer-facing stand-in report — GET /v1/programs/{id}/stip-activity, described
in Reading back what we stood in for below — but do not use
it to detect recovery either. It records stand-in decisions, so a quiet window means "no
stand-in resolved a card", never "the outage is over"; and the classes it structurally cannot show
are largest during exactly the failure you would be watching for. Read it to audit what we decided
on your behalf, not to decide when to resume.
We never convert currencies to stand in. The exposure budget carries its own currency, and a stand-in is only attempted when the transaction's currency matches it exactly — a mismatch declines outright rather than being converted at some rate we'd have to pick on your behalf. If your programme sees genuinely multi-currency traffic, expect stand-in to cover only the currency you declared.
Changing that currency mid-outage starts the budget over. The running total is denominated in
the currency your policy declared when the outage began. If you update the policy to a different
currency while an outage is still in progress, the next stand-in sees a total in a currency that
no longer applies and resets it to zero, then accumulates afresh under the new one — we will
not add two denominations together, since doing so would misstate your exposure by an exchange
rate we have no business picking. The consequence is worth stating plainly: a single outage can
cost you more than one max_total_exposure if the declared currency changes during it, and
switching back and forth starts a fresh budget each time. If you are tempted to change the
currency mid-incident to unblock declines on another currency's cards, that is the trade you are
making. Prefer waiting until the outage has ended.
You bear the loss on a stand-in approval that turns out to be unfunded. Gateway mode places no ledger hold on either path — Rigid never reserves or moves money for your programme's cards — so the balance of record has always been yours, on every card, in real time. Standing in doesn't change that: we're deciding on your behalf, over money we never held, using the most recent figure you gave us. If that figure was stale or wrong and the transaction goes through, the shortfall is yours to absorb, the same as it would be if your own endpoint had approved it directly.
Declaring a balance is not yet self-service. Gateway mode itself is still shipping dark (see
above), and the write path for declared balances isn't published in this catalogue or the public
API reference — it isn't reachable through dev.api.rigid.fi the way every operation documented
elsewhere on this site is. If you're integrating a gateway-mode programme, talk to your Rigid
integration contact about provisioning it; this section describes the mechanism you're agreeing
to, not yet a self-serve endpoint you can call today.
Reading back what we stood in for
Because the loss on an unfunded stand-in approval is yours, the record of those approvals is
yours to read. GET /v1/programs/{id}/stip-activity returns every stand-in decision we recorded
for a programme in a window — approvals and declines — with the rule that fired and the
exposure each one created:
curl "https://api.rigid.fi/v1/programs/{id}/stip-activity?from=2026-09-01T00:00:00Z&to=2026-09-05T00:00:00Z" \
-H "Authorization: Bearer $RIGID_API_TOKEN"Declines are in the report deliberately. A window holding only approvals can't show that the stand-in path was reached at all on a programme that declined everything — which is exactly the question to ask during an outage of your own endpoint.
exposure_minor is a string, in minor units. It's the figure you're liable for, and it's the
one number in the response that must never be parsed into a JavaScript number: values above 2^53
lose precision silently, and this is the field where that would matter.
Both window bounds are required, and the window can't exceed 90 days. Page with starting_after,
using the next_cursor from the previous page.
unattributed_count reads 0 today, and a 0 here does not mean nothing is missing. The
field counts recorded stand-ins that carry no programme. No part of the platform currently writes
such a row — a stand-in is only recorded once the card has resolved, and a resolved card always
names a programme — so the number is 0 for every window. It exists so that a future producer of
programme-less rows cannot make them silently vanish from your view; treat a non-zero value as a
signal to raise with your Rigid contact, and treat 0 as saying nothing at all.
{
"data": [ /* this programme's stand-ins */ ],
"has_more": false,
"next_cursor": null,
"unattributed_count": 0
}What this report genuinely cannot show, and why that matters most on the worst day. Three
classes of stand-in never reach it, and none of them is captured by unattributed_count:
- A stand-in taken before your card could be resolved — the cards dependency was down, or the decision deadline was already exceeded on arrival. There is no row that you or your webhooks will ever see for this class, because nothing knows which tenant it belonged to. During a cards outage this is the entire population.
- A stand-in taken at the network edge, before the request reached the decision engine at all. It happens because the engine did not answer, so the engine never sees it.
- A stand-in the engine decided but did not commit — the replay authority or the idempotency memo could not be read, so the decision was returned without being recorded.
So an empty window means "no stand-in that resolved a card was recorded here". It never means "no stand-in happened". If you are reconciling a window that overlaps an incident, ask us — the figures above are a floor by construction.
Catalogued, not yet live
transaction.cleared and clearing.exception.raised are documented in the event
catalog — schema final, no producer yet — for the eventual
Managed-mode clearing lifecycle (a presentment matching or failing to match its authorization).
Subscribing to either is safe; neither will deliver until it ships.
Redelivery
Trigger a fresh delivery attempt for a past event to a given endpoint:
curl -X POST https://api.rigid.fi/v1/events/{id}/redeliver \
-H "Authorization: Bearer $RIGID_API_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{ "endpoint_id": "<endpoint-id>" }'Reference: Redeliver an event.