API guides

Webhooks

Signed pushes for the document lifecycle — the alternative to polling.

The API isn’t switched on in this environment yet. Everything below is accurate; the examples come from the published contract rather than a live read.

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.

Register
From the published contract
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"]}'
WebhookEndpoint
FieldTypeNotes
id*stringThe endpoint's id.
url*stringWhere deliveries are POSTed (https).
events*WebhookEvent[]Subscribed events; empty means all, including events added later.
status*ACTIVE | DISABLEDDISABLED after 20 consecutive terminal failures; re-enable resets the counter.
disabled_reason*string | nullWhy it was disabled, when it was.
last_success_at*string | nullMost recent successful delivery.
last_failure_at*string | nullMost recent failed delivery.
previous_secret_valid_until*string | nullWhile 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.

A delivery body
From the published contract
{
  "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.

The replay window is five minutes. Refuse a delivery whose 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.
Verify (Node)
From the published contract
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:

Send a test event
From the published contract
curl -X POST "https://api.rivet.network/v1/webhooks/6a5eed00000000000000cb01/ping" \
  -H "Authorization: Bearer rk_live_…"

Delivery, retries and replay

  • At-least-once: answer 2xx quickly and treat Rivet-Webhook-Id as 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 status becomes DISABLED with 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 in Rivet-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_until on the endpoint says when the old one stops.
Polling and webhooks are complementary: webhooks for promptness, a periodic updated_since sweep as the backstop that guarantees you never miss a change.