Webhooks
Receiving events
Section titled “Receiving events”Register an endpoint (POST /v1/webhook_endpoints) with a URL and the event types you want (see the event catalogue for the full list). Your endpoint receives a POST with a JSON body:
{ "id": "evt_01H8X...", "type": "exchange.delivered", "api_version": "2026-09-01", "created": "2026-09-10T09:00:00Z", "mode": "test", "data": { "object": { "id": "exch_01H8X...", "state": "delivered", "...": "..." } }}Respond 2xx within a few seconds — anything else is treated as a delivery failure and queued for retry.
Verifying signatures
Section titled “Verifying signatures”Every delivery carries a Railflo-Signature header: t=<unix-seconds>,v1=<hex-hmac-sha256>. The signed string is <timestamp>.<raw request body> — verify against the raw, unparsed body, before your framework touches it.
Always verify. An unverified webhook endpoint is a public POST target anyone can hit.
TypeScript / Node
Section titled “TypeScript / Node”import { verifyRailfloWebhook } from '@railflo/events'; // packages/events/src/signing.ts
const valid = verifyRailfloWebhook({ rawBody: rawRequestBody, // the exact request body as a string — NOT JSON.parse()'d signatureHeader: req.headers['railflo-signature'], secret: process.env.RAILFLO_WEBHOOK_SECRET!, // or [oldSecret, newSecret] during rotation});Python
Section titled “Python”The canonical reference implementation ships in this repo at packages/events/verify/verify.py — copy it directly:
from verify import verify_railflo_webhook
valid = verify_railflo_webhook( raw_body=request.body, # bytes, unparsed signature_header=request.headers.get("Railflo-Signature"), secret=os.environ["RAILFLO_WEBHOOK_SECRET"],)Canonical reference: packages/events/verify/verify.cs in this repo — same algorithm, same tolerance window.
Retries
Section titled “Retries”A failed delivery (non-2xx, timeout, or connection error) retries on a fixed backoff ladder: 30s, 2m, 10m, 1h, 6h, 24h. After the last attempt fails, the delivery is marked permanently failed — it will not retry again, but it remains visible (and re-deliverable on demand) via GET /v1/webhook_deliveries.
An endpoint failing more than 90% of its deliveries over 24 hours is automatically disabled — you’ll receive a webhook_endpoint.disabled event on your other endpoints, and need to re-enable it from Console once the underlying problem is fixed.
Rotating your secret
Section titled “Rotating your secret”POST /v1/webhook_endpoints/{id}/rotate-secret issues a new secret while keeping the old one valid for a 24-hour overlap window — verify against both during that window, then drop the old one.
Going to production
Section titled “Going to production”- Verify every signature — no endpoint that skips this.
- Return
2xxfast; do slow work (DB writes, downstream calls) asynchronously after responding. - Handle duplicate deliveries idempotently — at-least-once delivery means the same
idcan arrive twice. - Watch for
webhook_endpoint.disabledon a secondary channel (email/Slack), not just as another webhook to the endpoint that just got disabled. - Request a live-mode key and re-register your endpoint under it — test and live endpoints are entirely separate.