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

POST /v1/calls loads the agent, pre-fetches context and pre-synthesises the opening template for a real phone call; by default it only prepares that call, and a retried request must carry an Idempotency-Key or it risks dialling the same customer twice.


`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](/docs/api/telephony) 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`](/docs/errors/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](/docs/authentication).

## Place a call

```varta-endpoint
POST /v1/calls
```

```bash
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](/docs/api/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](/docs/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`):

```json
{
  "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:

```json
{
  "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`](/docs/errors/invalid_request) when `agent_id` or `to`
is missing or `context` isn't an object,
[`workflow_not_found`](/docs/errors/workflow_not_found) when `agent_id` is malformed,
[`not_found`](/docs/errors/not_found) when it's well-formed but doesn't belong to your
tenant, [`rate_limit_exceeded`](/docs/errors/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`](/docs/errors/idempotency_key_reuse) when the
`Idempotency-Key` was already used with a different body, and
[`idempotency_key_in_progress`](/docs/errors/idempotency_key_in_progress) when a
request with that key is still being processed. With `dial: true` additionally:
[`invalid_request`](/docs/errors/invalid_request) again, this time for a missing
`trunk_id` when your tenant has zero or more than one trunk,
[`telephony_not_configured`](/docs/errors/telephony_not_configured) when this
deployment has no provider configured,
[`trunk_not_found`](/docs/errors/trunk_not_found) when `trunk_id` doesn't resolve to a
registered trunk, [`caller_id_not_on_trunk`](/docs/errors/caller_id_not_on_trunk) when
`from` isn't one of the trunk's registered numbers, and
[`dial_failed`](/docs/errors/dial_failed) when the provider rejects the dial.

## Get call status

```varta-endpoint
GET /v1/calls/{call_id}
```

```bash
curl "$VARTA_BASE_URL/calls/cl_9021" \
  -H "Authorization: Bearer $VARTA_API_KEY"
```

```json
{
  "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`](/docs/api/sessions) (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`](/docs/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`](/docs/errors/not_found) when it's
well-formed but doesn't belong to your tenant.
