> ## Documentation Index
> Fetch the complete documentation index at: https://api.fanvue.com/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Verify Webhook Signatures

> Validate that an incoming webhook genuinely came from Fanvue before you act on it.

Every webhook Fanvue sends carries a signature. Verify it on every request so an attacker cannot forge events against your endpoint or replay an old delivery. Reject anything that fails.

## The signature header

Each delivery includes an `X-Fanvue-Signature` header:

```
X-Fanvue-Signature: t=<timestamp>,v0=<signature>
```

* `t` is a Unix timestamp (seconds since epoch) recording when Fanvue signed the request.
* `v0` is the HMAC-SHA256 signature of the signed payload, encoded as a hexadecimal string.

## Get your signing secret

Which secret signs your deliveries depends on how the webhook was registered. There are two paths, and they do not share a secret:

| How you registered the webhook                                                     | Secret that signs the delivery                                  | Where you get it                                    |
| ---------------------------------------------------------------------------------- | --------------------------------------------------------------- | --------------------------------------------------- |
| In the Developer Area (**Events** tab)                                             | One per-app signing secret, shared by every endpoint on the app | **View Signing Secret**, any time                   |
| Via [`POST /webhooks/subscriptions`](/docs/api-reference/create-a-webhook-subscription) | A fresh per-subscription secret, unique to that subscription    | The `signingSecret` field in the response, **once** |

### Developer Area apps

The signature is computed with a per-app signing secret. Find it in the Fanvue Developer Area:

<Steps>
  <Step title="Open the Events tab">
    Go to the Fanvue Developer Area, select your app, and open the **Events** tab.
  </Step>

  <Step title="Reveal the secret">
    Click **View Signing Secret** and copy the value.
  </Step>

  <Step title="Store it server-side">
    Keep the secret in an environment variable (for example `FANVUE_WEBHOOK_SECRET`). Never ship it in client-side code or commit it to source control.
  </Step>
</Steps>

### Webhooks registered through the API

A creator who subscribes an endpoint with `POST /webhooks/subscriptions` gets a
**per-subscription** secret, not the app secret. It is returned as `signingSecret`
in the create response and looks like `whsec_` followed by 64 hex characters:

```json theme={null}
{
  "id": "e0a5f3b2-6c41-4f0f-9d2a-1b7c8e3f4a90",
  "signingSecret": "whsec_3b1f8a0c2d4e6f8091a2b3c4d5e6f70819a2b3c4d5e6f70819a2b3c4d5e6f708"
}
```

<Warning>
  The `signingSecret` is returned **only** in the response to the call that
  created the subscription. There is no endpoint that reads it back and no way to
  reveal it later. Store it before you discard the response. If you lose it,
  delete the subscription and create a new one — which mints a new secret.
</Warning>

Every subscribe call mints a new destination with its own secret, even for the
same URL and the same creator. So if you register several subscriptions, keep a
secret per subscription id and select the right one when verifying — do not
assume one secret verifies every delivery reaching your endpoint.

<Note>
  Deleting one subscription does not affect the secrets of the others. This is why
  the secrets are per subscription rather than shared.
</Note>

## How verification works

Recompute the signature yourself and check it matches what Fanvue sent.

<Steps>
  <Step title="Parse the header">
    Split `X-Fanvue-Signature` on the comma and read `t` (the timestamp) and `v0` (the signature). If either is missing, reject the request.
  </Step>

  <Step title="Reconstruct the signed payload">
    Concatenate the timestamp, a period, and the **raw** request body: `{timestamp}.{body}`. Use the exact bytes Fanvue sent. If you parse the JSON first and re-serialize it, the bytes change and the signature will not match.
  </Step>

  <Step title="Compute the expected signature">
    Run HMAC-SHA256 over the signed payload using your signing secret, then hex-encode the result.
  </Step>

  <Step title="Compare in constant time">
    Compare your computed signature against `v0` with a timing-safe comparison (`crypto.timingSafeEqual` in Node, `hmac.compare_digest` in Python). A plain `==` can leak the secret through timing differences.
  </Step>

  <Step title="Reject stale timestamps">
    Check that `t` is within an acceptable window of the current time (5 minutes is a sensible default). This stops an attacker from replaying a previously valid request.
  </Step>
</Steps>

<Warning>
  Verify against the raw request body, not a parsed-and-re-serialized version. Most frameworks expose the raw bytes through a hook (for example Express's `verify` callback). Capture them before any JSON middleware mutates the request.
</Warning>

## Code sample

Both samples take the raw request body and the `X-Fanvue-Signature` header value, and return whether the request is authentic.

<Tabs>
  <Tab title="Node.js">
    ```ts theme={null}
    import crypto from "crypto";

    const SIGNING_SECRET = process.env.FANVUE_WEBHOOK_SECRET!;
    const TOLERANCE_SECONDS = 300; // 5 minutes

    export function verifyWebhookSignature(
      rawBody: string,
      signatureHeader: string
    ): boolean {
      // Parse "t=1234567890,v0=abc123..."
      let timestamp: string | undefined;
      let signature: string | undefined;

      for (const part of signatureHeader.split(",")) {
        const [key, value] = part.split("=");
        if (key === "t") timestamp = value;
        if (key === "v0") signature = value;
      }

      if (!timestamp || !signature) return false;

      // Reject stale timestamps to block replays
      const now = Math.floor(Date.now() / 1000);
      if (Math.abs(now - parseInt(timestamp, 10)) > TOLERANCE_SECONDS) {
        return false;
      }

      // Recompute the signature over `{timestamp}.{rawBody}`
      const signedPayload = `${timestamp}.${rawBody}`;
      const expected = crypto
        .createHmac("sha256", SIGNING_SECRET)
        .update(signedPayload)
        .digest("hex");

      // Constant-time comparison
      const a = Buffer.from(signature);
      const b = Buffer.from(expected);
      return a.length === b.length && crypto.timingSafeEqual(a, b);
    }
    ```
  </Tab>

  <Tab title="Python">
    ```python theme={null}
    import hashlib
    import hmac
    import os
    import time

    SIGNING_SECRET = os.environ["FANVUE_WEBHOOK_SECRET"]
    TOLERANCE_SECONDS = 300  # 5 minutes


    def verify_webhook_signature(raw_body: bytes, signature_header: str) -> bool:
        # Parse "t=1234567890,v0=abc123..."
        timestamp = None
        signature = None

        for part in signature_header.split(","):
            key, _, value = part.partition("=")
            if key == "t":
                timestamp = value
            elif key == "v0":
                signature = value

        if not timestamp or not signature:
            return False

        # Reject stale timestamps to block replays
        if abs(int(time.time()) - int(timestamp)) > TOLERANCE_SECONDS:
            return False

        # Recompute the signature over `{timestamp}.{raw_body}`
        signed_payload = f"{timestamp}.".encode() + raw_body
        expected = hmac.new(
            SIGNING_SECRET.encode(),
            signed_payload,
            hashlib.sha256,
        ).hexdigest()

        # Constant-time comparison
        return hmac.compare_digest(expected, signature)
    ```
  </Tab>
</Tabs>

<Note>
  When verification fails, respond with `401` and do not process the payload. When it succeeds, persist the event and return a `2xx` as described in the [Webhooks Overview](/docs/webhooks/webhooks-overview).
</Note>

## Test it without waiting for a real event

Use the **Test** action in the webhook's **…** menu (in the **Events & Endpoints** list) to send a sample payload to your endpoint. It carries a real `X-Fanvue-Signature` header, so a passing test confirms your verification logic works end to end.
