---
title: "Signing"
description: "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](/docs/send/signing) uses, [Standard Webhooks](https://www.standardwebhooks.com), under Repost's own header names.

## What arrives at your destination

```http
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",...}
```

| Header | Contents |
|--------|----------|
| `x-repost-id` | The 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-timestamp` | Unix 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-signature` | One 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](/docs/forwarders/functions) 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.

<Note>
  **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.
</Note>

## 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.

<Warning>
  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.
</Warning>

## 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.

<Tabs>
  <Tab title="TypeScript">
    ```ts
    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"],
      });
    }
    ```
  </Tab>
  <Tab title="Python">
    ```python
    from standardwebhooks import Webhook

    wh = Webhook(os.environ["REPOST_FORWARDER_SECRET"])  # whsec_...

    def verify_forward(raw_body: bytes, headers: dict[str, str]):
        return wh.verify(raw_body, {
            "webhook-id": headers["x-repost-id"],
            "webhook-timestamp": headers["x-repost-timestamp"],
            "webhook-signature": headers["x-repost-signature"],
        })
    ```
  </Tab>
  <Tab title="Go">
    ```go
    import standardwebhooks "github.com/standard-webhooks/standard-webhooks/libraries/go"

    wh, _ := standardwebhooks.NewWebhook(os.Getenv("REPOST_FORWARDER_SECRET")) // whsec_...

    func verifyForward(rawBody []byte, h http.Header) error {
        mapped := http.Header{}
        mapped.Set("webhook-id", h.Get("x-repost-id"))
        mapped.Set("webhook-timestamp", h.Get("x-repost-timestamp"))
        mapped.Set("webhook-signature", h.Get("x-repost-signature"))
        return wh.Verify(rawBody, mapped)
    }
    ```
  </Tab>
  <Tab title="By hand">
    1. Recompute `HMAC-SHA256(secret, "{x-repost-id}.{x-repost-timestamp}.{raw body}")` and compare it, in constant time, against each space-separated `v1,` signature until one matches.
    2. Reject a stale `x-repost-timestamp`. A few minutes of tolerance is the usual choice.
    3. Dedupe on `x-repost-id` if you must not process a retried delivery twice. Use `x-repost-event-id` to correlate every attempt and replay of the same event.
  </Tab>
</Tabs>

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

<Columns cols={3} className="gap-y-4">
  <Card title="Replay" icon="rotate-ccw" href="/docs/history/replay" cta="Redeliver" arrow="true">
    Replays are signed with a fresh timestamp, so timestamp-validating destinations accept them.
  </Card>

  <Card title="Configuration" icon="settings" href="/docs/forwarders/configuration" cta="Settings" arrow="true">
    The settings page where the Signing card lives.
  </Card>

  <Card title="Functions" icon="code" href="/docs/forwarders/functions" cta="Transform" arrow="true">
    The signature covers the body after your transform runs.
  </Card>
</Columns>
