Signing

Every forwarded request is signed by Repost: the x-repost-* headers, the HMAC scheme, why the names differ from Standard Webhooks, secret rotation with an overlap window, and how your destination verifies.

Repost signs every request it forwards, whether it is the first delivery, an automatic retry, or a replay from history. Your destination can verify that a request came through Repost and was not tampered with, and it can accept replays that a provider's own signature would reject because that signature's timestamp went stale in the meantime.

The scheme is the same one the send feature uses, Standard Webhooks, under Repost's own header names.

What arrives at your destination

POST /webhooks/stripe HTTP/1.1
content-type: application/json
stripe-signature: t=1784289600,v1=...
x-repost-event-id: 01JZX3NV7Q9WD9F2M6K4T8RSAB
x-repost-forward-id: 01JZX3NVJ2QF1C8H0M5W6EXQ0V
x-repost-id: 01JZX3NVJ2QF1C8H0M5W6EXQ0V
x-repost-timestamp: 1784289612
x-repost-signature: v1,K5oZfzN95Z9UVu1EsPQqUIoRQOU...
 
{"id":"evt_1P9kQz2eZvKYlo2C","type":"payment_intent.succeeded",...}
HeaderContents
x-repost-idThe signed message id. Stable across automatic retries of the same delivery, so it is your dedupe key. Each replay is a new message with its own id.
x-repost-timestampUnix seconds at the moment this attempt was sent. Retries and replays carry a fresh timestamp, which is what lets them pass a staleness check.
x-repost-signatureOne or more space-separated signatures, each v1,<base64>. More than one appears only during a rotation overlap.

The signature is HMAC-SHA256 over {x-repost-id}.{x-repost-timestamp}.{raw body}, keyed with the forwarder's signing secret. The body is signed exactly as sent, after any function has run.

Everything the provider sent passes through untouched. Repost never rewrites a stripe-signature, webhook-signature, or any other upstream header; it only adds its own. If an inbound request already carries an x-repost-* signing header, that value is dropped before Repost's is set, so a caller cannot forge one.

Why not webhook-id / webhook-timestamp / webhook-signature? Standard Webhooks fixes those names for the sender of a webhook. A forwarder is a second signer, which the specification does not model. Using the same names would overwrite the provider's Standard Webhooks headers, which Svix-based providers and every other Standard Webhooks adopter send. Repost's names keep both signatures on the request.

The secret

Each forwarder has its own signing secret in the Standard Webhooks format, whsec_ followed by a base64 key, created the first time it is needed. Reveal or rotate it from the forwarder's Settings page, in the Signing card. It is available for every forwarder type and both bucket modes.

Rotating creates a new version and keeps the previous one signing through an overlap window: 24 hours by default, configurable up to 30 days. During the overlap every forward carries one signature per live version, and a verifier that accepts any matching signature keeps working while you switch secrets.

Promoting a guest bucket to a workspace re-keys its forwarders: the signing secret changes and the old one stops verifying. Reveal the new secret after promotion.

Verify at your destination

Verification is three checks. Any Standard Webhooks library does all of them once you hand it the three headers under the names it expects.

import { Webhook } from "standardwebhooks";
 
const wh = new Webhook(process.env.REPOST_FORWARDER_SECRET!); // whsec_...
 
export function verifyForward(rawBody: string, headers: Record<string, string>) {
  return wh.verify(rawBody, {
    "webhook-id": headers["x-repost-id"],
    "webhook-timestamp": headers["x-repost-timestamp"],
    "webhook-signature": headers["x-repost-signature"],
  });
}

Two mistakes to avoid: verifying a re-serialized body instead of the raw bytes, and comparing against a single signature instead of iterating the list, which breaks on the first rotation.

Continue