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

HeaderExample valuePurpose
X-Webhook-Signaturet=1718000000,v1=a3f8…HMAC-SHA256 signature (verify this)
X-Webhook-Id550e8400-e29b-…Stable event UUID across all retry attempts; use for deduplication
X-Webhook-Attempt1Attempt 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:

(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

MistakeEffect
Parsing the body to JSON before verifyingRe-serialisation may change whitespace — signature never matches
Using == instead of a constant-time comparisonVulnerable to timing attacks
Skipping the timestamp checkOld captured requests can be replayed
Using the source's ingest secret instead of the destination's signing secretWrong key — always fails