#Recipe: Webhooks
Events are delivered to your endpoints as signed JSON (Standard Webhooks: webhook-id,
webhook-timestamp, webhook-signature). Verify with the raw body, answer 2xx within 10 seconds,
and do the work asynchronously. Failed deliveries are retried with exponential backoff (8 attempts
over about 2 hours); every attempt is in the delivery log and any delivery can be replayed.
#TypeScript (SDK)
// Receive signed events. Verify with the raw body; respond 2xx quickly; deduplicate on event id.
import { createServer } from 'node:http';
import { createVoiceClient, verifyWebhook } from '@voice/sdk';
const voice = createVoiceClient({ baseUrl: process.env.VOICE_API_URL!, apiKey: process.env.VOICE_API_KEY! });
const seen = new Set<string>();
// 1. A receiver (in your CRM this is a normal route).
let secret = '';
const received: string[] = [];
const server = createServer((req, res) => {
let raw = '';
req.on('data', (c) => (raw += c));
req.on('end', () => {
try {
const event = verifyWebhook(secret, raw, req.headers as Record<string, string>);
if (!seen.has(event.id)) {
seen.add(event.id);
received.push(event.type); // e.g. store call.ended as a CRM activity
}
res.writeHead(204).end();
} catch {
res.writeHead(401).end(); // bad signature
}
});
});
await new Promise<void>((r) => server.listen(Number(process.env.DOCS_WEBHOOK_PORT ?? 4600), '0.0.0.0', r));
// 2. Register it. The signing secret is returned once.
const { data, error } = await voice.POST('/v1/webhook-endpoints', {
body: { url: process.env.DOCS_WEBHOOK_URL!, event_types: ['webhook.test', 'call.initiated', 'call.answered', 'call.ended', 'recording.ready'] },
});
if (error) throw new Error(error.error.message);
secret = data.secret;
// 3. Send a test event and wait for it.
await voice.POST('/v1/webhook-endpoints/{id}/test', { params: { path: { id: data.endpoint.id } } });
for (let i = 0; i < 20 && !received.includes('webhook.test'); i++) await new Promise((r) => setTimeout(r, 500));
console.log('received', received);
await voice.DELETE('/v1/webhook-endpoints/{id}', { params: { path: { id: data.endpoint.id } } });
server.close();
if (!received.includes('webhook.test')) throw new Error('test event not received');
#Notes
- Deduplicate on the event
id(equalswebhook-id): delivery is at-least-once. - Events for one call are emitted in order, but deliveries can arrive out of order after retries:
use
data.statusand timestamps, not arrival order. - Rotate the secret with
POST /v1/webhook-endpoints/{id}/rotate-secret(the old one stops at once). - Full catalog with JSON Schemas:
/docs/events.md.
This example is executed against the sandbox in CI (tests/e2e/specs/m6-docs.spec.ts).