RailbedDocs

Verify signatures

Prove each webhook came from Railbed and wasn't changed. The signing scheme, verification code in six languages, how to get the raw body in common frameworks, and a test vector.

How deliveries are signed

Every delivery carries a Railbed-Signature header:

The header
Railbed-Signature: t=1790381342,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  • t is when this attempt was signed, in Unix seconds.
  • v1 is the hex HMAC-SHA256 of the string {t}.{raw body}, keyed with the endpoint's signing secret: the whole whsec_… value, as text.

To verify a delivery:

  1. Read the header and split out t and v1. Reject the request if the header is missing or doesn't match t=<digits>,v1=<64 hex characters>.
  2. Reject it if t is more than five minutes from your clock. This stops someone replaying an old delivery they captured.
  3. Compute HMAC-SHA256 over t, a full stop and the raw request body, with your secret as the key, and hex-encode it.
  4. Compare your result with v1 in constant time. Reject the request if they differ.

Each attempt is signed afresh, so a retry has a new t and v1 for the same body.

Verify a delivery

Each function returns true only for a genuine, recent delivery. Check yours against the test vector below.

import crypto from 'node:crypto';

export function verifyRailbedSignature(
  rawBody,
  header,
  secret,
  toleranceSeconds = 300,
) {
  const match = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header ?? '');
  if (!match) return false;
  const [, timestamp, signature] = match;
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (age > toleranceSeconds) return false;
  const expected = crypto
    .createHmac('sha256', secret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature));
}

Use the raw body

The signature covers the exact bytes Railbed sent. If your framework parses the JSON and you serialize it again, spacing, key order or escaping can change and the signature won't match. Read the body as text or bytes, verify it, then parse it.

FrameworkThe raw body
Expressexpress.raw({ type: 'application/json' }) on the webhook route, then req.body.toString('utf8')
FastifyAdd a content-type parser with parseAs: 'string' for the route, or use fastify-raw-body
Next.js (App Router)const raw = await request.text() in the route handler
Cloudflare Workers, Deno, Bunconst raw = await request.text()
Flaskrequest.get_data()
Djangorequest.body
FastAPIraw = await request.body()
Laravel$request->getContent()
Plain PHP, WordPressfile_get_contents('php://input')
Railsrequest.raw_post
Go net/httpio.ReadAll(r.Body) before anything else reads it
A complete Cloudflare Worker or Next.js route handler
export async function POST(request: Request) {
  const raw = await request.text();
  const header = request.headers.get('Railbed-Signature');
  const secret = process.env.RAILBED_WEBHOOK_SECRET!;
  if (!(await verifyRailbedSignature(raw, header, secret))) {
    return new Response('Invalid signature', { status: 400 });
  }
  const event = JSON.parse(raw);
  // Record event.id with a unique constraint, queue the work, then answer.
  return new Response(null, { status: 200 });
}

Test vector

Check your code against these known values. The secret is an example only; it isn't a real endpoint's.

InputValue
Secretwhsec_4mJ9pQx2VtR7cY1nKs8LwZ3bHd6fGa0e
Timestamp t1790380800
Raw body{"id":"evt_ExampleEventId0001","type":"ping","created":1790380800,"livemode":false,"data":{"payment":null}}
String to sign1790380800.{"id":"evt_ExampleEventId0001",…}: the timestamp, a full stop, then the body
Expected v10f48db16650bc9a4d07b56d6db5b8f7cd53e778317e31492ed32c8fb0749fd0f
Reproduce it with OpenSSL
printf '%s' '1790380800.' \
  '{"id":"evt_ExampleEventId0001","type":"ping","created":1790380800,' \
  '"livemode":false,"data":{"payment":null}}' \
  | openssl dgst -sha256 -hmac 'whsec_4mJ9pQx2VtR7cY1nKs8LwZ3bHd6fGa0e'

The timestamp is in the past, so a verifier with a five-minute tolerance rejects this header. To test with it, pass a very large tolerance (every function above takes one), or test the HMAC step on its own. Change one byte of the body and the result must be false.

For an end-to-end check, choose Send test event (Test) or Send ping (Live) on your endpoint in Developers: the result shows whether your server accepted it.

Replays and clock skew

The five-minute window assumes your server's clock is right; keep it synced with NTP. A captured delivery replayed within the window still has a valid signature, which is why you also deduplicate on the event id: a replay of an event you've processed then changes nothing.

Rolling a secret

Roll secret on the endpoint replaces its secret at once, and every later delivery, retries included, is signed with the new one. Update your server's secret straight away. Deliveries that fail verification in between are retried, and any that run out of retries can be sent again from the delivery log.

A roll can't be seamless: from the moment you roll, every delivery is signed with the new secret, and you see it only then. Deliveries that reach your server before it has the new secret fail and are retried a few minutes later, so update promptly and nothing is lost.

Troubleshooting

SymptomLikely cause
Every delivery fails verificationThe wrong secret (Test and Live endpoints have different ones, and each endpoint has its own), or a secret with extra spaces or quotes around it
Only some deliveries failThe body was parsed and re-serialized before verifying. Verify the raw body
Test events pass, real ones failA proxy, CDN or security plugin changes the body or strips the Railbed-Signature header on real traffic. Allow the webhook path through untouched
Fails after a whileYour server's clock has drifted beyond five minutes
Your server answered 2xx but verification failedCheck the order: verify first, then answer. A 2xx stops retries

Updated · This page as Markdown