# How do I make a POST /v1/calls retry safe?

Send an Idempotency-Key header on any POST, PUT or PATCH under /v1 and a retry with the identical key and body replays the stored response instead of running the handler again — the one endpoint where this matters most is POST /v1/calls, where a retry without a key is a second real telephone call.


`IdempotencyMiddleware` sits in front of every state-changing `/v1` request
and gives a retried request a way to say "this is the same attempt, not a new
one" (`backend/app/api/v1/idempotency.py`). It applies to any `POST`, `PUT`
or `PATCH` under `/v1` — `IDEMPOTENT_METHODS` (`idempotency.py:45`), and the
middleware is mounted with `prefix="/v1"` — not only to
[`POST /v1/calls`](/docs/api/calls#place-a-call). Register a trunk, bind a
number, submit a turn: all of it is covered.

## Why it matters most for placing a call

A network timeout doesn't tell you whether the request it interrupted
actually ran. For most `/v1` endpoints, retrying blind is at worst wasteful.
For `POST /v1/calls`, retrying blind means the second attempt can place a
second real telephone call to the same person — duplicate spend at best, and
on a regulated outbound campaign, a repeat-contact compliance incident. An
`Idempotency-Key` turns "did that actually go through?" from a guess into a
provable replay.

## The four behaviours

Send the key as an `Idempotency-Key` header, any string unique to one
logical attempt (a UUID is the usual choice). The middleware fingerprints
the request by method, path and body (`_fingerprint`,
`idempotency.py:52-59`) and behaves as follows:

1. **No `Idempotency-Key` header.** Nothing changes — the handler simply runs
   (`idempotency.py:144-147`).
2. **Same key, same body.** The stored response is replayed, the handler is
   **not** run again, and the response carries an `Idempotency-Replayed:
   true` header (`idempotency.py:153-158`, `:196-201`).
3. **Same key, a different body.** Rejected with `409`
   [`idempotency_key_reuse`](/docs/errors/idempotency_key_reuse)
   (`idempotency.py:156-157`). One key is scoped to one exact request; reuse
   it for a logically different request and it's treated as a mistake, not a
   new attempt.
4. **The key is still in flight.** A second request carrying the same key
   while the first is still being processed is rejected with `409`
   [`idempotency_key_in_progress`](/docs/errors/idempotency_key_in_progress)
   (`idempotency.py:154-155`). The middleware claims the key with an
   `in_progress` marker before it calls the handler, specifically so two
   concurrent retries can't both run — storing only on completion would let
   a second, simultaneous copy of the request through before the first one
   finished (`idempotency.py:160-164`).

```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", "dial": true}'
```

A retry of exactly this request, same key and same body, gets back the
original response with `Idempotency-Replayed: true` — no second call is
placed.

## A failed attempt is not stored

If the handler's response is a `5xx`, the middleware deletes the in-progress
marker instead of storing the outcome (`idempotency.py:172-175` — "5xx is an
unsettled outcome — release the key so a retry can retry"). A `2xx` or a
`4xx` **is** stored: both are settled outcomes — the request either
succeeded or was rejected for a reason that won't change on retry — while a
`5xx` means the platform doesn't actually know what happened, so a retry
with the same key must be free to actually retry rather than replaying an
unreliable result. The same release happens if the handler raises an
exception outright (`idempotency.py:166-170`).

## Storage

Stored records live for 24 hours (`TTL_SECONDS`, `idempotency.py:43`); an
in-progress claim lives for 15 minutes so a process that dies mid-request
doesn't wedge the key for a day (`IN_PROGRESS_TTL_SECONDS`,
`idempotency.py:47-49`). Records are kept in the shared, file-backed store
(`app.core.store`), not an in-process dict — a production deployment runs
more than one API process, and an in-memory dict would only dedupe requests
that happened to land on the same worker, which is no protection at all
against a load-balanced retry (`idempotency.py:21-26`).
