Skip to main content
VARTA

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

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 /v1IDEMPOTENT_METHODS (idempotency.py:45), and the middleware is mounted with prefix="/v1" — not only to POST /v1/calls. 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 (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 (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).
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).