Webhooks — coming
Webhooks aren't switched on in this environment yet — the endpoints below answer not_enabled until they are. The contract they keep:
Register an endpoint
An organization admin registers an https endpoint (from Settings → Developers or the API). The events list narrows what you receive — leave it empty for all, including events added later. The response includes the signing secret, shown once.
curl -X POST "https://api.rivet.network/v1/webhooks" \
-H "Authorization: Bearer rk_live_…" \
-H "Content-Type: application/json" \
-d '{"url":"https://acme.example/hooks/rivet","events":["document.status_changed"]}'| Field | Type | Notes |
|---|---|---|
| id* | string | The endpoint's id. |
| url* | string | Where deliveries are POSTed (https). |
| events* | WebhookEvent[] | Subscribed events; empty means all, including events added later. |
| status* | ACTIVE | DISABLED | DISABLED after 20 consecutive terminal failures; re-enable resets the counter. |
| disabled_reason* | string | null | Why it was disabled, when it was. |
| last_success_at* | string | null | Most recent successful delivery. |
| last_failure_at* | string | null | Most recent failed delivery. |
| previous_secret_valid_until* | string | null | While a rotation window is open (24h after rotate_secret) the previous secret still signs every delivery; this is when it stops. null otherwise. |
The events
Deliveries carry the document lifecycle — document.created, document.status_changed, document.edited — and the payment lifecycle, first-class: payment.settled (with the settlement reference when the money rail drove it) and payment.failed (with the reason; the document is payable again). Each is projected for your organization: the same document your key would read, so a webhook to you shows your Bill where the counterparty's endpoint shows their Invoice. A sandbox organization's deliveries carry "sandbox": true at the envelope's top level — a simulated settlement is never mistakable for a live one.
{
"id": "evt_6a5eed00000000000000ee01",
"type": "document.status_changed",
"api_version": "1.3.0",
"created_at": "2026-09-02T15:04:05.000Z",
"data": {
"document": {
"id": "6a5eed00000000000000d001",
"direction": "received",
"label": "Bill",
"number": "BILL-0042",
"status": "approved",
"…": "…the same fields GET /v1/documents/{id} returns for you…"
}
}
}Verify every delivery
Every request carries Rivet-Signature in the form t=<unix seconds>,v1=<hex> — the timestamp the signature covers and one v1= entry per active secret (two for 24 hours after a rotation) — plus Rivet-Webhook-Id. The same values are also sent split, as Rivet-Webhook-Timestamp and Rivet-Webhook-Signature. Recompute the HMAC over the timestamp, a dot, and the raw body with your secret; accept when any entry matches, compared constant-time. Verify against the raw body, before any JSON parsing.
t= is more than 300 seconds from your clock, before doing any HMAC work — a captured delivery replayed later must fail on the timestamp alone. Keep your receiver's clock synchronised.import crypto from "node:crypto";
const REPLAY_WINDOW_SEC = 300; // five minutes — older or newer than this is refused
// req.body is the RAW request body (a Buffer/string), not parsed JSON.
function verify(req, secret) {
// Rivet-Signature carries the timestamp the MAC covers and every v1= entry:
// t=1757505600,v1=<hex>[,v1=<hex>] (two entries for 24h after a rotation)
// The older split headers say the same thing; read them when only they exist.
const combined = req.headers["rivet-signature"] ?? "";
const parts = combined.split(",").map((p) => p.trim());
const ts = parts.find((p) => p.startsWith("t="))?.slice(2)
?? req.headers["rivet-webhook-timestamp"];
const entries = parts.filter((p) => p.startsWith("v1="));
if (entries.length === 0 && req.headers["rivet-webhook-signature"]) {
entries.push(...req.headers["rivet-webhook-signature"].split(",").map((p) => p.trim()));
}
if (!ts || entries.length === 0) return false;
// The replay window first: a stale timestamp is refused before any HMAC work.
if (Math.abs(Date.now() / 1000 - Number(ts)) > REPLAY_WINDOW_SEC) return false;
const expected = "v1=" + crypto
.createHmac("sha256", secret)
.update(`${ts}.${req.body}`)
.digest("hex");
// Accept when ANY entry matches yours; compare constant-time.
return entries.some((e) =>
e.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(e), Buffer.from(expected)));
}Confirm your receiver end to end with a test event — signed exactly like real ones:
curl -X POST "https://api.rivet.network/v1/webhooks/6a5eed00000000000000cb01/ping" \
-H "Authorization: Bearer rk_live_…"Delivery, retries and replay
- At-least-once: answer
2xxquickly and treatRivet-Webhook-Idas your idempotency key. Anything non-2xx (or a timeout) retries — roughly 1m, 5m, 30m, 2h, 8h. - Twenty consecutive terminal failures auto-disable an endpoint; its
statusbecomesDISABLEDwith the reason. Fix your receiver, re-enable it, and redeliver anything missed from the deliveries list (kept 30 days). - Rotate the signing secret when you need to. For 24 hours after a rotation every delivery carries two
v1=entries inRivet-Webhook-Signature— the new secret first, the previous one second — so a receiver that accepts any matching entry can switch secrets with nothing failing verification;previous_secret_valid_untilon the endpoint says when the old one stops.
updated_since sweep as the backstop that guarantees you never miss a change.