Skip to content

Idempotency & retries

Teler is a system of record for real-world side effects: phone calls cost money, ring real phones, and cannot be undone. Retries must be deliberate.

This page covers two retry surfaces:

  • Client to Teler: when you call the API and the response is in doubt.
  • Teler to your webhook endpoint: when Teler delivers an event and isn’t sure you received it.
OperationIdempotent?Why
GET readsYesNo state mutation.
POST /api/v1/voice/calls/initiateNoEvery call creates a new call record and dials a real phone.

For non-idempotent operations like calls.create():

  1. Set a tight timeout on the request: 5 to 10 seconds is plenty.
  2. On success, you’re done. Persist the returned call_id.
  3. On a non-2xx that’s clearly a client error (400, 403, 422), don’t retry. Fix the input.
  4. On a 5xx or transport failure, retry at most once, with backoff and jitter, and only if you’re confident the original request did not reach the server.
  5. On uncertainty, wait for the call.initiated webhook before deciding. If it arrives within a few seconds, the call exists and you don’t need to retry.

For idempotent GET-style operations, exponential backoff with jitter:

AttemptDelay
1immediate
21s + jitter
32s + jitter
44s + jitter
58s + jitter

Stop after 3 to 5 attempts. If the platform is degraded for longer, back off to a circuit-breaker rather than continuing to load it.

import asyncio
import random
from teler import AsyncClient
from teler.exceptions import TelerException
async def fetch_with_retry(client, op, max_attempts=4):
for attempt in range(1, max_attempts + 1):
try:
return await op()
except TelerException as e:
if e.code < 500 or attempt == max_attempts:
raise
delay = (2 ** (attempt - 1)) + random.random()
await asyncio.sleep(delay)

While calls.create() is not idempotent, the call-control mutation endpoints are. POST /api/v1/voice/calls/{call_id}/hangup, /mute, /dtmf, and /play all require an Idempotency-Key header (up to 255 characters).

Teler fingerprints the request against that key, locks in-progress duplicates, and replays the stored response for retries (TTL ~24h). This means you can safely retry these mutations on a network timeout: reusing the same Idempotency-Key returns the original result instead of applying the action twice. See Call control for the full contract.

Teler retries webhook delivery up to 5 times with backoff (immediate to ~30 min). See Webhooks retries for the full schedule.

This means your endpoint will receive the same event more than once under unusual conditions. Build for it.

Every event envelope carries a unique id (an evt_<ULID>, also echoed in the X-Teler-Event-Id header). Persist an (id, processed_at) row on first receipt; reject duplicates idempotently:

async def handle_webhook(event):
if await db.events.exists(event["id"]):
return # already processed; idempotent ack
await db.events.insert(event["id"])
await process_async(event)

Webhook handlers must return 2xx within 5 seconds. The pattern is always:

1. Verify signature
2. Check dedup table
3. Enqueue for async work
4. Return 200 OK

Doing real work inline, such as DB writes, downstream API calls, and slow integrations, pushes you past the timeout, triggers retries, and creates more duplicates.

A real-world failure mode: your handler does work + acks, but the network drops the ack. Teler retries, your dedup table catches it, but only if you inserted into the dedup table before doing the work. Otherwise the work runs twice.