# Verify webhook signatures

> Check that a webhook request came from Cevoid by verifying its HMAC-SHA256 signature against the exact raw request body.

Breadcrumb: Webhooks › Verify signatures · Updated: 2026-10-18
Docs index: https://docs.cevoid.com/developers/llms.txt

Every request is signed with your destination's secret, following the [Standard Webhooks](https://www.standardwebhooks.com/) scheme. Verify the signature before you parse the JSON, against the exact raw request body, and reject the request when it does not match.

Cevoid shows the signing secret once, when you create the webhook destination or rotate its secret. See [Set up a webhook](https://docs.cevoid.com/developers/webhooks/set-up.md).

## Signature headers

Each request carries three headers for verification:

| Header | Value |
| --- | --- |
| `webhook-id` | The event ID, the same as `id` in the body. Retries keep it; a replay has a new one. |
| `webhook-timestamp` | Unix seconds when this attempt was signed. Every attempt, retries included, is signed again with a new timestamp. |
| `webhook-signature` | `v1,<base64 signature>`. During a secret rotation, two signatures separated by a space: the new secret's first, then the previous one's. |

## Verify with a library

The Standard Webhooks libraries check the signature and the timestamp (within 5 minutes) for you, and accept the secret with its `whsec_` prefix:

```js title="Node.js"
// npm install standardwebhooks
import { Webhook } from "standardwebhooks"

const webhook = new Webhook(process.env.CEVOID_WEBHOOK_SECRET)

export function parseCevoidEvent(rawBody, headers) {
  // Throws a WebhookVerificationError on a bad signature or a stale timestamp.
  webhook.verify(rawBody, headers)
  return JSON.parse(rawBody)
}
```

```python title="Python"
# pip install standardwebhooks
import json
import os

from standardwebhooks.webhooks import Webhook

webhook = Webhook(os.environ["CEVOID_WEBHOOK_SECRET"])

def parse_cevoid_event(raw_body: bytes, headers: dict[str, str]):
    # Raises WebhookVerificationError on a bad signature or a stale timestamp.
    webhook.verify(raw_body, headers)
    return json.loads(raw_body)
```

Pass the headers with their names in any case; the libraries lowercase them.

## Verify the exact raw body

To verify without a library, build the signed content from the two headers and the raw body:

```text
webhook-id + "." + webhook-timestamp + "." + exact_raw_request_body
```

Decode the base64 value after `whsec_` to get the key, calculate HMAC-SHA256 of the signed content, encode the digest as base64, and compare it in constant time with every `v1` signature in `webhook-signature`. Accept the request when one of them matches.

```js title="Node.js"
import { createHmac, timingSafeEqual } from "node:crypto"

export function verifyCevoidWebhook({ rawBody, headers, secret }) {
  const id = headers["webhook-id"]
  const timestamp = headers["webhook-timestamp"]
  const signatureHeader = headers["webhook-signature"]
  if (typeof signatureHeader !== "string") return false

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64")
  const expected = createHmac("sha256", key)
    .update(`${id}.${timestamp}.${rawBody}`, "utf8")
    .digest()

  return signatureHeader.split(" ").some((entry) => {
    const [version, encoded] = entry.split(",", 2)
    if (version !== "v1" || !encoded) return false
    const received = Buffer.from(encoded, "base64")
    return received.length === expected.length && timingSafeEqual(received, expected)
  })
}
```

```python title="Python"
import base64
import hashlib
import hmac

def verify_cevoid_webhook(raw_body: bytes, headers: dict[str, str], secret: str) -> bool:
    webhook_id = headers["webhook-id"]
    timestamp = headers["webhook-timestamp"]
    key = base64.b64decode(secret.removeprefix("whsec_"), validate=True)
    signed = webhook_id.encode() + b"." + timestamp.encode() + b"." + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()

    signature_header = headers.get("webhook-signature")
    if not signature_header:
        return False

    for entry in signature_header.split(" "):
        version, separator, signature = entry.partition(",")
        if version == "v1" and separator and hmac.compare_digest(signature, expected):
            return True
    return False
```

Your framework must hand you the body exactly as it arrived. Parsing the JSON and serializing it again changes the bytes, so the signature no longer matches:

- **Next.js route handlers:** read it with `await request.text()`.
- **Express:** mount `express.raw({ type: "application/json" })` on the webhook route and use `req.body.toString("utf8")`. Do not use `JSON.stringify(req.body)`.
- **Python (Flask):** use `request.get_data()`.

### HMAC test vector

Use this vector to check your implementation without sending a request. The raw body is a test event, as Send test delivers it:

```text
secret: whsec_MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=
webhook-id: d8177c1d-39da-4c4d-93cd-5964043735e4
webhook-timestamp: 1785758400
raw body: {"created_at":"2026-08-03T12:00:00.000Z","data":{"message":"Cevoid webhook destination test"},"id":"d8177c1d-39da-4c4d-93cd-5964043735e4","type":"cevoid.test","version":1}
webhook-signature: v1,hBoFA5FxR80jhi8JKrNuVm8C3a/2zulNorylzHdCdTg=
```

## Reject stale timestamps

A captured request can be sent to you again by someone else. To refuse such replays, reject a request whose `webhook-timestamp` is more than 5 minutes from your clock, the window the Standard Webhooks libraries use. Retries are signed again when they are sent, so a legitimate retry always carries a fresh timestamp. Deduplicate on `webhook-id` as well; see [Respond and deduplicate](https://docs.cevoid.com/developers/webhooks/retries-and-replays.md#respond-and-deduplicate).

## Rotate the signing secret

Rotation gives a destination a new secret while the old one keeps working for 24 hours, so you can switch without failed deliveries:

1. In Settings → Integrations → Cevoid API, open the destination's menu and choose Rotate secret. Result: Cevoid shows the new secret once.
2. Deploy the new secret to your receiver, keeping the old one. For 24 hours every request carries two signatures, so accept a match with either secret.
3. Once the new secret is live, choose Finish secret rotation in the same menu, or wait for the 24 hours to end. Result: requests are signed with the new secret only.

Finish secret rotation stops the previous secret at once. You cannot start another rotation while one is in progress.

## Secret format

A signing secret is `whsec_` followed by 32 random bytes in standard base64. Secrets of destinations created before the current format may use the URL-safe base64 alphabet, with `-` or `_`: decode those as base64url, or rotate the secret to get one in the current format, which the libraries and the samples above expect.

## Troubleshooting

- **Signature mismatch:** verify against the exact raw bytes, not parsed JSON, and remove `whsec_` before base64 decoding.
- **Mismatch only during a rotation:** the header holds two signatures; check each one, not only the first.
- **Secret was lost:** rotate it, deploy the new secret, then finish the rotation.

## Related articles

- [Set up a webhook](https://docs.cevoid.com/developers/webhooks/set-up.md)
- [Test a webhook](https://docs.cevoid.com/developers/webhooks/testing.md)

---

Canonical: https://docs.cevoid.com/developers/webhooks/verify-signatures
