Webhook RelayQuickstart

HMAC signing

Every outbound webhook carries an X-Webhook-Signature header. The receiver recomputes the HMAC over the timestamp + body with the shared secret and compares in constant time. Stripe and Svix use the same shape; if you've seen one you've seen this one.

Header format

http
X-Webhook-Signature: t=1717000000,v1=cafebabe...
  • t — Unix timestamp the signature was generated at.
  • v1 — hex-encoded HMAC-SHA256 over the string "{t}.{raw_body}" .

Verifying

The signature verifier is a 30-line helper. Every SDK ships one — use the helper rather than rolling your own; constant-time comparison and timestamp-window checks are easy to get wrong.

typescript
import { verifySignature } from "@philiprehberger/webhook-relay-client";

verifySignature(
  secret,                                  // whsec_...
  rawBody,                                 // exact bytes received
  request.headers.get("x-webhook-signature"),
  300,                                     // tolerance seconds (default)
);
php
use PhilipRehberger\WebhookRelayClient\Signer;

Signer::verify(
    $secret,
    file_get_contents('php://input'),
    $_SERVER['HTTP_X_WEBHOOK_SIGNATURE'] ?? '',
);
python
from philiprehberger_webhook_relay_client import verify_signature

verify_signature(secret, request.body, request.headers.get("X-Webhook-Signature"))
go
webhookrelay.VerifySignature(
    secret,
    string(body),
    r.Header.Get("X-Webhook-Signature"),
    0,  // default tolerance: 5 minutes
)

Two failure modes that bite

Re-encoding the body

The signature is computed over the bytes that hit the wire. If your framework JSON-parses the body and you re-serialize it before verifying, key order or whitespace can change and the signature will fail. Always verify against the raw request body.

Skipping the timestamp window

A signature on a body is replayable forever without a timestamp check. The SDK helpers reject signatures older than 5 minutes by default — keep that on. If you need a longer window, raise it explicitly; don't turn it off.

Rotating secrets

Calling POST /v1/subscriptions/{id}/rotate-secretgenerates a new secret and keeps the old one valid for 48 hours. During the window, every outbound delivery is signed with the new secret. Update your receiver to accept both during the grace, then drop the old one.