openapi: 3.1.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\n\
runs 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\n\
response 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`\n\
is 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)
paths:
/health:
get:
tags:
- core
summary: Health check
description: Check if the API server is running
operationId: health_check_health_get
responses:
'200':
description: Successful Response
content:
application/json:
schema: {}
/v1/usage:
get:
tags:
- core
summary: Get API usage statistics
description: Get current usage, quota, and reset date for the API key.
operationId: get_usage_v1_usage_get
parameters:
- name: X-API-Key
in: header
required: false
schema:
anyOf:
- type: string
- type: 'null'
title: X-Api-Key
responses:
'200':
description: Successful Response
content:
application/json:
schema:
$ref: '#/components/schemas/UsageResponse'
'401':
description: Missing API key header
content:
application/json:
example:
success: false
error: API key required. Include X-API-Key header.
error_code: AUTHENTICATION_ERROR
request_id: ad33009a-fdfe-4e25-9650-4a49099c3d4a
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: Invalid, revoked, or unauthorized API key
content:
application/json:
example:
success: false
error: Invalid or revoked API key
error_code: AUTHENTICATION_ERROR
request_id: 25b104f0-9ad0-4fc7-8530-c5d5ff8d9698
schema:
$ref: '#/components/schemas/ErrorResponse'
'422':
description: Validation Error
content:
application/json:
schema:
$ref: '#/components/schemas/HTTPValidationError'
/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 —
# --- truncated at 32 KB (66 KB total) ---
# Full source: https://raw.githubusercontent.com/api-evangelist/voygr/refs/heads/main/openapi/voygr-calls-api-openapi.yml