TVH Voice docs

#Recipe: Click-to-call

The agent's phone rings first. When the agent answers, the platform dials to through the carrier that owns caller_id and connects both parties. Policy checks run before anything rings: allowed destination countries, blocked prefixes, owned caller ID, concurrency and hourly/daily caps, and the spend guard.

The agent leg arrives at the softphone with SIP header X-Call-Id: <call_id> so the UI can auto-answer the call the user just started.

#TypeScript (SDK)

// Click-to-call: the agent's phone rings first; when they answer we dial the customer.
import { createVoiceClient } from '@voice/sdk';
import { randomUUID } from 'node:crypto';

const voice = createVoiceClient({ baseUrl: process.env.VOICE_API_URL!, apiKey: process.env.VOICE_API_KEY! });

const { data: call, error } = await voice.POST('/v1/calls', {
  body: {
    agent: process.env.DOCS_AGENT_REF!, // agent_ref of a registered agent
    to: '+31612345678',
    caller_id: process.env.CALLER_ID!, // one of your active numbers; its carrier places the call
    external_ref: 'crm-activity-42', // your id, searchable in the call log
    metadata: { lead_id: 'L-1001' },
  },
  // Safe retries: the same key never dials twice.
  headers: { 'Idempotency-Key': randomUUID() },
});
if (error) throw new Error(`${error.error.code}: ${error.error.message}`);
console.log('call', call.call_id, call.status);

// Follow it (webhooks are better in production — see the webhooks recipe).
for (let i = 0; i < 30; i++) {
  const { data } = await voice.GET('/v1/calls/{id}', { params: { path: { id: call.call_id } } });
  if (data!.status === 'answered' || data!.ended_at) break;
  await new Promise((r) => setTimeout(r, 1000));
}
await voice.POST('/v1/calls/{id}/hangup', { params: { path: { id: call.call_id } } });
const { data: done } = await voice.GET('/v1/calls/{id}', { params: { path: { id: call.call_id } } });
console.log('timeline', done!.timeline.map((e) => e.type).join(' → '));

#curl

curl -sS -X POST "$VOICE_API_URL/v1/calls" \
  -H "Authorization: Bearer $VOICE_API_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: docs-$(date +%s)" \
  -d "{\"agent\":\"$DOCS_AGENT_REF\",\"to\":\"+31612345678\",\"caller_id\":\"$CALLER_ID\"}"

#Notes

Error code Fix
AGENT_NOT_REGISTERED The agent's softphone is offline
CALLER_ID_NOT_OWNED Use an active number from GET /v1/numbers
DESTINATION_NOT_ALLOWED Country/prefix not allowed by the workspace limits
LIMIT_EXCEEDED, SPEND_GUARD_TRIPPED Volume caps reached; see GET /v1/usage
INVALID_PHONE_NUMBER to must be E.164 (+31612345678)

Status flow: initiated → ringing → answered → completed (or busy, no_answer, canceled, failed).

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