Skip to content

Verify signatures

Every delivery is signed with the endpoint’s secret. Verify the signature before you act on a request, so that nobody who learns your endpoint URL can send you events.

A delivery can carry two independent signatures over the same body. The endpoint’s signatureScheme decides which are sent:

signatureScheme Headers sent
BOTH (default) X-Signature and the Standard Webhooks headers.
LEGACY Only X-Signature.
STANDARD Only webhook-id, webhook-timestamp, webhook-signature.

A receiver ignores the headers it does not read, so verify either one. Narrow the endpoint to a single scheme only once nothing reads the other.

Header Value
X-Signature t=<unix-ms>,v1=<hex>. May carry a second v1 during a secret rotation.
X-Timestamp The same value as t: when this attempt was signed, in milliseconds.
X-Event-Id The event. The same id reaches every subscribed endpoint.
X-Delivery-Id This delivery: one event to one endpoint. Unchanged across attempts.
X-Sequence-Number The position of this delivery when ordering is on, 0 when it is off.
Idempotency-Key Stable across attempts, so you can discard what you already processed.
webhook-id The delivery id. Part of what the Standard Webhooks signature covers.
webhook-timestamp When this attempt was signed, in seconds.
webhook-signature v1,<base64>, space-separated when there is more than one.

v1 is HMAC-SHA256 over the exact string <t>.<raw body>, keyed with the endpoint secret, as lower-case hex. t is in milliseconds. Railhook’s SDKs reject a timestamp more than 300 seconds away from now.

import express from 'express';
import { constructEvent } from '@railhook/node';
const app = express();
// express.raw, not express.json: the signature covers the bytes that arrived.
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
try {
const event = constructEvent(req.body.toString('utf8'), req.headers, process.env.WEBHOOK_SECRET);
// event.data is the parsed body; event.deliveryId is safe to deduplicate on.
res.sendStatus(200);
} catch {
res.sendStatus(400);
}
});

The same check in plain code. Read every v1 in the header, not only the first one.

import crypto from 'node:crypto';
export function verify(rawBody, header, secret) {
let t;
const candidates = [];
for (const part of header.split(',')) {
const [key, value] = part.split('=', 2);
if (key === 't') t = value;
if (key === 'v1') candidates.push(value);
}
if (!t || candidates.length === 0) return false;
if (Math.abs(Date.now() - Number(t)) > 5 * 60 * 1000) return false;
const expected = Buffer.from(
crypto.createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex'),
);
let ok = false;
for (const candidate of candidates) {
const given = Buffer.from(candidate);
if (given.length === expected.length && crypto.timingSafeEqual(given, expected)) ok = true;
}
return ok;
}

The Standard Webhooks headers follow a published convention, so an off-the-shelf library can verify them. Three things differ from X-Signature:

  • The signed string is <webhook-id>.<webhook-timestamp>.<raw body>: the id is part of it.
  • webhook-timestamp is in seconds, not milliseconds.
  • The digest is base64, not hex, and the key is the endpoint’s standardWebhooksSecret, in the form whsec_<base64>.

The API returns standardWebhooksSecret next to secret, at creation and at rotation. The SDKs also accept a raw secret and use it as-is, so passing the wrong form fails as an invalid signature, not as an error that names the form.

import express from 'express';
import { verifyStandardWebhook } from '@railhook/node';
app.post('/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
try {
verifyStandardWebhook(req.body.toString('utf8'), req.headers, process.env.RAILHOOK_WHSEC);
} catch {
return res.sendStatus(400);
}
const data = JSON.parse(req.body.toString('utf8'));
return res.sendStatus(204);
});

Rotate a secret without breaking the receiver

Section titled “Rotate a secret without breaking the receiver”
  1. Call POST /api/v1/projects/{projectId}/endpoints/{id}/rotate-secret. The response carries the new secret and standardWebhooksSecret.

  2. For the endpoint’s grace period, 24 hours by default, every delivery is signed with both the new and the retired secret. X-Signature carries one v1 for each, and webhook-signature carries one space-separated entry for each:

    X-Signature: t=1735689600000,v1=8f2a41c7…new,v1=3b90de55…retired
  3. Deploy the new secret to the receiver at any point inside that window. After the window closes, only the new secret signs.