Every API here is available over the APIs.io API and to AI agents over MCP.
openapi: 3.2.0
info:
title: Voygr Calls API
description: "\nProgrammatic outbound phone calls executed by an AI voice agent. Submit a task,\nthe agent places the call, conducts the conversation, and returns a structured\noutcome with a full transcript.\n\n## Authentication\n\nEvery request needs the `X-API-Key` header. Keys are issued per customer and\ncarry a credit quota, rate limits, and a concurrent-call limit.\n\n## Placing a call\n\n`POST /calls` accepts two request shapes — pick one per call.\n\n**Freeform** — describe the task in plain language:\n\n```bash\ncurl -s https://api.voygr.tech/calls -H \"X-API-Key: $API_KEY\" -H \"Content-Type: application/json\" -d '{\"target_phone\": \"+15551234567\",\n \"brief\": \"Ask what time the kitchen closes tonight.\",\n \"language\": \"en\"}'\n```\n\n**Structured** — a machine-readable `intent` plus `slots`, validated\ndeterministically before anything is dialed. Five intents are supported:\n\n| Intent | Required slots |\n|---|---|\n| `inquiry` | `target_phone`, `question` |\n| `info_gathering` | `target_phone`, `questions` |\n| `issue_resolution` | `target_phone`, `issue_description` |\n| `booking` | `target_phone`, `name`, `date`, `time`, `party_size` |\n| `cancellation` | `booking_id` — no phone; it comes from the stored booking |\n\nAn incomplete submission returns `422` listing exactly what is missing, each\nslot with a `suggested_question` to relay to your user — submit, collect,\nresubmit. Validation never dials and never charges. (The credit-hold check\nruns first, so a key that cannot cover the hold gets `402` even for an\nincomplete submission.)\n\n`language` accepts `en`, `es`, `fr`, `de`, `hi`, `ru`, `pt`, `ja`, `it`,\n`nl`, `sr`, `tr`, `pl`, or `auto` (the default); any other code is refused\nwith `422 unsupported_language`.\n\nThe success envelope differs by shape: freeform wraps the call object in\n`{\"call\": {...}}`; structured returns a flat envelope with top-level\n`call_id`, `status_url`, and `answer_url`. Both are documented with examples\non the operation below.\n\n## Call lifecycle\n\nCalls are asynchronous. Poll `GET /calls/{call_id}` until `status` reaches\n`completed`, `failed`, or `cancelled` — typically 30-90 seconds. The terminal\nresponse carries `outcome_type`, `outcome_summary`, and `transcript_full`.\nThere is no completion webhook.\n\nFor live progress, poll `GET /calls/{call_id}/events?after_event_id=N` with\nshort requests, advancing the cursor to the last `id:` you received — the\nbody is SSE-formatted text, but do NOT hold a long-lived stream open, and use\nthe query cursor (the gateway strips the `Last-Event-ID` header). If the\nagent needs input mid-call it emits an `ask_user` event — create the call\nwith `ask_user_mode: \"stream\"` to receive these, and respond via\n`POST /calls/{call_id}/answer`. A queued or ringing call can be aborted with\n`POST /calls/{call_id}/cancel`.\n\nOnce the call ends, `GET /calls/{call_id}/transcript-merged` returns the\ncomplete two-sided transcript rebuilt from the dual-channel recording —\nincluding speech the live pipeline never transcribed (IVR phone trees, hold\nannouncements). The live `transcript_full` stays real-time-only; the merged\none is the complete record. A `202 {\"status\": \"merger_pending\"}` means the\nmerge (which fires seconds after the call ends) hasn't finished — retry\nafter the `transcript_ready` SSE event, or force it with\n`POST /calls/{call_id}/transcript-merged/rebuild`.\n\n## Billing\n\nThe hold and the charge are different numbers:\n\n| Event | Credits |\n|---|---|\n| Hold at dial time (`credits_reserved`) | 30 — frozen, refundable, NOT a charge |\n| Successful outcome (`success_*`) — `credits_charge_on_success` | 10 |\n| Unsuccessful outcome (`failed_*` — no answer, voicemail, technical) | 0 |\n\nThe hold is released at completion and you are only ever charged the settled\namount. Requests are rejected with `402` when the available balance cannot\ncover the hold.\n\n`GET /v1/usage` reports `available` (spendable right now) and\n`call_credit_hold` (what one call freezes), so `available // call_credit_hold`\nis how many calls you can start.\n\n## Limits\n\n- Free keys: 5 requests/second, 10 requests/minute. Paid keys: 10\n requests/second, 100 requests/minute (`429` beyond).\n- Concurrent calls per key are limited (`409` with `active_call_ids` at the\n cap; typically 2).\n- Service maintenance windows return `503` with a `resume_at` timestamp.\n\n## Acceptable use\n\nCalls are transactional and user-initiated only: no telemarketing,\nsolicitation, bulk dialing, or harassment. The agent discloses that it is an\nAI assistant and that the call is recorded; disclosure is not configurable.\nUnited States destinations only. Recordings and transcripts are retained for\na maximum of 90 days.\n\n\nCredit rates: validation, enrichment, business status and categorize are 1 credit per request. An AI booking call is 10 credits. Credits never expire."
version: 1.0.0
servers:
- url: https://api.voygr.tech
description: Production (stated by the provider in the spec description curl examples, api.voygr.tech/docs, and callwright-skill SKILL.md)
tags:
- name: calls
paths:
/calls:
post:
tags:
- calls
summary: Place a call
description: Place an outbound AI phone call. Two request shapes — freeform (target_phone + brief) or structured (target_phone + intent + slots). See the guide above for the 422 slot-retry loop.
operationId: create_call
parameters:
- name: X-API-Key
in: header
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: X-Api-Key
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CallCreateRequest'
examples:
freeform:
summary: Freeform brief
value:
target_phone: '+15551234567'
brief: Call and ask what time the kitchen closes tonight. Thank them and hang up.
language: en
structured:
summary: Structured slots
value:
target_phone: '+15551234567'
intent: inquiry
slots:
intent: inquiry
target_phone: '+15551234567'
question: What time does the kitchen close tonight?
responses:
'201':
description: Call created. The envelope shape depends on which request path was used — freeform returns CallResponse (a `call` wrapper), structured returns the flat SkillRunResponse (top-level call_id, no wrapper). Either way, poll GET /calls/{call_id} afterward — that response is identical regardless of which path created the call.
content:
application/json:
schema:
oneOf:
- $ref: '#/components/schemas/CallResponse'
- $ref: '#/components/schemas/SkillRunResponse'
examples:
freeform_response:
summary: Freeform path -> CallResponse (call wrapper)
value:
call:
call_id: c_abc123
customer_id: cus_xyz
task_id: t_abc123
target_phone: '+15551234567'
language: en
status: dialing
call_sid: CAxxxxxxxx
started_at: null
ended_at: null
duration_sec: null
outcome_type: null
outcome_summary: null
outcome_charge_cents: 0
created_at: '2026-07-17T12:00:00Z'
has_recording: false
recording_url: null
reservation_signals: null
transcript_full: null
supervisor_decisions: null
task_id: t_abc123
credits_reserved: 10
credits_charge_on_success: 10
owner_pod: pod-3
structured_response:
summary: Structured path -> SkillRunResponse (flat, no wrapper)
value:
skill_run_id: srun_c_abc123
call_id: c_abc123
call_sid: CAxxxxxxxx
owner_pod: pod-3
status: dialing
credits_reserved: 10
credits_charge_on_success: 10
status_url: /calls/c_abc123
answer_url: /calls/c_abc123/answer
recording_url: /calls/c_abc123/recording
replayed: false
expected_next_steps:
- '...'
'202':
description: 'Call queued (deployments that queue before dialing — the drainer hasn''t dialed yet). The envelope-by-path rule DIFFERS from 201: freeform returns QueuedCallResponse (a third, much smaller shape — call_id/queue_id/position/status only, no call_sid/owner_pod/credits_reserved at all), structured still returns SkillRunResponse but with status=''queued'', call_sid=null, owner_pod=null.'
content:
application/json:
schema:
oneOf:
- $ref: '#/components/schemas/QueuedCallResponse'
- $ref: '#/components/schemas/SkillRunResponse'
examples:
freeform_queued:
summary: Freeform path, queued -> QueuedCallResponse
value:
call_id: c_abc123
queue_id: q_abc123
position: 0
status: queued
structured_queued:
summary: 'Structured path, queued -> SkillRunResponse (credits_reserved=0: the gateway-mediated NORMAL case for public-API callers — the gateway holds the reservation, not callwright)'
value:
skill_run_id: srun_c_abc123
call_id: c_abc123
call_sid: null
owner_pod: null
status: queued
credits_reserved: 0
status_url: /calls/c_abc123
answer_url: /calls/c_abc123/answer
recording_url: /calls/c_abc123/recording
replayed: false
expected_next_steps:
- '...'
'401':
description: Missing or invalid X-API-Key.
content:
application/json:
examples:
missing_key:
summary: X-API-Key header absent
value:
detail:
error: API key required
invalid_key:
summary: X-API-Key doesn't resolve to an active key
value:
detail:
error: invalid API key
'402':
description: Insufficient credits for the 30-credit hold.
content:
application/json:
examples:
gateway_precheck:
summary: Gateway pre-check (common path)
value:
detail:
error: insufficient credits
callwright_race:
summary: 'Race: gateway''s quota view was stale'
value:
detail:
error: quota_exceeded
needed_credits: 200
checkout_url: /checkout/buy
'403':
description: Key not permitted to make this request.
content:
application/json:
examples:
tier_not_permitted:
value:
detail:
error: tier not permitted
missing_entitlement:
value:
detail:
error: 'missing entitlement: booking'
freeform_disabled:
summary: Freeform brief disabled for this key
value:
detail:
error_code: freeform_disabled
hint: Submit structured `slots` (see GET /skills) or ask ops to enable freeform for this key.
'409':
description: Concurrent-call cap reached.
content:
application/json:
examples:
concurrent_call_not_allowed:
value:
detail:
error: concurrent_call_not_allowed
max_concurrent: 2
active_call_ids:
- c_abc123
'422':
description: Bad request body — four flavors, distinguished by error_code, plus standard Pydantic validation.
content:
application/json:
examples:
missing_brief:
summary: Freeform path, brief absent/empty
value:
detail:
error_code: missing_brief
hint: Provide `brief` (freeform) or `slots` (structured) — see GET /skills/concierge/manifest.
missing_slots:
summary: Structured path, required slots absent (flagship retry loop)
value:
detail:
error_code: missing_slots
slot_schema_version: concierge-v1
intent_type: inquiry
missing_slots:
- slot_name: question
reason: the single question the agent must ask the venue
suggested_question: What should we ask them?
type_hint: free_text
invalid: {}
hint: Collect the listed slots from your user and resubmit with `slots` populated.
unknown_intent:
summary: intent not a supported value
value:
detail:
error_code: unknown_intent
slot_schema_version: concierge-v1
supported_intents:
- booking
- cancellation
- info_gathering
- inquiry
- issue_resolution
hint: Pick a supported intent and resubmit.
invalid_slots:
summary: A free-text slot exceeds 2000 characters
value:
detail:
error_code: invalid_slots
slot_schema_version: concierge-v1
invalid:
question: must be at most 2000 characters
hint: Shorten the listed free-text slots to at most 2000 characters and resubmit.
pydantic_validation:
summary: Standard field validation, e.g. missing target_phone
value:
detail:
- type: missing
loc:
- body
- target_phone
msg: Field required
input:
intent: inquiry
slots:
intent: inquiry
'429':
description: 'Rate limited. Free keys: 5 req/s, 10 req/min. Paid keys: 10 req/s, 100 req/min.'
content:
application/json:
examples:
rate_limited:
value:
detail:
error: rate limit exceeded
'503':
description: Service in a maintenance window.
content:
application/json:
examples:
maintenance:
value:
detail:
error: maintenance
message: '...'
resume_at: '2026-07-17T13:00:00Z'
get:
tags:
- calls
summary: List calls
description: List your own calls, most recent first. Each entry omits transcript_full/supervisor_decisions — fetch a single call for those.
operationId: list_calls
parameters:
- name: X-API-Key
in: header
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: X-Api-Key
- name: limit
in: query
required: false
schema:
type: integer
default: 100
minimum: 1
maximum: 1000
title: Limit
responses:
'200':
description: Successful Response
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/CallDTO'
'400':
description: limit outside 1..1000.
content:
application/json:
examples:
bad_limit:
value:
detail: limit must be between 1 and 1000
'401':
description: Missing or invalid X-API-Key.
content:
application/json:
examples:
missing_key:
summary: X-API-Key header absent
value:
detail:
error: API key required
invalid_key:
summary: X-API-Key doesn't resolve to an active key
value:
detail:
error: invalid API key
/calls/{call_id}:
get:
tags:
- calls
summary: Get a call
description: Poll a single call. Returns the full detail fields (including transcript_full) that the list endpoint omits.
operationId: get_call
parameters:
- name: X-API-Key
in: header
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: X-Api-Key
- name: call_id
in: path
required: true
schema:
type: string
title: Call Id
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/CallDTO'
'401':
description: Missing or invalid X-API-Key.
content:
application/json:
examples:
missing_key:
summary: X-API-Key header absent
value:
detail:
error: API key required
invalid_key:
summary: X-API-Key doesn't resolve to an active key
value:
detail:
error: invalid API key
'404':
description: Call not found, or isn't yours.
content:
application/json:
examples:
not_found:
value:
detail: Call not found
/calls/{call_id}/answer:
post:
tags:
- calls
summary: Answer a mid-call question
description: Answer a question the agent asked mid-call. If no answer arrives before the wait window elapses, the call proceeds and wraps up gracefully rather than stalling indefinitely.
operationId: answer_call
parameters:
- name: X-API-Key
in: header
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: X-Api-Key
- name: call_id
in: path
required: true
schema:
type: string
title: Call Id
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/AnswerRequest'
examples:
answer:
value:
answer: Yes, hold the table until 7:30pm
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/AnswerResponse'
examples:
delivered:
value:
delivered: true
reason: delivered
'401':
description: Missing or invalid X-API-Key.
content:
application/json:
examples:
missing_key:
summary: X-API-Key header absent
value:
detail:
error: API key required
invalid_key:
summary: X-API-Key doesn't resolve to an active key
value:
detail:
error: invalid API key
/calls/{call_id}/events:
get:
tags:
- calls
summary: Poll call events (SSE-formatted)
description: 'Event log of a call in SSE text format (`id:` / `event:` / `data:` lines). POLL this endpoint with short requests and the `after_event_id` cursor — do NOT hold a long-lived stream open. The gateway strips the `Last-Event-ID` header; the query param is the only cursor. Event types: `status_change`, `ask_user` (answer promptly via POST /calls/{call_id}/answer; requires ask_user_mode=stream at call creation), `outcome` (terminal), `recording_ready`, `transcript_ready` (the post-call merged transcript is built — see GET /calls/{call_id}/transcript-merged).'
operationId: get_call_events
parameters:
- name: X-API-Key
in: header
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: X-Api-Key
- name: call_id
in: path
required: true
schema:
type: string
title: Call Id
- name: after_event_id
in: query
required: false
schema:
type: integer
minimum: 0
title: After Event Id
description: Return only events with event_id greater than this cursor. Start at 0; advance to the last `id:` you received.
responses:
'200':
description: SSE-formatted text; empty body when no new events.
content:
text/event-stream:
schema:
type: string
examples:
status_change:
value: 'id: 2077
event: status_change
data: {"event_id": 2077, "call_id": "c_abc123", "event_type": "status_change", "data": {"status": "dialing"}}
'
'401':
description: Missing or invalid X-API-Key.
content:
application/json:
examples:
missing_key:
summary: X-API-Key header absent
value:
detail:
error: API key required
invalid_key:
summary: X-API-Key doesn't resolve to an active key
value:
detail:
error: invalid API key
'404':
description: Call not found, or isn't yours.
content:
application/json:
examples:
not_found:
value:
detail: Call not found
/calls/{call_id}/transcript-merged:
get:
tags:
- calls
summary: Get the post-call merged transcript
description: 'The post-call merged transcript: both sides of the call on one timeline, built from the dual-channel recording after the call ends. This is a different, fuller record than the live transcript_full rows, not a tidied-up copy of them — the recording captures the callee''s channel in full, so the merged transcript routinely contains speech the live pipeline never transcribed: IVR phone trees, hold and queue announcements, anything said before the STT gate opened. If a recording plainly contains something your transcript doesn''t, this endpoint is where to look.'
operationId: get_transcript_merged
parameters:
- name: X-API-Key
in: header
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: X-Api-Key
- name: call_id
in: path
required: true
schema:
type: string
title: Call Id
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/MergedTranscript'
examples:
merged:
value:
version: 1
duration_ms: 17240
calibration_delta_ms: 0
turns:
- speaker: hostess
start_ms: 300
end_ms: 6100
text: Thank you for calling. If you know your party's extension, you may dial it at any time.
source: deepgram_batch_multichannel
- speaker: bot
start_ms: 6800
end_ms: 9200
text: Hi, this is an AI assistant calling to ask about your opening hours.
source: deepgram_batch_multichannel
'202':
description: 'The merger hasn''t run yet — it fires seconds after the call ends. Retry after the `transcript_ready` SSE event, poll this endpoint, or request the transcript directly with POST /calls/{call_id}/transcript-merged/rebuild. Note 202 is not a promise the transcript is coming: a merge that already ran and failed answers 202 too (the automatic trigger fires once per call and is not retried), so a 202 that persists well past the call''s end is the cue to force one rebuild rather than keep polling.'
content:
application/json:
examples:
merger_pending:
value:
status: merger_pending
'401':
description: Missing or invalid X-API-Key.
content:
application/json:
examples:
missing_key:
summary: X-API-Key header absent
value:
detail:
error: API key required
invalid_key:
summary: X-API-Key doesn't resolve to an active key
value:
detail:
error: invalid API key
'404':
description: Call not found or isn't yours — also returned for calls where the callee declined recording (no verbatim transcript may be served; the summary + outcome on GET /calls/{call_id} remain available).
content:
application/json:
examples:
not_found:
value:
detail: Transcript not available
/calls/{call_id}/transcript-merged/rebuild:
post:
tags:
- calls
summary: Rebuild the merged transcript now
description: Rebuild this call's merged transcript from the recording, and return it. The automatic merge trigger fires once per call and cannot be replayed, so a call whose merge failed at that moment — or one merged before a merger fix — would otherwise keep its stale or missing transcript forever. Use this to backfill past calls, or to retry after a 202 on the GET. Idempotent — it rebuilds from the recording and overwrites what was stored. Each request costs a full transcription pass over the recording, so don't poll with it — poll the GET above or wait for the `transcript_ready` SSE event.
operationId: rebuild_transcript_merged
parameters:
- name: X-API-Key
in: header
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: X-Api-Key
- name: call_id
in: path
required: true
schema:
type: string
title: Call Id
responses:
'200':
description: The rebuilt transcript — same body as the GET.
content:
application/json:
schema:
$ref: '#/components/schemas/MergedTranscript'
'401':
description: Missing or invalid X-API-Key.
content:
application/json:
examples:
missing_key:
summary: X-API-Key header absent
value:
detail:
error: API key required
invalid_key:
summary: X-API-Key doesn't resolve to an active key
value:
detail:
error: invalid API key
'404':
description: Call not found or isn't yours — also returned for calls where the callee declined recording (no verbatim transcript may be served).
content:
application/json:
examples:
not_found:
value:
detail: Transcript not available
'425':
description: The recording isn't available yet — the call may still be finalizing. Nothing failed; retry shortly. On an old call, though, a persistent 425 means the recording no longer exists (retention purge) and the transcript cannot be rebuilt.
content:
application/json:
examples:
recording_pending:
value:
status: recording_pending
'503':
description: The rebuild hit a dependency failure. Safe to retry.
content:
application/json:
examples:
merge_failed:
value:
status: merge_failed
'504':
description: The gateway gave up waiting for the rebuild. The rebuild usually still completes and persists on the backend — recover the result with the free GET /calls/{call_id}/transcript-merged instead of re-POSTing.
content:
application/json:
examples:
backend_timeout:
value:
error: backend timeout
error_code: BACKEND_TIMEOUT
/calls/{call_id}/cancel:
post:
tags:
- calls
summary: Cancel a queued or active call
description: 'Best-effort cancel. Returns `{"cancelled": true}` when the call was still cancellable (queued/dialing), `{"cancelled": false}` when it already reached a terminal state — safe to call idempotently.'
operationId: cancel_call
parameters:
- name: X-API-Key
in: header
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: X-Api-Key
- name: call_id
in: path
required: true
schema:
type: string
title: Call Id
responses:
'200':
description: Successful Response
content:
application/json:
schema:
type: object
properties:
cancelled:
type: boolean
title: Cancelled
required:
- cancelled
examples:
already_terminal:
value:
cancelled: false
'401':
description: Missing or invalid X-API-Key.
content:
application/json:
examples:
missing_key:
summary: X-API-Key header absent
value:
detail:
error: API key required
invalid_key:
summary: X-API-Key doesn't resolve to an active key
value:
detail:
# --- truncated at 32 KB (57 KB total) ---
# Full source: https://raw.githubusercontent.com/api-evangelist/voygr/refs/heads/main/openapi/voygr-calls-api-openapi.yml