Verifying webhook signatures
Anyone who learns your endpoint’s URL can send it a request. Before you act on a webhook, check its signature: it proves that MoreVoice sent the request and that nobody changed it on the way. MoreVoice signs webhooks with Standard Webhooks, an open scheme with verifiers in many languages.
With the SDK
Section titled “With the SDK”webhooks.constructEvent() in @morevoice/sdk checks the headers, the timestamp and the signature, then returns the parsed event. It throws a WebhookVerificationError (with a code such as bad_signature or timestamp_out_of_range) when the request isn’t genuine; answer 400 and MoreVoice retries. constructEventAsync() does the same on WebCrypto, for edge runtimes.
// A webhook endpoint for any runtime with Fetch API handlers:// Next.js route handlers, Hono, Bun, Deno, Cloudflare Workers.import { webhooks, WebhookVerificationError } from "@morevoice/sdk";
const secret = process.env.MOREVOICE_WEBHOOK_SECRET!; // whsec_…
export async function POST(request: Request): Promise<Response> { const body = await request.text(); // the raw body, exactly as sent try { const event = await webhooks.constructEventAsync(body, request.headers, secret); if (event.type === "call.ended") { console.log(`Call ${event.data.object.id} ended`); } // Acknowledge fast; do slow work afterwards. return new Response(null, { status: 204 }); } catch (err) { if (err instanceof WebhookVerificationError) { return new Response(err.code, { status: 400 }); } throw err; }}With Express, mount the route with express.raw({ type: "application/json" }) so req.body is the raw Buffer, and pass it to webhooks.constructEvent(req.body, req.headers, secret).
Without the SDK
Section titled “Without the SDK”The check takes a few lines in any language:
- Read the
webhook-id,webhook-timestampandwebhook-signatureheaders, and the raw body. - Reject the request if the timestamp is more than 5 minutes away from your clock. This stops an old request from being replayed.
- Decode your secret: drop the
whsec_prefix and base64-decode the rest. - Compute HMAC-SHA256 over
<webhook-id>.<webhook-timestamp>.<body>with that key, and base64-encode it. - Compare it, in constant time, with each
v1,<signature>entry ofwebhook-signature(entries are separated by spaces). One match is enough.
// Standard Webhooks verification with Node's crypto module only: no SDK, no dependencies.import { createHmac, timingSafeEqual } from "node:crypto";
const TOLERANCE_SEC = 5 * 60;
/** Verifies a MoreVoice webhook and returns the parsed event. Throws when the request isn't genuine. */export function verifyWebhook(rawBody: string, headers: Record<string, string | undefined>, secret: string, nowSec = Math.floor(Date.now() / 1000)): unknown { const id = headers["webhook-id"]; const timestamp = headers["webhook-timestamp"]; const signatures = headers["webhook-signature"]; if (!id || !timestamp || !signatures) throw new Error("Missing webhook headers"); if (!/^\d+$/.test(timestamp) || Math.abs(nowSec - Number(timestamp)) > TOLERANCE_SEC) throw new Error("Timestamp out of range");
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64"); const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${rawBody}`).digest(); const valid = signatures.split(" ").some((entry) => { const [version, signature] = entry.split(","); if (version !== "v1" || !signature) return false; const received = Buffer.from(signature, "base64"); return received.length === expected.length && timingSafeEqual(received, expected); }); if (!valid) throw new Error("Bad signature"); return JSON.parse(rawBody);}# Standard Webhooks verification with Python's standard library only (Python 3.10+).import base64import hashlibimport hmacimport jsonimport time
TOLERANCE_SEC = 5 * 60
def verify_webhook(raw_body: bytes, headers: dict, secret: str, now: int | None = None) -> dict: """Verify a MoreVoice webhook and return the parsed event. Raises ValueError when it isn't genuine.""" headers = {name.lower(): value for name, value in headers.items()} msg_id = headers.get("webhook-id") timestamp = headers.get("webhook-timestamp") signatures = headers.get("webhook-signature") if not msg_id or not timestamp or not signatures: raise ValueError("Missing webhook headers") now = int(time.time()) if now is None else now if not timestamp.isdigit() or abs(now - int(timestamp)) > TOLERANCE_SEC: raise ValueError("Timestamp out of range")
key = base64.b64decode(secret.removeprefix("whsec_")) signed = f"{msg_id}.{timestamp}.".encode() + raw_body expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode() for entry in signatures.split(" "): version, _, signature = entry.partition(",") if version == "v1" and hmac.compare_digest(signature, expected): return json.loads(raw_body) raise ValueError("Bad signature")Test your code
Section titled “Test your code”Run your verifier against this request. It must accept it when your clock reads the webhook-timestamp below, and reject it when you change a single character of the body or the secret.
| Field | Example |
|---|---|
| Secret | whsec_o7vehMKPiam3Ecgstvh931HBnZCcr6ww |
webhook-id |
evt_3fT9kq2ZpLr8YwVbN1cX0a |
webhook-timestamp |
1762161262 |
Expected webhook-signature |
v1,Py7y93taSs6W/aMMKVpfJxrj5zQIb14una9a+V/Gw1A= |
{"id":"evt_3fT9kq2ZpLr8YwVbN1cX0a","object":"event","type":"call.ended","created":"2025-11-03T09:14:22Z","api_version":"2026-11-01","livemode":true,"org_id":"org_0000000000000000000001","data":{"object":{"id":"call_7HkQ2mXr9","object":"call","direction":"outbound","status":"ended","end_reason":"hangup","duration_ms":93120,"campaign_id":"cmp_4Lz","metadata":{"crm_contact_id":"0031x00000AbCdE"}}},"request":{"id":"req_9aB","idempotency_key":null}}To test the endpoint itself, sign your own payload with webhooks.generateTestHeaders({ payload, secret }) from the SDK and POST it to your server.
Endpoints created before v2
Section titled “Endpoints created before v2”Endpoints from SettingsCompliance and campaign settings use the earlier signature: X-Webhook-Signature: sha256=<hex>, an HMAC-SHA256 of <timestamp>.<body> with the endpoint secret used as is, plus X-Webhook-Timestamp. The SDK verifies it with webhooks.verifyLegacy():
// Endpoints created before webhooks v2 use the legacy signature:// X-Webhook-Signature: sha256=<hex HMAC-SHA256(secret, "<timestamp>.<body>")>import { webhooks, type LegacyWebhookEvent } from "@morevoice/sdk";
const secret = process.env.MOREVOICE_WEBHOOK_SECRET!;
export function handleLegacyWebhook(rawBody: string, headers: Record<string, string>): LegacyWebhookEvent { const event = webhooks.verifyLegacy(rawBody, headers, secret); console.log(event.event, event.data); // e.g. "call.ended", { … } return event;}