Skip to main content
VARTA

How do I place an outbound VARTA call, and how do I make a retry safe?

POST /v1/calls is the outbound-call entry point. By default it only prepares a call — it loads the agent, pre-fetches customer context and pre-synthesises the opening line, then returns without ringing anyone. Add "dial": true to the body and, on a deployment that has a telephony provider configured (a registered trunk plus LiveKit credentials), the same request places the real SIP dial. On a deployment with no provider configured, "dial": true fails with telephony_not_configured rather than silently staying prepare-only — a call that reports success without ringing anyone would be the wrong kind of quiet failure.

Tenant isolation on this page applies in enforce mode. The scoping described below — "your tenant", and an id belonging to another tenant reported as not_found — is enforced only when the instance runs with auth_mode: "enforce". On a default instance that setting is "audit" (backend/app/core/config.py:26), and in audit mode require_tenant returns the resource without comparing tenants at all (backend/app/security/ownership.py:51-52). Object-level isolation is then observed but not enforced: a well-formed id belonging to another tenant resolves normally instead of 404ing. See Authentication.

Place a call

POST /v1/calls
curl -X POST "$VARTA_BASE_URL/calls" \
  -H "Authorization: Bearer $VARTA_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 8f14e45f-ceea-467e-bd36-6df963f3f223" \
  -d '{
    "agent_id": "ag_412",
    "to": "+9198xxxxxxx",
    "context": {"customer_name": "Rahul", "amount_due": 2350}
  }'

agent_id and to are required. from, context, title and customer_name are optional; to and from are recorded as call metadata for correlation and personalisation — on this prepare-only default they never reach a carrier. Pass "dial": true to actually place the call, and optionally trunk_id (required once you're dialling if your tenant has zero or more than one trunk registered — zero because there is nothing to default to, more than one because the choice is ambiguous; see Telephony), ringing_timeout_s and max_call_duration_s.

The agent must be an outbound agent

POST /v1/calls is not valid for every agent. The agent's workflow has to have direction: "outbound"; a non-outbound agent is rejected before any work happens (backend/app/services/mind_initializer.py:697-699).

That rejection surfaces in a way worth knowing about in advance: the internal code is workflow_not_outbound, which is not in the published error catalogue, so the error layer downgrades it to a not_found carrying the message "Workflow not outbound." (backend/app/api/v1/errors.py:190-193). A not_found from this endpoint with that message is therefore not a tenancy or a missing-agent condition — the agent exists and is yours, it is just pointed the wrong way. Read message, not just code, to tell the two apart, and fix it by setting the agent's direction to outbound in the studio rather than by hunting for the right id.

Always send an Idempotency-Key

Preparing a call does real work — context fetch, speech synthesis, and once you pass dial: true, an actual telephone ringing. A client that retries POST /v1/calls after a timeout, with no way for the server to recognise the retry, dials the same customer a second time: duplicate spend, and on a regulated outbound campaign, a repeat-contact compliance incident. Send an Idempotency-Key header (any string unique to this call attempt — a UUID is the usual choice) and a retry with the identical key and body replays the first response instead of running the handler again — the replayed response carries an Idempotency-Replayed: true header, so your client can tell a replay from a fresh dial. The same key reused with a different body is rejected with idempotency_key_reuse; a key still being processed is rejected with idempotency_key_in_progress. Failed (5xx) attempts are not stored, so a retry after a genuine failure is still free to actually retry. See Idempotency for the full semantics — every state-changing endpoint under /v1 honours this header, not only this one; it matters most here because the retried side effect is a real phone ringing.

Prepare-only response (default, no dial):

{
  "call_id": "cl_9021",
  "session_id": "se_9021",
  "agent_id": "ag_412",
  "to": "+9198xxxxxxx",
  "from": null,
  "idempotent": true,
  "status": "prepared",
  "dial_required": true
}

idempotent reflects whether you sent the header on this request — it does not mean the request itself was a replay. Response when "dial": true succeeds against a configured provider:

{
  "call_id": "cl_9021",
  "session_id": "se_9021",
  "agent_id": "ag_412",
  "to": "+9198xxxxxxx",
  "from": "+9180xxxxxxx",
  "idempotent": true,
  "status": "ringing",
  "dial_required": false,
  "room_name": "varta-cl_9021",
  "provider_call_id": "SPX_xxx",
  "trunk_id": "tk_a1"
}

Errors: invalid_request when agent_id or to is missing or context isn't an object, workflow_not_found when agent_id is malformed, not_found when it's well-formed but doesn't belong to your tenant, rate_limit_exceeded when your tenant is over its daily spend budget — note this check is off in the default configuration, since it short-circuits unless tenant_daily_budget_inr > 0 and that setting defaults to 0.0, meaning unlimited (backend/app/security/abuse_guard/limiter.py:83-89, backend/app/core/config.py:48) — idempotency_key_reuse when the Idempotency-Key was already used with a different body, and idempotency_key_in_progress when a request with that key is still being processed. With dial: true additionally: invalid_request again, this time for a missing trunk_id when your tenant has zero or more than one trunk, telephony_not_configured when this deployment has no provider configured, trunk_not_found when trunk_id doesn't resolve to a registered trunk, caller_id_not_on_trunk when from isn't one of the trunk's registered numbers, and dial_failed when the provider rejects the dial.

Get call status

GET /v1/calls/{call_id}
curl "$VARTA_BASE_URL/calls/cl_9021" \
  -H "Authorization: Bearer $VARTA_API_KEY"
{
  "call_id": "cl_9021",
  "session_id": "se_9021",
  "agent_id": "ag_412",
  "status": "in_progress",
  "started_at": 1735600000.1,
  "ended_at": null,
  "duration_s": null,
  "customer_name": "Rahul",
  "cost": { "llm_inr": 0.04, "tts_inr": 0.01, "stt_inr": 0, "total_inr": 0.05 }
}

status is "completed" once the underlying session has been ended via POST /v1/sessions/{session_id}/end (directly, or by whatever ends the call on your side), "in_progress" until then. duration_s is always present — it is null while the call is in progress and becomes a number once both started_at and ended_at are set (backend/app/api/v1/calls.py:190). Branch on its value being null, not on the key being absent.

Errors: session_not_found when call_id is malformed — the underlying lookup is keyed by session, so a bad call id reports as a missing session, not a missing call, not_found when it's well-formed but doesn't belong to your tenant.