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

# Signature Verification

> Verify HMAC-SHA256 signatures to ensure payloads come from Framelane.

## How signing works

Every webhook delivery includes these headers:

| Header               | Description                                                 |
| -------------------- | ----------------------------------------------------------- |
| `X-Render-Event`     | The event type, e.g. `render.completed`                     |
| `X-Render-Id`        | The job ID the event refers to (omitted for `webhook.test`) |
| `X-Render-Delivery`  | Unique ID for this delivery attempt                         |
| `X-Render-Timestamp` | Unix timestamp (seconds) at which the payload was signed    |
| `X-Render-Signature` | `sha256=<hex>` — the HMAC signature of the payload          |

The signature is computed as:

```
X-Render-Signature: sha256=HMAC-SHA256(secret, "{X-Render-Timestamp}.{raw_body}")
```

where:

* `secret` is your webhook signing secret (returned when you create a webhook)
* the timestamp is the value of the `X-Render-Timestamp` header
* `raw_body` is the raw (unparsed) request body bytes

To verify, recompute the HMAC over `"{timestamp}.{raw_body}"`, hex-encode it, and
constant-time compare it against the hex digest after the `sha256=` prefix in the
`X-Render-Signature` header.

## Verification example

<CodeGroup>
  ```typescript TypeScript theme={null}
  import { createHmac, timingSafeEqual } from "crypto";

  function verifyWebhook(
    rawBody: string,
    timestampHeader: string, // X-Render-Timestamp
    signatureHeader: string, // X-Render-Signature, e.g. "sha256=a4b3c2d1..."
    secret: string,
    toleranceSeconds = 300
  ): boolean {
    const timestamp = parseInt(timestampHeader, 10);

    if (Math.abs(Date.now() / 1000 - timestamp) > toleranceSeconds) {
      throw new Error("Webhook timestamp out of tolerance");
    }

    const received = signatureHeader.replace(/^sha256=/, "");
    const expected = createHmac("sha256", secret)
      .update(`${timestamp}.${rawBody}`)
      .digest("hex");

    const a = Buffer.from(received);
    const b = Buffer.from(expected);
    return a.length === b.length && timingSafeEqual(a, b);
  }
  ```

  ```python Python theme={null}
  import hashlib
  import hmac
  import time


  def verify_webhook(
      raw_body: bytes,
      timestamp_header: str,  # X-Render-Timestamp
      signature_header: str,  # X-Render-Signature, e.g. "sha256=a4b3c2d1..."
      secret: str,
      tolerance_seconds: int = 300,
  ) -> bool:
      timestamp = int(timestamp_header)

      if abs(time.time() - timestamp) > tolerance_seconds:
          raise ValueError("Webhook timestamp out of tolerance")

      received = signature_header.removeprefix("sha256=")
      payload = f"{timestamp}.{raw_body.decode()}".encode()
      expected = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest()

      return hmac.compare_digest(received, expected)
  ```
</CodeGroup>

<Warning>
  Always use a constant-time comparison (`timingSafeEqual` / `hmac.compare_digest`) to prevent timing attacks.
</Warning>

## Rotating the secret

```bash theme={null}
curl -X POST https://api.framelane.io/v1/webhooks/{id}/rotate-secret \
  -H "Authorization: Bearer $FRAMELANE_API_KEY"
```

Response:

```json theme={null}
{ "secret": "whsec_new..." }
```

Signing is **workspace-level**: rotating re-keys every webhook endpoint and every
per-request `webhook_url` callback in the workspace. Update all of your verifiers
immediately after rotation — the old secret stops working as soon as the new one
is issued.
