{"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\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"},"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\nevent: status_change\ndata: {\"event_id\": 2077, \"call_id\": \"c_abc123\", \"event_type\": \"status_change\", \"data\": {\"status\": \"dialing\"}}\n\n"}}}}},"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":{"error":"invalid API key"}}}}}}},"404":{"description":"Call not found, or isn't yours.","content":{"application/json":{"examples":{"not_found":{"value":{"detail":"Call not found"}}}}}}}}},"/skills":{"get":{"tags":["calls"],"summary":"List available skills","description":"Discovery for the structured path: every runnable skill with its manifest/run URLs. The concierge skill owns the three POST /calls structured intents.","operationId":"list_skills","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":{"type":"array","items":{"type":"object","properties":{"skill_id":{"type":"string"},"title":{"type":"string"},"description":{"type":"string"},"manifest_url":{"type":"string"},"run_url":{"type":"string"}},"required":["skill_id","title","manifest_url"]}}}}},"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"}}}}}}}}}},"/skills/{skill_id}/manifest":{"get":{"tags":["calls"],"summary":"Get a skill's manifest (slot schemas)","description":"Machine-readable contract for a skill: `input_schema` (JSON Schema for its slots), `intent_types_supported`, `event_types`, `ask_user_modes_supported`, URL templates and limits. The 422 `missing_slots` hints on POST /calls point here — fetch it once and drive slot collection from it.","operationId":"get_skill_manifest","parameters":[{"name":"X-API-Key","in":"header","required":false,"schema":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"X-Api-Key"}},{"name":"skill_id","in":"path","required":true,"schema":{"type":"string","title":"Skill Id","examples":["concierge"]}}],"responses":{"200":{"description":"Successful Response","content":{"application/json":{"schema":{"type":"object","additionalProperties":true,"description":"Keys include: skill_id, title, description, version, input_schema, intent_types_supported, event_types, ask_user_modes_supported, run/status/answer/events URL templates, limits."}}}},"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":"Unknown skill_id.","content":{"application/json":{"examples":{"not_found":{"value":{"detail":"Skill not found"}}}}}}}}},"/users/me":{"get":{"tags":["calls"],"summary":"Who am I / quota snapshot","description":"Identity + quota for the presented API key. Lighter-weight companion to GET /v1/usage with key metadata included.","operationId":"get_me","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":{"type":"object","properties":{"api_key_id":{"type":"string"},"customer_id":{"type":"string"},"customer_name":{"type":"string"},"api_key_last_4":{"type":"string"},"status":{"type":"string"},"quota_limit":{"type":"integer"},"current_usage":{"type":"integer"},"quota_period":{"type":"string"},"ask_user_webhook_url":{"type":["string","null"]},"ask_user_webhook_configured":{"type":"boolean"}},"required":["api_key_id","customer_id","quota_limit","current_usage"]}}}},"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"}}}}}}}}}}},"components":{"schemas":{"ErrorResponse":{"properties":{"success":{"type":"boolean","title":"Success","description":"Always false for errors","default":false},"error":{"type":"string","title":"Error","description":"Error message"},"error_code":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Error Code","description":"Error category code (VALIDATION_ERROR, MODEL_ERROR, EXTERNAL_API_ERROR, etc.)"},"request_id":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Request Id","description":"Request ID for tracing"},"validation_timestamp":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Validation Timestamp","description":"UTC timestamp when error response was produced (ISO 8601)."},"detail":{"anyOf":[{"additionalProperties":true,"type":"object"},{"type":"null"}],"title":"Detail","description":"Additional error details"}},"type":"object","required":["error"],"title":"ErrorResponse","description":"Error response model.","examples":[{"success":false,"error":"API key required. Include X-API-Key header.","error_code":"AUTHENTICATION_ERROR","request_id":"ad33009a-fdfe-4e25-9650-4a49099c3d4a"},{"success":false,"error":"Invalid or revoked API key","error_code":"AUTHENTICATION_ERROR","request_id":"25b104f0-9ad0-4fc7-8530-c5d5ff8d9698"}]},"HTTPValidationError":{"properties":{"detail":{"items":{"$ref":"#/components/schemas/ValidationError"},"type":"array","title":"Detail"}},"type":"object","title":"HTTPValidationError"},"UsageResponse":{"properties":{"tier":{"type":"string","title":"Tier","description":"API key tier (free, paid, enterprise)"},"api_key_id":{"type":"string","title":"Api Key Id","description":"API key ID"},"customer_name":{"type":"string","title":"Customer Name","description":"Customer name"},"quota_limit":{"type":"integer","title":"Quota Limit","description":"Maximum requests allowed per period"},"current_usage":{"type":"integer","title":"Current Usage","description":"Current usage count"},"remaining":{"type":"integer","title":"Remaining","description":"Remaining requests"},"available":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Available","description":"Credits spendable right now — same value as `remaining`, named for the question callers ask. Already net of credits frozen by in-flight reservations."},"call_credit_hold":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Call Credit Hold","description":"Credits ONE outbound call freezes at dial time (a refundable hold, not a charge). available // call_credit_hold is the CREDIT-limited bound on calls in flight — entitlement and the per-customer concurrency cap gate independently, so it is not a guarantee that many will start."},"percentage_used":{"type":"number","title":"Percentage Used","description":"Percentage of quota used"},"reset_date":{"type":"string","title":"Reset Date","description":"Date when usage resets (YYYY-MM-DD)"},"period":{"type":"string","title":"Period","description":"Quota period (monthly, weekly, daily)"},"status":{"type":"string","title":"Status","description":"API key status (active, revoked, suspended)"},"validation_timestamp":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Validation Timestamp","description":"UTC timestamp when usage response was produced (ISO 8601)."}},"type":"object","required":["tier","api_key_id","customer_name","quota_limit","current_usage","remaining","percentage_used","reset_date","period","status"],"title":"UsageResponse","description":"Response model for usage endpoint.","examples":[{"api_key_id":"f4d0a2c2-f73f-4de6-ac3e-f42ce35af4d6","customer_name":"Acme Inc","quota_limit":10000,"current_usage":2375,"remaining":7625,"percentage_used":23.75,"reset_date":"2026-03-31","period":"monthly","status":"active"},{"api_key_id":"eaa59659-7fab-4d1f-a80d-4f1e0f9388e4","customer_name":"Beta Foods","quota_limit":5000,"current_usage":5000,"remaining":0,"percentage_used":100.0,"reset_date":"2026-03-01","period":"monthly","status":"active"}]},"ValidationError":{"properties":{"loc":{"items":{"anyOf":[{"type":"string"},{"type":"integer"}]},"type":"array","title":"Location"},"msg":{"type":"string","title":"Message"},"type":{"type":"string","title":"Error Type"},"input":{"title":"Input"},"ctx":{"type":"object","title":"Context"}},"type":"object","required":["loc","msg","type"],"title":"ValidationError"},"TranscriptTurn":{"type":"object","title":"TranscriptTurn","description":"One line of a call transcript.","properties":{"ts":{"type":"string","format":"date-time","title":"Ts"},"role":{"type":"string","title":"Role","enum":["operator","bot","supervisor_stt","system"],"description":"operator = the callee, bot = the agent, supervisor_stt = a parallel transcription of the operator's side, system = lifecycle markers."},"text":{"type":"string","title":"Text"}},"required":["ts","role","text"]},"MergedTranscriptTurn":{"type":"object","title":"MergedTranscriptTurn","description":"One turn of the post-call merged transcript.","properties":{"speaker":{"type":"string","title":"Speaker","enum":["bot","hostess"],"description":"bot = the agent; hostess = the other party — staff, receptionist or IVR (the name is historical and covers every callee-side voice)."},"start_ms":{"type":"integer","title":"Start Ms"},"end_ms":{"type":"integer","title":"End Ms"},"text":{"type":"string","title":"Text"},"source":{"type":"string","title":"Source","enum":["deepgram_batch_multichannel","deepgram_live_multichannel","deepgram_batch","deepgram_live"],"description":"Where the turn came from: deepgram_batch_multichannel = the dual-channel recording transcribed per channel (the normal case — exact timing on one clock); deepgram_live_multichannel = per-channel live STT; deepgram_batch = a single-channel recording (bot side only); deepgram_live = live STT (callee side)."},"words":{"type":"array","title":"Words","items":{"type":"object","properties":{"start_ms":{"type":"integer"},"end_ms":{"type":"integer"},"text":{"type":"string"}}},"description":"Per-word timings, when the source provides them."},"overlap_with_next":{"type":"boolean","title":"Overlap With Next","description":"This turn overlapped the next one in time (cross-talk). When true, end_ms has been trimmed to the next turn's start_ms; the untrimmed value is in original_end_ms."},"original_end_ms":{"type":"integer","title":"Original End Ms","description":"The pre-trim end_ms, present only when overlap_with_next is true."},"timing_estimated":{"type":"boolean","title":"Timing Estimated","description":"The text is real but the milliseconds are a reading order, not a measurement — don't plot such turns on a timeline."}},"required":["speaker","start_ms","end_ms","text","source"]},"MergedTranscript":{"type":"object","title":"MergedTranscript","description":"The post-call merged transcript: both sides of the call on one timeline, built from the dual-channel call recording after the call ends. A fuller record than the live transcript_full — see GET /calls/{call_id}/transcript-merged.","properties":{"version":{"type":"integer","title":"Version","examples":[1]},"duration_ms":{"type":"integer","title":"Duration Ms"},"calibration_delta_ms":{"type":"number","title":"Calibration Delta Ms","description":"0 whenever both sides came off one recording; non-zero only on the single-channel fallback, where the two sources have to be aligned."},"turns":{"type":"array","title":"Turns","items":{"$ref":"#/components/schemas/MergedTranscriptTurn"}}},"required":["version","duration_ms","calibration_delta_ms","turns"]},"CallDTO":{"type":"object","title":"CallDTO","description":"A call. GET /calls list entries omit transcript_full/supervisor_decisions — fetch GET /calls/{call_id} for those.","properties":{"call_id":{"type":"string","title":"Call Id","examples":["c_abc123"]},"customer_id":{"type":"string","title":"Customer Id","examples":["cus_xyz"]},"task_id":{"type":"string","title":"Task Id","examples":["t_abc123"]},"target_phone":{"type":"string","title":"Target Phone","examples":["+15551234567"]},"language":{"type":"string","title":"Language","enum":["en","es","fr","de","hi","ru","pt","ja","it","nl","sr","tr","pl","auto"]},"status":{"type":"string","title":"Status","enum":["queued","dialing","in_progress","completed","failed","cancelled"]},"call_sid":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Call Sid","description":"The Twilio Call SID. `null` until dialing starts.","examples":["CAxxxxxxxx"]},"started_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Started At"},"ended_at":{"anyOf":[{"type":"string","format":"date-time"},{"type":"null"}],"title":"Ended At"},"duration_sec":{"anyOf":[{"type":"integer"},{"type":"null"}],"title":"Duration Sec"},"outcome_type":{"anyOf":[{"type":"string","enum":["success_booked","success_refused","success_no_booking","failed_no_answer","failed_voicemail","failed_busy","failed_short_hangup","failed_technical","failed_no_agent_available"]},{"type":"null"}],"title":"Outcome Type","description":"Set once the call is terminal. success_* outcomes are billed 10 credits; failed_* are billed 0. failed_no_agent_available = the venue kept the agent in a hold queue past the hold budget and no human ever picked up."},"outcome_summary":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Outcome Summary"},"outcome_charge_cents":{"type":"integer","title":"Outcome Charge Cents","default":0,"description":"Despite the name, this is in the same credit unit as credits_reserved. A successful call settles at 10 (the charge), unsuccessful at 0 — not the 30 held."},"created_at":{"type":"string","format":"date-time","title":"Created At"},"has_recording":{"type":"boolean","title":"Has Recording","default":false},"recording_url":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Recording Url","description":"A RELATIVE path to this service's recording proxy endpoint (/calls/{call_id}/recording) when a recording exists — never the raw Twilio mp3 URL. `null` when has_recording is false. Retrieval isn't documented here."},"reservation_signals":{"anyOf":[{"type":"array","items":{"type":"string"}},{"type":"null"}],"title":"Reservation Signals","description":"Short free-text signals (<10 short strings) about the reservation outcome. Always included (not detail-gated like transcript_full)."},"transcript_full":{"anyOf":[{"type":"array","items":{"$ref":"#/components/schemas/TranscriptTurn"}},{"type":"null"}],"title":"Transcript Full","description":"Only populated on GET /calls/{call_id}, not on the GET /calls list."},"supervisor_decisions":{"anyOf":[{"type":"array","items":{"type":"object","additionalProperties":true}},{"type":"null"}],"title":"Supervisor Decisions","description":"Only populated on GET /calls/{call_id}, not on the GET /calls list (same heavy-field gating as transcript_full)."}},"required":["call_id","customer_id","task_id","target_phone","language","status","created_at"]},"CallCreateRequest":{"type":"object","title":"CallCreateRequest","description":"Two shapes — pick one per call. Freeform: target_phone + brief (+ optional language). Structured: intent + slots (one of inquiry, info_gathering, issue_resolution, booking, cancellation). target_phone is required on the freeform path only — the structured path reads slots.target_phone, and cancellation needs no phone at all.","properties":{"target_phone":{"type":"string","title":"Target Phone","description":"E.164 format.","examples":["+15551234567"]},"brief":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Brief","description":"Freeform natural-language task; becomes the agent's system prompt. Required on the freeform path."},"language":{"type":"string","title":"Language","enum":["en","es","fr","de","hi","ru","pt","ja","it","nl","sr","tr","pl","auto"],"default":"auto","description":"ISO 639-1 code or `auto`. Unsupported codes are refused with 422 unsupported_language."},"intent":{"anyOf":[{"type":"string","enum":["inquiry","info_gathering","issue_resolution","booking","cancellation"]},{"type":"null"}],"title":"Intent","description":"Structured path only."},"slots":{"anyOf":[{"type":"object","additionalProperties":true},{"type":"null"}],"title":"Slots","description":"Structured path only. Required slots per intent: inquiry -> target_phone, question; info_gathering -> target_phone, questions; issue_resolution -> target_phone, issue_description; booking -> target_phone, name, date, time, party_size; cancellation -> booking_id."},"kind":{"type":"string","title":"Kind","default":"other","enum":["restaurant_booking","restaurant_cancel","doctor_appointment","hotel_booking","concierge","other"],"description":"Optional freeform-path task category. Purely a routing/analytics hint — the brief still carries the actual task. Ignored on the structured path (intent decides)."},"ask_user_mode":{"type":"string","title":"Ask User Mode","default":"any","enum":["any","stream"],"description":"Routing for the agent's mid-call ask_user questions. `stream` delivers them as `ask_user` events on GET /calls/{call_id}/events (answer via POST /calls/{call_id}/answer) — the right choice for API clients. `any` (default) tries the legacy operator channels first; API-only integrations may never see the question."}},"anyOf":[{"title":"Freeform","required":["target_phone","brief"]},{"title":"Structured","required":["slots"],"properties":{"slots":{"type":"object","minProperties":1}}}]},"CallResponse":{"type":"object","title":"CallResponse","description":"POST /calls success response (201, or 202 on deployments that queue before dialing).","properties":{"call":{"$ref":"#/components/schemas/CallDTO"},"task_id":{"type":"string","title":"Task Id"},"credits_reserved":{"type":"integer","title":"Credits Reserved","description":"The refundable HOLD placed at dial time (10). Not a charge — released at completion, minus credits_charge_on_success if the outcome is billable.","examples":[10]},"credits_charge_on_success":{"type":"integer","title":"Credits Charge On Success","description":"What the call actually costs if it reaches a billable outcome (10). Unsuccessful outcomes cost 0 and the whole hold comes back.","examples":[10]},"owner_pod":{"type":"string","title":"Owner Pod"}},"required":["call","task_id","credits_reserved","owner_pod"]},"SkillRunResponse":{"type":"object","title":"SkillRunResponse","description":"POST /calls success response for the STRUCTURED (slots) path ONLY. The structured branch runs through the same pipeline as the skills engine and returns this flat envelope instead of CallResponse — no `call` wrapper, top-level call_id. 201 Created (dial started immediately), or 202 Accepted with status='queued', owner_pod=null and call_sid=null (drainer hasn't dialed yet) on deployments that queue before dialing. Whichever path created the call, poll GET /calls/{call_id} (the status_url) the same way afterward — the call object itself is identical from there on.","properties":{"skill_run_id":{"type":"string","title":"Skill Run Id","examples":["srun_c_abc123"]},"call_id":{"type":"string","title":"Call Id","examples":["c_abc123"]},"call_sid":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Call Sid","description":"null until the drainer dials (e.g. status='queued')."},"owner_pod":{"anyOf":[{"type":"string"},{"type":"null"}],"title":"Owner Pod","description":"null until the drainer dials (e.g. status='queued')."},"status":{"type":"string","title":"Status","enum":["queued","dialing","in_progress","completed","failed","cancelled"]},"credits_reserved":{"type":"integer","title":"Credits Reserved","description":"The refundable HOLD, not a charge: 10 on the synchronous (immediate-dial) path. On the queued (202) path this is 0 for a GATEWAY-MEDIATED request — the NORMAL case for public-API callers — because the gateway already holds the reservation; callwright reports what it itself holds locally (0) so a reconciler never over-refunds. See credits_charge_on_success for what the run actually costs.","examples":[10,0]},"credits_charge_on_success":{"type":"integer","title":"Credits Charge On Success","description":"What the run costs if the call reaches a billable outcome (10). Unsuccessful outcomes cost 0.","examples":[10]},"status_url":{"type":"string","title":"Status Url","description":"Same as GET /calls/{call_id}.","examples":["/calls/c_abc123"]},"answer_url":{"type":"string","title":"Answer Url","examples":["/calls/c_abc123/answer"]},"recording_url":{"type":"string","title":"Recording Url","description":"Always a relative path to this service's recording proxy (/calls/{call_id}/recording) — never null, even before a recording exists (unlike CallDTO.recording_url).","examples":["/calls/c_abc123/recording"]},"replayed":{"type":"boolean","title":"Replayed","default":false},"expected_next_steps":{"type":"array","items":{"type":"string"},"title":"Expected Next Steps"}},"required":["skill_run_id","call_id","call_sid","owner_pod","status","credits_reserved","status_url","answer_url","recording_url","expected_next_steps"]},"QueuedCallResponse":{"type":"object","title":"QueuedCallResponse","description":"POST /calls 202 Accepted body for the FREEFORM path ONLY, on deployments that queue before dialing. The call was persisted and credits reserved but NOT dialed yet — the drainer dials it later at the global rate limit. Poll GET /calls/{call_id} until status leaves 'queued'. Distinct from SkillRunResponse (the structured path's own 202 shape) — this is a separate, much smaller envelope with no owner_pod/call_sid fields.","properties":{"call_id":{"type":"string","title":"Call Id","examples":["c_abc123"]},"queue_id":{"type":"string","title":"Queue Id"},"position":{"type":"integer","title":"Position","description":"FIFO place in the queue (0 == front). Counts entries still pending or currently being dialed (warming), so this can read 0 while the entry just ahead is mid-dial.","examples":[0]},"status":{"type":"string","title":"Status","enum":["queued"],"default":"queued"},"credits_reserved":{"type":"integer","title":"Credits Reserved","description":"The refundable HOLD already placed for this queued call. 0 for a GATEWAY-MEDIATED request (the normal public-API case) — the gateway holds it instead.","examples":[10,0]},"credits_charge_on_success":{"type":"integer","title":"Credits Charge On Success","description":"What the call costs if it reaches a billable outcome (10).","examples":[10]}},"required":["call_id","queue_id","position","status"]},"AnswerRequest":{"type":"object","title":"AnswerRequest","description":"Answer a pending mid-call question. Exactly one of `answer` or `outcome` must be given — never both, never neither (server-side model_validator rejects both cases with 'provide exactly one of answer or outcome'). When `outcome` is given, `request_id` is also required (server rejects with 'outcome requires request_id' otherwise) — it disambiguates which pending ask_user is being closed out when several are in flight on the same call.","properties":{"answer":{"anyOf":[{"type":"string","minLength":1,"maxLength":4000},{"type":"null"}],"title":"Answer","description":"Free text the bot will dictate to the operator (mode=voice, the default) or act on silently (mode=context). Mutually exclusive with `outcome`."},"outcome":{"anyOf":[{"type":"string","enum":["timeout","transport_failure"]},{"type":"null"}],"title":"Outcome","description":"Closed client-side outcome when the observed ask_user request cannot receive an answer. Requires `request_id`. Mutually exclusive with `answer`."},"request_id":{"anyOf":[{"type":"string","maxLength":64},{"type":"null"}],"title":"Request Id","description":"Disambiguates which pending ask_user this answers, when several are in flight on the same call. Required when `outcome` is set; optional with `answer` (falls back to FIFO — oldest pending — when omitted)."},"mode":{"type":"string","enum":["voice","context"],"default":"voice","title":"Mode","description":"'voice' (default): the bot speaks `answer` aloud to the operator. 'context': silent guidance — the bot acts on `answer` without voicing it (e.g. concierge IVR navigation hints). Only meaningful with `answer`, not `outcome`."}},"oneOf":[{"required":["answer"],"not":{"required":["outcome"]}},{"required":["outcome","request_id"],"not":{"required":["answer"]}}]},"AnswerResponse":{"type":"object","title":"AnswerResponse","properties":{"delivered":{"type":"boolean","title":"Delivered"},"reason":{"type":"string","title":"Reason","enum":["delivered","no_pending_request"],"description":"'delivered' — a transport took the answer. 'no_pending_request' — nothing was pending under this request_id (already answered, expired (~60s), or unknown) — the answer was silently dropped (not an error; a stale retry lands here safely)."}},"required":["delivered","reason"]}}}}