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 withauth_mode: "enforce". On a default instance that setting is"audit"(backend/app/core/config.py:26), and in audit moderequire_tenantreturns 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/callscurl -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.