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:
Railbed-Signature: t=1790381342,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdtis when this attempt was signed, in Unix seconds.v1is the hex HMAC-SHA256 of the string{t}.{raw body}, keyed with the endpoint's signing secret: the wholewhsec_…value, as text.
To verify a delivery:
- Read the header and split out
tandv1. Reject the request if the header is missing or doesn't matcht=<digits>,v1=<64 hex characters>. - Reject it if
tis more than five minutes from your clock. This stops someone replaying an old delivery they captured. - Compute HMAC-SHA256 over
t, a full stop and the raw request body, with your secret as the key, and hex-encode it. - Compare your result with
v1in 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));
}Cloudflare Workers, Deno, Bun, Next.js route handlers
export async function verifyRailbedSignature(
rawBody: string,
header: string | null,
secret: string,
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 enc = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
enc.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify'],
);
const bytes = new Uint8Array(
signature.match(/../g)!.map((h) => parseInt(h, 16)),
);
const signedPayload = enc.encode(`${timestamp}.${rawBody}`);
// subtle.verify compares in constant time
return crypto.subtle.verify('HMAC', key, bytes, signedPayload);
}import hashlib, hmac, re, time
def verify_railbed_signature(
raw_body: bytes,
header: str,
secret: str,
tolerance_seconds: int = 300,
) -> bool:
match = re.fullmatch(r"t=(\d+),v1=([0-9a-f]{64})", header or "")
if not match:
return False
timestamp, signature = match.groups()
if abs(time.time() - int(timestamp)) > tolerance_seconds:
return False
expected = hmac.new(
secret.encode(), timestamp.encode() + b"." + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)<?php
function verify_railbed_signature(
string $rawBody,
string $header,
string $secret,
int $toleranceSeconds = 300
): bool {
if (!preg_match('/^t=(\d+),v1=([0-9a-f]{64})$/', $header, $match)) return false;
[, $timestamp, $signature] = $match;
if (abs(time() - (int) $timestamp) > $toleranceSeconds) return false;
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $signature);
}require 'openssl'
require 'rack/utils' # secure_compare; Rails and Sinatra already load it
def verify_railbed_signature(raw_body, header, secret, tolerance_seconds = 300)
match = /\At=(\d+),v1=([0-9a-f]{64})\z/.match(header.to_s)
return false unless match
timestamp, signature = match.captures
return false if (Time.now.to_i - timestamp.to_i).abs > tolerance_seconds
expected = OpenSSL::HMAC.hexdigest('SHA256', secret, "#{timestamp}.#{raw_body}")
Rack::Utils.secure_compare(expected, signature)
endpackage railbed
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"regexp"
"strconv"
"time"
)
var signatureHeader = regexp.MustCompile(`^t=(\d+),v1=([0-9a-f]{64})$`)
func VerifySignature(
rawBody []byte,
header, secret string,
tolerance time.Duration,
) bool {
m := signatureHeader.FindStringSubmatch(header)
if m == nil {
return false
}
ts, err := strconv.ParseInt(m[1], 10, 64)
if err != nil {
return false
}
if age := time.Since(time.Unix(ts, 0)); age > tolerance || age < -tolerance {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(m[1] + "."))
mac.Write(rawBody)
expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(m[2]))
}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.
| Framework | The raw body |
|---|---|
| Express | express.raw({ type: 'application/json' }) on the webhook route, then req.body.toString('utf8') |
| Fastify | Add 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, Bun | const raw = await request.text() |
| Flask | request.get_data() |
| Django | request.body |
| FastAPI | raw = await request.body() |
| Laravel | $request->getContent() |
| Plain PHP, WordPress | file_get_contents('php://input') |
| Rails | request.raw_post |
Go net/http | io.ReadAll(r.Body) before anything else reads it |
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.
| Input | Value |
|---|---|
| Secret | whsec_4mJ9pQx2VtR7cY1nKs8LwZ3bHd6fGa0e |
Timestamp t | 1790380800 |
| Raw body | {"id":"evt_ExampleEventId0001","type":"ping","created":1790380800,"livemode":false,"data":{"payment":null}} |
| String to sign | 1790380800.{"id":"evt_ExampleEventId0001",…}: the timestamp, a full stop, then the body |
Expected v1 | 0f48db16650bc9a4d07b56d6db5b8f7cd53e778317e31492ed32c8fb0749fd0f |
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
| Symptom | Likely cause |
|---|---|
| Every delivery fails verification | The 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 fail | The body was parsed and re-serialized before verifying. Verify the raw body |
| Test events pass, real ones fail | A 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 while | Your server's clock has drifted beyond five minutes |
Your server answered 2xx but verification failed | Check the order: verify first, then answer. A 2xx stops retries |