Skip to content

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.

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.

app/webhooks/route.ts
// 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).

The check takes a few lines in any language:

  1. Read the webhook-id, webhook-timestamp and webhook-signature headers, and the raw body.
  2. Reject the request if the timestamp is more than 5 minutes away from your clock. This stops an old request from being replayed.
  3. Decode your secret: drop the whsec_ prefix and base64-decode the rest.
  4. Compute HMAC-SHA256 over <webhook-id>.<webhook-timestamp>.<body> with that key, and base64-encode it.
  5. Compare it, in constant time, with each v1,<signature> entry of webhook-signature (entries are separated by spaces). One match is enough.
verify-webhook.ts
// 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);
}

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=
Body (exactly as sent)
{"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 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():

legacy-webhook.ts
// 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;
}