Skip to main content
Version: v1

Errors & retries

Every error shares one shape, so you can branch on error.code rather than parsing prose.

{
"error": {
"code": "<machine-readable>",
"message": "<human-readable>",
"details": {}
}
}

Status table

HTTPcodeWhen
400invalid_or_expiredPair code doesn't exist, expired, or already consumed.
400missing_uploadfinalize with no prior upload (or the file is absent).
400idempotency_key_requiredFull-snapshot finalize without an Idempotency-Key.
401missing_hmac_headersA required HMAC header is absent.
401bad_tenant_idX-VoiceBot-Tenant-Id is not a valid UUID.
401bad_timestampX-VoiceBot-Timestamp is not an integer epoch.
401stale_timestampTimestamp drift > 300s.
401nonce_replayNonce already seen within its 600s window.
401tenant_not_foundNo connected provider connection for this tenant.
401missing_secretShared secret is missing or undecryptable.
401bad_signatureHMAC mismatch.
404not_foundsync_id doesn't exist or belongs to another tenant.
409conflict_full_sync_in_progressA snapshot is already uploading / dispatching.
413(see note)Upload exceeds max_size_bytes (200 MB). Returns the FastAPI {"detail": …} body, not the ingest envelope.
426protocol_upgrade_requiredX-VoiceBot-Protocol-Version2. Flat shape — see note below.
429rate_limitedOver a rate limit. Retry-After reports the window.
500internalUnexpected backend error (no internal detail returned).
426 breaks the error envelope

Every row above uses the { "error": { "code", "message", "details" } } shape except 426. The protocol-version mismatch is rejected by a separate handler, before the body is read, and returns a flat object where error is the string code itself:

{
"error": "protocol_upgrade_required",
"expected_version": 2,
"received_version": 1,
"message": "Expected sync protocol version 2; received 1"
}

A producer branching on error.code gets undefined on a 426. Branch on the HTTP status for this one case (or handle error being a string). The 413 upload-too-large is also not the ingest envelope — it's the framework's {"detail": …} body.

The retry rule

This is the single most important operational detail of the protocol:

Signed requests are not blindly retried

The backend records the nonce before running the handler, so a 429 or 5xx has already consumed that nonce. Re-sending the identical signed request is rejected as nonce_replay. To retry a signed request you must build a fresh one — new timestamp, new nonce, new signature.

What is and isn't safe:

RequestRetry on connection errorRetry on 429 / 5xx
POST /pair (unsigned)YesYes, honour Retry-After
PUT /upload/{id} (unsigned URL)Yes (within expires_at)Yes, honour Retry-After
POST /init (signed)Fresh request onlyFresh request only
POST /finalize (signed)Use Idempotency-KeyUse Idempotency-Key
POST /events (signed)Use batch_id / Idempotency-KeyUse batch_id / Idempotency-Key
GET /status (signed)Fresh request onlyFresh request only

The unsigned legs (the pair handshake and the snapshot upload, which has no HMAC) are the only ones safe to blind-retry on 429/5xx with backoff.

Idempotency for exactly-once

For the two mutating signed endpoints, idempotency keys give you exactly-once even across a lost connection:

  • Idempotency-Key (UUID) on /finalize (required for full snapshots) and optionally on /events. The full response is cached for 24h and a repeat with the same key returns it without re-running.
  • batch_id (UUID) on /events is deduplicated server-side; a replay returns idempotent_replay: true with processed: 0.

Use a stable key per logical operation (e.g. derive it from the sync_id or batch_id) so a retry reuses the same key rather than minting a new one.

Per-event errors

/events is partial-success: the batch returns 200 with a per-index errors array for the operations that failed. Failed ops are dead-lettered for review and are not applied — fix the payload and re-send those events; don't replay the whole batch under the same batch_id (it would be treated as a dedup replay).

Back to the reference