API Reference
Calls

Place an outbound call via the PBX

POST
/v1/calls

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.

Authorization

bearerAuth
AuthorizationBearer <token>

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

Idempotency-Key?string

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.

Lengthlength <= 64

Request 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.