Skip to content

Webhooks

webhooks.constructEvent() checks a delivery’s Standard Webhooks signature and returns the parsed event; the …Async variants do the same on WebCrypto for edge runtimes. Pass the raw request body: the signature covers the exact bytes MoreVoice sent.

How signing works, and how to verify without the SDK, is on verifying signatures. The events themselves are in the event catalogue.

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;
}
}

Exported from @morevoice/sdk (source: packages/sdk-ts/src/lib/webhooks.ts).

constant

Prefix of a Standard Webhooks secret (whsec_<base64 key>).

const SECRET_PREFIX = "whsec_";
constant

Default replay window: a signature older or newer than this many seconds is refused.

const DEFAULT_TOLERANCE_SEC = 300;
type

A Standard Webhooks secret: "whsec_<base64>" as shown in the dashboard, the same base64 without the prefix, or the raw key bytes. Pass several (an array) while you rotate your own copy: any one of them may verify.

type WebhookSecret = string | Uint8Array;
type

The request body exactly as received: a string, a Node Buffer, or any Uint8Array / ArrayBuffer.

type RawBody = string | Uint8Array | ArrayBuffer;
type
type WebhookVerificationErrorCode =
/** A required header is absent. */
| "missing_header"
/** A header is present but malformed (e.g. a non-numeric timestamp). */
| "invalid_header"
/** The timestamp is outside the tolerance window (replay protection, or a badly skewed clock). */
| "timestamp_out_of_range"
/** No signature matches: wrong secret, or the body / id / timestamp was altered. */
| "bad_signature"
/** The secret you passed is not a usable key (empty, or not base64 after "whsec_"). */
| "invalid_secret"
/** The signature is valid but the body is not JSON. */
| "invalid_payload";
class
class WebhookVerificationError extends Error {
readonly name = "WebhookVerificationError";
readonly code: WebhookVerificationErrorCode;
constructor(code: WebhookVerificationErrorCode, message: string);
}
interface
interface VerifyOptions {
/** Accept timestamps within ± this many seconds of now (default 300). `Infinity` turns the check off. */
toleranceSec?: number;
/** The current time in milliseconds since the epoch, like `Date.now` (tests, or a trusted clock). */
now?: () => number;
}
interface

A request whose signature checked out.

interface VerifiedWebhook {
/** The `webhook-id` (Standard) or `x-webhook-id` (legacy, may be empty). */
id: string;
/** Unix seconds from the timestamp header. */
timestamp: number;
/** The body as text (UTF-8), not yet parsed. */
payload: string;
}
type

The three Standard Webhooks headers, as generateTestHeaders returns them (a plain record: pass it to fetch).

type StandardWebhookHeaders = {
"webhook-id": string;
"webhook-timestamp": string;
"webhook-signature": string;
};
function

Check a Standard Webhooks signature and return the verified (unparsed) payload. Throws WebhookVerificationError.

function verifySignature(rawBody: RawBody, headers: HeadersLike, secret: WebhookSecret | readonly WebhookSecret[], opts: VerifyOptions = {}): VerifiedWebhook;
function

verifySignature on WebCrypto, for edge runtimes (Vercel/Next.js edge, Cloudflare Workers, Deno, browsers).

function verifySignatureAsync(rawBody: RawBody, headers: HeadersLike, secret: WebhookSecret | readonly WebhookSecret[], opts: VerifyOptions = {}): Promise<VerifiedWebhook>;
function

Verify a webhook delivery and return its event. The body is parsed only after the signature checks out.

const event = webhooks.constructEvent(req.body, req.headers, process.env.MOREVOICE_WEBHOOK_SECRET!);

Throws WebhookVerificationError (code: missing_header | invalid_header | timestamp_out_of_range | bad_signature | invalid_secret | invalid_payload); answer 400 and MoreVoice retries.

function constructEvent<T = MoreVoiceEvent>(rawBody: RawBody, headers: HeadersLike, secret: WebhookSecret | readonly WebhookSecret[], opts: VerifyOptions = {}): T;
function

constructEvent on WebCrypto, for edge runtimes.

function constructEventAsync<T = MoreVoiceEvent>(rawBody: RawBody, headers: HeadersLike, secret: WebhookSecret | readonly WebhookSecret[], opts: VerifyOptions = {}): Promise<T>;
function

Verify a delivery signed with the legacy X-Webhook-Signature: sha256=<hex> scheme (endpoints created before webhooks v2; kept for one deprecation cycle) and return its body { id, event, createdAt, data }.

function verifyLegacy<T = LegacyWebhookEvent>(rawBody: RawBody, headers: HeadersLike, secret: WebhookSecret | readonly WebhookSecret[], opts: VerifyOptions = {}): T;
function

verifyLegacy on WebCrypto, for edge runtimes.

function verifyLegacyAsync<T = LegacyWebhookEvent>(rawBody: RawBody, headers: HeadersLike, secret: WebhookSecret | readonly WebhookSecret[], opts: VerifyOptions = {}): Promise<T>;
function

Sign a payload the way MoreVoice does, for testing your own webhook or tool endpoint:

const headers = webhooks.generateTestHeaders({ payload: body, secret: "whsec_…" });
await fetch("http://localhost:3000/tools", { method: "POST", headers, body });

Several secrets produce one “v1,…” entry each (what MoreVoice sends while a secret rotates).

function generateTestHeaders(p: { payload: string | Uint8Array; secret: WebhookSecret | readonly WebhookSecret[]; id?: string; timestamp?: number }): StandardWebhookHeaders;
constant

The webhook helpers as one namespace (import { webhooks } from "@morevoice/sdk"; the client exposes mv.webhooks).

const webhooks: {
constructEvent: typeof constructEvent;
constructEventAsync: typeof constructEventAsync;
verifySignature: typeof verifySignature;
verifySignatureAsync: typeof verifySignatureAsync;
verifyLegacy: typeof verifyLegacy;
verifyLegacyAsync: typeof verifyLegacyAsync;
generateTestHeaders: typeof generateTestHeaders;
};
type

Anything headers can arrive as.

type HeadersLike = { get(name: string): string | null } | Readonly<Record<string, HeaderValue>> | Iterable<readonly [string, string]>;
type
type HeaderValue = string | readonly string[] | number | null | undefined;

Exported from @morevoice/sdk (source: packages/sdk-ts/src/lib/event-envelope.ts).

interface

One event, as signed and delivered. TObject is the payload (data.object).

interface EventEnvelope<TType extends string = string, TObject = Record<string, unknown>> {
/** `evt_…`; the same on every redelivery, so receivers dedupe on it (it is also the `webhook-id` header). */
id: string;
object: "event";
type: TType;
/** ISO-8601 UTC, second precision. */
created: string;
api_version: string;
livemode: boolean;
/** `org_…` */
org_id: string;
data: { object: TObject; previous_attributes?: Record<string, unknown> };
request?: { id: string | null; idempotency_key: string | null };
}
type

Every event the API documents, discriminated by type: what webhooks.constructEvent returns. switch (event.type) { case "call.analyzed": event.data.object.summary … } narrows the payload.

type MoreVoiceEvent = [KnownEvent] extends [never] ? EventEnvelope : KnownEvent;
type

The event of one type: EventOf<"call.ended">.

type EventOf<T extends keyof EventTypeMap> = EventTypeMap[T];
type

An event whose type this SDK does not know yet (a newer server): the envelope with an untyped payload.

type UnknownEvent = EventEnvelope<string, Record<string, unknown>>;
type

Any event, known or not (for code that stores or forwards events generically).

type AnyEvent = MoreVoiceEvent | UnknownEvent;
function

True when this SDK knows the event’s type (its payload is typed); false for types newer than the SDK.

function isKnownEvent(event: { type: string }): event is MoreVoiceEvent;
interface

The pre-v2 body that endpoints still on api_version: "legacy" receive (signed with X-Webhook-Signature).

interface LegacyWebhookEvent {
id: string;
event: string;
createdAt: string;
data: Record<string, unknown>;
}