# 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.

Source: https://railbed.io/docs/webhooks/signatures/ · Updated: 2026-09-26 · Railbed by DeepWork developer docs

## How deliveries are signed

Every delivery carries a `Railbed-Signature` header:

The header:

```text
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](#test-vector) below.

Node.js:

```js
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));
}
```

Web Crypto · Cloudflare Workers, Deno, Bun, Next.js route handlers:

```ts
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);
}
```

Python:

```python
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:

```php
<?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);
}
```

Ruby:

```ruby
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)
end
```

Go:

```go
package 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 |

A complete Cloudflare Worker or Next.js route handler:

```ts
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` |

Reproduce it with OpenSSL:

```bash
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](https://app.railbed.io/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`](https://railbed.io/docs/webhooks.md#duplicates-and-order): 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](https://railbed.io/docs/webhooks.md#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 |
