TVH Voice docs

#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

This example is executed against the sandbox in CI (tests/e2e/specs/m6-docs.spec.ts).