Calls
Place outbound calls, accept inbound, and control the live audio path in flight: transfer, bridge, record, stream media, run IVR menus, and get real-time transcription or answering-machine detection.
All paths under
/v1/.Authorization: Bearer <api-key>required.
The call lifecycle
initiated → ringing → answered → bridged? → ended
↑
failed (terminal)
| Status | Meaning |
|---|---|
initiated | Dial issued, no audio yet. |
ringing | Far end is ringing. |
answered | Two-way audio established. |
bridged | Joined to a second leg (transfer / agent connect). |
ended | Call completed — check end_reason for why. |
failed | Call never reached a usable state (rejected, busy, no route). |
Every state change fires a webhook (call.started, call.answered,
call.bridged, call.ended). See Webhooks.
The Call resource
{
"id": "voepy_call_01HZABC…",
"direction": "outbound",
"status": "ended",
"from": "+15125550100",
"to": "+15558675309",
"route_id": "voepy_route_01HZ…",
"initiated_at": "2026-05-12T14:32:18.000Z",
"answered_at": "2026-05-12T14:32:22.120Z",
"ended_at": "2026-05-12T14:33:00.880Z",
"duration_seconds": 38,
"end_reason": "completed",
"end_initiator": "callee",
"customer_reference": "cart-abc-123",
"cost_cents": 12,
"rating_status": "rated",
"legs": [ /* CallLeg objects */ ],
"recordings": [ /* Recording objects */ ]
}
cost_cents is null until rating completes (usually within seconds
of hangup). null ≠ 0 — 0 would mean a free call.
Place an outbound call
POST /v1/calls
Content-Type: application/json
Idempotency-Key: <uuid>
{
"from": "+15125550100",
"to": "+15558675309",
"record": "off",
"customer_reference": "cart-abc-123",
"timeout_secs": 30
}
Required fields are from and to (both E.164). Everything else is
optional.
| Field | Type | Default | Notes |
|---|---|---|---|
from | string (E.164) | required | Must be a tenant-owned, active DID (or see Caller-ID forwarding below). |
to | string (E.164) | required | Destination. |
route_id | voepy_route_… | tenant default | Override which connection routes the call. |
record | enum | off | off, on_answer, on_ringing, on_demand. |
record_channels | enum | single | single (mixed) or dual (stereo, separate legs). |
record_format | enum | mp3 | mp3 or wav. |
customer_reference | string | — | Opaque to us, ≤128 chars. Echoed back on every webhook for this call. |
answering_machine_detection | enum | off | off or premium. |
audio_url | URL | — | Play this audio file on the far leg as pre-answer ringback. |
timeout_secs | int 1–600 | 60 | How long to ring before giving up. |
time_limit_secs | int 1–86400 | none | Hard cap on the answered call duration. |
caller_id_forwarding | bool | false | Forward an arbitrary from not on your account. See below. |
Response: a Call resource with status: "initiated". The call is
now in flight.
Always send Idempotency-Key
Outbound calls cost money. A retry without an idempotency key (e.g. after a network blip) will dial twice. Use one UUID per logical call.
Caller-ID forwarding
By default, from must be one of your provisioned DIDs (403 from_number_not_owned otherwise). To dial from a number you don't
own — e.g. forwarding a customer's identity — set
caller_id_forwarding: true. Requires a per-tenant admin flag
(Quota.allow_caller_id_forwarding), which we enable after a
STIR/SHAKEN compliance review. Contact support to get it turned on.
Accept an inbound call
When someone calls one of your DIDs, voepy delivers a call.started
webhook with direction: "inbound". Your handler decides what to do:
POST /v1/calls/{id}/actions/answer
Content-Type: application/json
{
"record": "record-from-answer",
"record_channels": "dual",
"record_format": "mp3",
"stream_url": "wss://media.yourapp.com/voepy",
"stream_track": "both_tracks"
}
All body fields are optional. Provide them to answer + record + stream in one round-trip instead of three calls. Common patterns:
- Bare answer:
{} - Answer + record:
{ "record": "record-from-answer", "record_channels": "dual", "record_format": "mp3" } - Answer + bot pipeline:
{ "stream_url": "wss://…", "stream_track": "both_tracks" }
Reject instead
POST /v1/calls/{id}/actions/reject
{ "cause": "user_busy" } # or "rejected"
reject is pre-answer only. To end an answered call, use hangup.
Control actions
All on POST /v1/calls/{id}/actions/<verb>. Each returns the latest
Call resource on success.
| Verb | When | Body |
|---|---|---|
answer | inbound, pre-answer | see above; optional |
hangup | any non-terminal | {} |
reject | inbound, pre-answer | { "cause": "user_busy" | "rejected" } |
transfer | answered/bridged | { "to": "+1…", "from"?: "+1…", "timeout_secs"?: int } |
bridge | answered | { "leg_id": "voepy_leg_…" } — joins two existing legs |
refer | answered | { "sip_address": "sip:user@host" } — SIP-side handoff |
play | answered/bridged | { "url": "https://…", "loop"?: int, "target_legs"?: "self" | "opposite" | "both" } |
stop_playback | answered/bridged | { "stop"?: "current" | "all" } |
speak | answered/bridged | { "payload": "Hello", "voice": "Polly.Joanna", "language"?: "en-US" } |
start_recording | answered | { "channels": "single" | "dual", "format": "mp3" | "wav", "max_length"?: secs, "play_beep"?: bool } |
stop_recording | answered/bridged | {} |
send_dtmf | answered/bridged | { "digits": "1234#", "duration_millis"?: int } |
State errors come back as 409 call_not_in_required_state or 409 call_already_ended. Check Call.status before retrying.
IVR primitives (gather digits)
Build a menu without a flow engine:
POST /v1/calls/{id}/actions/gather_using_speak
{
"payload": "Press 1 for sales, 2 for support, then pound.",
"voice": "Polly.Joanna",
"language": "en-US",
"minimum_digits": 1,
"maximum_digits": 4,
"terminating_digit": "#",
"timeout_millis": 5000,
"inter_digit_timeout_millis": 2000,
"invalid_payload": "Sorry, I didn't catch that.",
"maximum_tries": 3
}
Variants:
gather— collect digits without prompting (caller already heard a prompt).gather_using_speak— speak a prompt, then collect.gather_using_audio— play an audio file as the prompt, then collect. Body hasaudio_url+invalid_audio_urlinstead ofpayload.stop_gather— cancel a gather in progress.
You receive a call.gather_complete webhook with the collected
digits. Branch on those, then issue the next action.
Real-time media streaming
Fork the call audio to your own WebSocket for live AI / analytics:
POST /v1/calls/{id}/actions/streaming_start
{
"stream_url": "wss://media.yourapp.com/voepy",
"stream_track": "both_tracks",
"stream_codec": "PCMU"
}
Tracks: inbound_track, outbound_track, or both_tracks. Codec
options: PCMU, PCMA, G722, OPUS, L16. Audio flows directly
from the carrier to your WSS endpoint (not relayed through voepy) for
low latency.
Stop: POST /v1/calls/{id}/actions/streaming_stop (optionally with a
specific stream_id).
List active streams for a call: GET /v1/calls/{id}/streamings.
Audio path note: stream URLs receive media directly from the upstream carrier. Don't expose your WSS endpoint to untrusted networks — anyone who reaches it during a session can listen.
Real-time transcription
POST /v1/calls/{id}/actions/transcription_start
{
"language": "en-US",
"engine": "accurate",
"transcription_tracks": "both"
}
engine is fast (low latency) or accurate (higher quality, ~1s
extra). You receive call.transcript_chunk webhooks for each
finalized phrase:
{
"event_type": "call.transcript_chunk",
"data": {
"call_id": "voepy_call_…",
"transcript": "I need help with my order.",
"confidence": 0.94,
"is_final": true,
"started_at": "2026-05-12T14:32:25.100Z",
"ended_at": "2026-05-12T14:32:27.480Z",
"words": [ /* per-word timing */ ]
}
}
Stop with transcription_stop. Fetch the full session transcript at
GET /v1/calls/{id}/transcription.
Transcription is billed per minute on top of the call charge.
Answering-machine detection (AMD)
Pass answering_machine_detection: "premium" when placing the call.
You receive a call.amd_complete webhook a few seconds in:
{
"event_type": "call.amd_complete",
"data": {
"call_id": "voepy_call_…",
"result": "human", // human | machine | unknown
"beep_detected": false
}
}
Also fetchable at GET /v1/calls/{id}/amd.
Use it to drop straight into voicemail with speak once result == "machine", or to bail out fast on unknown.
List and search calls
GET /v1/calls
?status=ended
&direction=inbound
&from_number=+15125550100
&to_number=+1...
&initiated_after=2026-05-01T00:00:00Z
&cursor=…
&limit=50
Filters are AND-combined. Result rows are abbreviated Call
resources; fetch the full one (with legs + recordings) at GET /v1/calls/{id}.
Recordings
Recordings show up on the parent Call resource and are also listable
directly:
GET /v1/recordings?call_id=voepy_call_…
GET /v1/recordings/{id}
DELETE /v1/recordings/{id}
Each recording:
{
"id": "voepy_rec_01HZ…",
"call_id": "voepy_call_01HZ…",
"channels": "dual",
"format": "mp3",
"duration_seconds": 42,
"size_bytes": 671088,
"download_url": "https://…signed-url…",
"status": "available",
"created_at": "2026-05-12T14:33:01.880Z"
}
download_url is signed and expires — fetch fresh if you cache it.
status is available, archived, expired, or failed.
You'll get a recording.completed webhook when one's ready, or
recording.failed if upload errored.
Error codes specific to calls
| HTTP | Code | When |
|---|---|---|
| 400 | invalid_request | Malformed body. |
| 402 | balance_insufficient | Your balance + credit limit ≤ 0. |
| 403 | from_number_not_owned | from isn't a tenant-owned active DID and forwarding isn't enabled. |
| 403 | country_blocked / country_not_allowed | Destination violates your quota's allow/deny lists. |
| 403 | prefix_blocked | Destination prefix on the block list. |
| 409 | call_already_ended | Action attempted on a terminal call. |
| 409 | call_not_in_required_state | Action's "valid from" doesn't match current status. |
| 409 | recording_not_ready | Tried to fetch a recording whose status != available. |
| 502 | upstream_error | Carrier-side failure. Often retriable; check the message. |
Full reference: Error reference.
Next
- Webhooks — listen for the events that drive your call flow.
- Phone numbers — buy DIDs to dial from.
- Billing — what each call costs and where you see it.
