Signature Verification
Every outbound request from RelayBird carries a cryptographic signature. By verifying it, your endpoint can confirm the request genuinely came from RelayBird and that the body has not been tampered with in transit.
Headers RelayBird sends
| Header | Example value | Purpose |
|---|---|---|
X-Webhook-Signature | t=1718000000,v1=a3f8… | HMAC-SHA256 signature (verify this) |
X-Webhook-Id | 550e8400-e29b-… | Stable event UUID across all retry attempts; use for deduplication |
X-Webhook-Attempt | 1 | Attempt number (starts at 1); useful for logging |
Only X-Webhook-Signature needs to be verified for authenticity.
Signature scheme
RelayBird uses the same scheme as Stripe: the signed payload is a timestamp-prefixed concatenation so that replaying an old request (with a valid signature but a stale timestamp) can be detected and rejected.
Signed string
signed_string = f"{timestamp}.".encode() + raw_body_bytes
That is: the decimal Unix timestamp (from the t= field), a literal . character, then the raw request body bytes — exactly as received by RelayBird at ingest, with no re-serialisation or whitespace normalisation.
Algorithm
HMAC-SHA256(key=signing_secret, msg=signed_string)
The result is hex-encoded (lowercase). This is the v1= value in the header.
Header format
X-Webhook-Signature: t=<unix_timestamp>,v1=<hex_digest>
Signing key
The key is the per-destination signing secret (whsec_…). Each destination has its own independent secret — changing one destination's secret does not affect others.
Where to find it:
- Dashboard: sources tab → destination row → secret button.
- API:
GET /v1/destinations/{destination_id}/secret
(requires your API key or session).
Store the secret in an environment variable on your receiving server. Do not
embed it in source code.
Verification steps
1. Extract t and v1 from the X-Webhook-Signature header. 2. Check the timestamp: reject if abs(now - t) > 300 (5 minutes). This prevents replay attacks. 3. Recompute the HMAC: HMAC-SHA256(secret, f"{t}.".encode() + raw_body). 4. Compare using a constant-time function. Reject if they differ.
Always read the raw request body bytes before any JSON parsing, and pass
those same bytes to the HMAC. Re-serialising the parsed JSON may change
whitespace or key order and break verification.
Python
import hashlib
import hmac
import time
def verify_relaybird_signature(
secret: str,
raw_body: bytes,
signature_header: str,
tolerance_seconds: int = 300,
) -> bool:
"""
Returns True if the signature is valid and the timestamp is fresh.
:param secret: the destination's signing secret (whsec_…)
:param raw_body: the raw request body bytes, before any JSON parsing
:param signature_header: the full value of X-Webhook-Signature
:param tolerance_seconds: reject requests older than this many seconds
"""
try:
parts = dict(p.split("=", 1) for p in signature_header.split(","))
ts = int(parts["t"])
sig = parts["v1"]
except Exception:
return False
if abs(int(time.time()) - ts) > tolerance_seconds:
return False # stale or replayed
expected = hmac.new(
secret.encode(),
f"{ts}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, sig)
# --- Flask example -----------------------------------------------------------
# from flask import Flask, request, abort
#
# app = Flask(__name__)
# RELAYBIRD_SECRET = os.environ["RELAYBIRD_SIGNING_SECRET"]
#
# @app.route("/hook", methods=["POST"])
# def hook():
# if not verify_relaybird_signature(
# RELAYBIRD_SECRET,
# request.get_data(), # raw bytes, before parsing
# request.headers.get("X-Webhook-Signature", ""),
# ):
# abort(400, "bad signature")
# payload = request.get_json()
# ...
Node.js
const crypto = require("crypto");
/**
* Returns true if the signature is valid and the timestamp is fresh.
*
* @param {string} secret - the destination signing secret (whsec_…)
* @param {Buffer} rawBody - the raw request body buffer, before any parsing
* @param {string} sigHeader - the full value of X-Webhook-Signature
* @param {number} toleranceSecs - reject requests older than this (default 300)
*/
function verifyRelaybirdSignature(secret, rawBody, sigHeader, toleranceSecs = 300) {
let ts, sig;
try {
const parts = Object.fromEntries(
sigHeader.split(",").map((p) => p.split("=", 2))
);
ts = parseInt(parts.t, 10);
sig = parts.v1;
} catch {
return false;
}
if (!sig || isNaN(ts)) return false;
if (Math.abs(Date.now() / 1000 - ts) > toleranceSecs) {
return false; // stale or replayed
}
const payload = Buffer.concat([
Buffer.from(`${ts}.`),
typeof rawBody === "string" ? Buffer.from(rawBody) : rawBody,
]);
const expected = crypto
.createHmac("sha256", secret)
.update(payload)
.digest("hex");
try {
// timingSafeEqual throws if buffers have different lengths
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig));
} catch {
return false;
}
}
// --- Express example ---------------------------------------------------------
// const express = require("express");
// const app = express();
//
// app.post(
// "/hook",
// express.raw({ type: "*/*" }), // raw body — must come before json()
// (req, res) => {
// if (!verifyRelaybirdSignature(
// process.env.RELAYBIRD_SIGNING_SECRET,
// req.body,
// req.headers["x-webhook-signature"] ?? "",
// )) {
// return res.status(400).send("bad signature");
// }
// const payload = JSON.parse(req.body);
// ...
// }
// );
PHP
<?php
/**
* Returns true if the signature is valid and the timestamp is fresh.
*
* @param string $secret Destination signing secret (whsec_…)
* @param string $rawBody Raw request body string
* @param string $sigHeader Value of X-Webhook-Signature header
* @param int $toleranceSec Reject requests older than this (default 300)
*/
function verifyRelaybirdSignature(
string $secret,
string $rawBody,
string $sigHeader,
int $toleranceSec = 300
): bool {
$parts = [];
foreach (explode(',', $sigHeader) as $pair) {
[$k, $v] = explode('=', $pair, 2) + [1 => ''];
$parts[$k] = $v;
}
if (!isset($parts['t'], $parts['v1'])) {
return false;
}
$ts = (int) $parts['t'];
if (abs(time() - $ts) > $toleranceSec) {
return false; // stale or replayed
}
$signed = $ts . '.' . $rawBody;
$expected = hash_hmac('sha256', $signed, $secret);
return hash_equals($expected, $parts['v1']); // constant-time
}
// Usage (plain PHP):
// $raw = file_get_contents('php://input');
// $valid = verifyRelaybirdSignature(
// getenv('RELAYBIRD_SIGNING_SECRET'),
// $raw,
// $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? ''
// );
// if (!$valid) { http_response_code(400); exit('bad signature'); }
Common mistakes
| Mistake | Effect |
|---|---|
| Parsing the body to JSON before verifying | Re-serialisation may change whitespace — signature never matches |
Using == instead of a constant-time comparison | Vulnerable to timing attacks |
| Skipping the timestamp check | Old captured requests can be replayed |
| Using the source's ingest secret instead of the destination's signing secret | Wrong key — always fails |