voepy

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)
StatusMeaning
initiatedDial issued, no audio yet.
ringingFar end is ringing.
answeredTwo-way audio established.
bridgedJoined to a second leg (transfer / agent connect).
endedCall completed — check end_reason for why.
failedCall 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). null00 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.

FieldTypeDefaultNotes
fromstring (E.164)requiredMust be a tenant-owned, active DID (or see Caller-ID forwarding below).
tostring (E.164)requiredDestination.
route_idvoepy_route_…tenant defaultOverride which connection routes the call.
recordenumoffoff, on_answer, on_ringing, on_demand.
record_channelsenumsinglesingle (mixed) or dual (stereo, separate legs).
record_formatenummp3mp3 or wav.
customer_referencestringOpaque to us, ≤128 chars. Echoed back on every webhook for this call.
answering_machine_detectionenumoffoff or premium.
audio_urlURLPlay this audio file on the far leg as pre-answer ringback.
timeout_secsint 1–60060How long to ring before giving up.
time_limit_secsint 1–86400noneHard cap on the answered call duration.
caller_id_forwardingboolfalseForward 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.

VerbWhenBody
answerinbound, pre-answersee above; optional
hangupany non-terminal{}
rejectinbound, pre-answer{ "cause": "user_busy" | "rejected" }
transferanswered/bridged{ "to": "+1…", "from"?: "+1…", "timeout_secs"?: int }
bridgeanswered{ "leg_id": "voepy_leg_…" } — joins two existing legs
referanswered{ "sip_address": "sip:user@host" } — SIP-side handoff
playanswered/bridged{ "url": "https://…", "loop"?: int, "target_legs"?: "self" | "opposite" | "both" }
stop_playbackanswered/bridged{ "stop"?: "current" | "all" }
speakanswered/bridged{ "payload": "Hello", "voice": "Polly.Joanna", "language"?: "en-US" }
start_recordinganswered{ "channels": "single" | "dual", "format": "mp3" | "wav", "max_length"?: secs, "play_beep"?: bool }
stop_recordinganswered/bridged{}
send_dtmfanswered/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 has audio_url + invalid_audio_url instead of payload.
  • 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

HTTPCodeWhen
400invalid_requestMalformed body.
402balance_insufficientYour balance + credit limit ≤ 0.
403from_number_not_ownedfrom isn't a tenant-owned active DID and forwarding isn't enabled.
403country_blocked / country_not_allowedDestination violates your quota's allow/deny lists.
403prefix_blockedDestination prefix on the block list.
409call_already_endedAction attempted on a terminal call.
409call_not_in_required_stateAction's "valid from" doesn't match current status.
409recording_not_readyTried to fetch a recording whose status != available.
502upstream_errorCarrier-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.