Verify webhook signatures
Check that a webhook request came from Cevoid by verifying its HMAC-SHA256 signature against the exact raw request body.
Every request is signed with your destination's secret, following the Standard Webhooks 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.
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:
// 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)
}# 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:
webhook-id + "." + webhook-timestamp + "." + exact_raw_request_bodyDecode 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.
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)
})
}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 FalseYour 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 usereq.body.toString("utf8"). Do not useJSON.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:
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.
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:
- In Settings → Integrations → Cevoid API, open the destination's menu and choose Rotate secret. Result: Cevoid shows the new secret once.
- 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.
- 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.