#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).