List call detail records for the active workspace
Returns the workspace's call detail records, most-recent first.
Cursor pagination via cursor (opaque, returned as
next_cursor when more results exist). Filters: status
(CSV), direction, q (substring on remote_e164 or
session_id), from / to (RFC3339 or unix seconds),
session_id (exact match), limit (1–100, default 25).
Also accepts a device credential (Authorization: Device <secret>). For a device principal, results are scoped to that
device's own number and exclude broadcast/API-controlled
originations — the same filter GET /v1/devices/calls/stream
applies — so this list and that stream never disagree about
what counts as the device's own call history.
Long-lived ES256 JWT minted from the dashboard (https://app.sautikit.com/developers/api-keys). Signed by the
platform keyring. Carries workspace_id and scopes claims;
revoked via the platform deny-list.
In: header
Query Parameters
Comma-separated subset of statuses. The backend accepts
BOTH the canonical underscored form (no_answer,
in_progress) AND the dash-cased form
(no-answer, in-progress) — dashes are normalized to underscores before matching. Values
are always returned in the underscored form.
Value in
- "ringing"
- "answered"
- "completed"
- "failed"
- "no_answer"
- "no-answer"
- "busy"
- "canceled"
Value in
- "inbound"
- "outbound"
1 <= value <= 100Response Body
application/json
application/json
application/json
curl -X GET "https://example.com/v1/calls"{ "data": [ { "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "workspace_id": "0967198e-ec7b-4c6b-b4d3-f71244cadbe9", "session_id": "string", "direction": "inbound", "local_e164": "string", "remote_e164": "string", "country": "string", "started_at": "2019-08-24T14:15:22Z", "answered_at": "2019-08-24T14:15:22Z", "ended_at": "2019-08-24T14:15:22Z", "duration_seconds": 0, "status": "ringing", "cost_minor": 0, "cost_currency": "string", "rate_per_minute_minor": 0, "ledger_entry_id": "34c94b99-ae7e-4ed1-928e-78f0df05c275", "pbx_call_id": "string", "failure_code": "string", "metadata": {}, "created_at": "2019-08-24T14:15:22Z", "updated_at": "2019-08-24T14:15:22Z", "events": [ { "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", "call_id": "0d0a16ab-a508-4d58-bf24-5dc3cb248f3e", "kind": "string", "payload": {}, "occurred_at": "2019-08-24T14:15:22Z", "created_at": "2019-08-24T14:15:22Z" } ] } ], "next_cursor": "string"}{ "error": { "code": "validation.bad_request", "message": "string", "request_id": "string", "details": [ "string" ] }}{ "error": { "code": "validation.bad_request", "message": "string", "request_id": "string", "details": [ "string" ] }}Upload an audio file (mp3/wav) to play back on a call POST
Streams the uploaded file to Sautikit's object storage and returns a signed `storage.sautikit.com` playback URL — the same branded-CDN mechanism `GET /v1/calls/{call_id}/recording` uses for captured call recordings. This is how you obtain a hosted URL for any "play a recording" setting; there is no other supported way to hand us audio. The returned `url` is what you pass on `PUT /v1/numbers/{id}/routing` to every field that plays a recording: - `voicemail.greeting_play_url` — the voicemail greeting. - `queue.greeting_play_url` — spoken once, as the caller joins. - `queue.hold_play_url` — the hold loop. Send `queue.hold_duration_seconds` alongside it: no verb can cut a `<Play>` short, so the file's real length is what sets the queue's cadence, and a wrong value there turns a short clip into a rapid-fire callback loop. - the same `voicemail` / `queue` blocks nested under `forward.voicemail`, `queue.voicemail` and `inbound_agent`. Accepts `multipart/form-data` with a single `file` field. Only `audio/mpeg` (mp3) and `audio/wav` (`audio/wav`, `audio/wave`, `audio/x-wav`) are accepted — anything else is rejected with `uploads.unsupported_media`. Max size 10 MiB (`uploads.too_large` above that). API-key callers need the `numbers.claim` scope; viewers are rejected. The URL is presigned for 7 days (SigV4's maximum), but you do NOT need to re-upload to keep a saved greeting working: a stored `*_play_url` is re-signed automatically each time the call is rendered, so it keeps playing indefinitely. The 7-day window only bounds the returned link itself — treat it as expiring if you hand it to a browser or store it somewhere outside a number's routing config.
Place an outbound call via the PBX POST
Places an outbound call from a workspace number (`from`) to one or more destination numbers (`to`). The call is dialed immediately and the response returns before the remote party answers — poll `GET /v1/calls/{id}` or subscribe to `call.answered` / `call.completed` webhooks for final status. A wallet pre-flight check fires before the PBX is contacted: if the workspace balance is zero the request is rejected 402 (`wallet.insufficient_balance`) without a PBX leg being created. Idempotency & correlation: the `Idempotency-Key` header scopes deduplication — repeated requests with the same key within the active leg's lifetime return the original response. The header value is also forwarded to the PBX as its `clientRequestId` when no `client_request_id` label is sent, and the PBX echoes that field back on callbacks. To correlate on identifiers Sautikit issues instead, use the `call_id` / `session_id` returned by this endpoint (they appear on `call.completed` / `call.failed` webhook payloads as `call_id` / `pbx_call_id`). Loopback label: send `client_request_id` (or its camelCase alias `clientRequestId`) to attach your OWN correlation handle to the call — an order id, a ticket number, your own uuid. It becomes the call's `clientRequestId` on the PBX, which echoes that field back verbatim, so an asynchronous callback can be tied to whatever the call was placed for without keeping a map of our identifiers: - on the 201 response, under whichever of the two spellings you sent; - as the `clientRequestId` field of every PBX callback and event body — the voice callback, per-number event forwarding, and `call.*` webhook deliveries; - on your voice callback URL as `?clientRequestId=…`. Callback and event bodies are forwarded exactly as the PBX sent them; nothing is rewritten to carry your label. The label appears in them because it IS the call's identifier on the box. Because the PBX treats `clientRequestId` as an idempotency hint, a label is a per-call identity: use a distinct one per call rather than reusing a single label across a campaign. Deduplication of your own retries is still `Idempotency-Key`, which the label replaces on the wire when both are sent. Labels beginning `mcp:` or `broadcast:` are refused (400) — those prefixes are reserved for internal call routing. Calls placed with `control: "mcp"` keep the internal token on the wire, so a label sent with one is echoed on the 201 response only. Which labels come back on callbacks: the PBX refuses a `clientRequestId` containing **whitespace, commas, braces or quotes**, so a label carrying any of those cannot ride the call. The call is still placed — it carries an internal token instead — and your label is echoed on the 201 response, but it will NOT appear on callback or event bodies. A JSON document is therefore not usable as a label; send an opaque id (order number, ticket id, uuid) and keep the structure on your own side. Large labels: a label whose percent-encoded form exceeds 2048 bytes is omitted from the `?clientRequestId=` query parameter. A request line that long is answered 414 by common webserver defaults (nginx, Apache), and that rejection would land on YOUR endpoint and break the call's routing rather than just its correlation. The callback body still carries it whatever its size, since that is the PBX's own field. Keep a label under ~2000 characters to have it on the query string too.