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

# Verify Webhook Signatures

> Off the Hook signs every delivery with HMAC-SHA256 using the Standard Webhooks spec. Use the Svix library or implement verification yourself.

Every webhook delivery Off the Hook sends is signed with HMAC-SHA256. Verifying the signature before processing an event confirms the delivery came from Off the Hook and that the payload has not been tampered with in transit. Skipping this step makes your endpoint vulnerable to spoofed requests.

## Signature headers

Each delivery includes three headers that you use together to verify the signature:

| Header              | Example                   | Description                                 |
| ------------------- | ------------------------- | ------------------------------------------- |
| `webhook-id`        | `evt_8xN9kP2QbA...`       | The event ID; stays the same across retries |
| `webhook-timestamp` | `1730000000`              | Unix timestamp as a string                  |
| `webhook-signature` | `v1,<base64-hmac-sha256>` | One or more space-separated signatures      |

<Warning>
  Always verify signatures against the **raw request body** before parsing it as JSON. Any modification to the body — even reformatting whitespace — invalidates the signature.
</Warning>

## Verify with the Svix library

Off the Hook implements the [Standard Webhooks](https://github.com/svix/svix-webhooks) specification, so the Svix verification library works without modification. Your signing secret starts with `whsec_` — pass it as-is; the library handles base64 decoding internally.

<CodeGroup>
  ```typescript TypeScript theme={null}
  import { Webhook } from "svix";

  const wh = new Webhook(process.env.WEBHOOK_SECRET!);
  try {
    const event = wh.verify(rawBody, {
      "webhook-id": req.headers["webhook-id"] as string,
      "webhook-timestamp": req.headers["webhook-timestamp"] as string,
      "webhook-signature": req.headers["webhook-signature"] as string,
    });
    console.log("Verified event:", event.kind);
  } catch (err) {
    console.error("Invalid signature:", err);
    res.status(400).send("Bad signature");
  }
  ```

  ```go Go theme={null}
  import (
    "os"
    "net/http"

    svix "github.com/svix/svix-webhooks/go"
  )

  wh, _ := svix.NewWebhook(os.Getenv("WEBHOOK_SECRET"))
  if err := wh.Verify(body, r.Header); err != nil {
      http.Error(w, "invalid signature", http.StatusBadRequest)
      return
  }
  ```

  ```python Python theme={null}
  from svix.webhooks import Webhook
  import os

  wh = Webhook(os.environ["WEBHOOK_SECRET"])
  try:
      event = wh.verify(raw_body, {
          "webhook-id": request.headers["webhook-id"],
          "webhook-timestamp": request.headers["webhook-timestamp"],
          "webhook-signature": request.headers["webhook-signature"],
      })
  except Exception as e:
      return Response("Invalid signature", status=400)
  ```
</CodeGroup>

Svix libraries are available for Python, Ruby, PHP, Java, Rust, and more. See the full list at [github.com/svix/svix-webhooks](https://github.com/svix/svix-webhooks).

## Secret rotation and multiple signatures

During a secret rotation grace period, the `webhook-signature` header contains multiple space-separated `v1,` values — one computed with the new secret and one with the old. The Svix library accepts any valid signature in the header automatically, so no code changes are required during a rotation.

```
webhook-signature: v1,<base64-new-secret> v1,<base64-old-secret>
```

See [Rotate Webhook Signing Secrets](/guides/rotate-secrets) for details on managing secret rotation.

## Manual verification

If no Svix library is available for your language, you can implement verification directly:

<Steps>
  <Step title="Construct the signed content">
    Concatenate the three values with `.` as the separator:

    ```
    {webhook-id}.{webhook-timestamp}.{raw body}
    ```
  </Step>

  <Step title="Decode the secret">
    Your secret starts with `whsec_`. Base64-decode the part after `whsec_` to get the raw key bytes.
  </Step>

  <Step title="Compute the HMAC">
    Compute HMAC-SHA256 over the signed content string using the raw key bytes.
  </Step>

  <Step title="Encode and compare">
    Base64-encode the resulting bytes. Compare the result against each `v1,<value>` entry in the `webhook-signature` header using a **constant-time** comparison to prevent timing attacks. The delivery is valid if any entry matches.
  </Step>
</Steps>
