Place an outbound call via the PBX
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
clientRequestIdfield of every PBX callback and event body — the voice callback, per-number event forwarding, andcall.*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.
Authorization
bearerAuth 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
Header Parameters
Client-supplied idempotency key. Reusing the same key for the same workspace within a live leg returns the original call row instead of a duplicate dial.
length <= 64Request Body
application/json
TypeScript Definitions
Use the request body type in TypeScript.
Response Body
application/json
application/json
application/json
application/json
application/json
application/json
application/json
curl -X POST "https://example.com/v1/calls" \ -H "Content-Type: application/json" \ -d '{ "from": "+254712345678", "to": [ "+254700000001" ] }'{ "call_id": "9d2b1f53-8c0e-4f1d-9a6b-5d3a8c47e9f0", "session_id": "HD_1a2b3c", "status": "ringing"}{ "error": { "code": "validation.bad_request", "message": "`from` is required", "request_id": "req-a1b2c3d4" }}{ "error": { "code": "validation.bad_request", "message": "string", "request_id": "string", "details": [ "string" ] }}{ "error": { "code": "wallet.insufficient_funds", "message": "Unable to place call. Insufficient balance on your account. Top up to continue.", "request_id": "req-a1b2c3d4" }}{ "error": { "code": "validation.bad_request", "message": "string", "request_id": "string", "details": [ "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" ] }}List call detail records for the active workspace GET
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.
Workspace stats (calls today / failed / minutes / spend) GET
Aggregates over the window selected by `since`. Supported values: `today` (default), `7d`, `month`. Currency follows the workspace wallet currency. **Eventual consistency.** `spend_minor` and (to a lesser extent) `minutes_total` are finalized asynchronously, after the call's `call.completed` event has been processed. The lag between hang-up and stats reflecting final cost is typically under 5 seconds, and can be longer at times of high load. Customers building real-time dashboards SHOULD poll this endpoint no more frequently than every 30 seconds. For per-call cost in near-real-time, subscribe to the `call.completed` webhook event instead — its payload carries the final `cost_minor` after rating.