Errors
Every code Call Lens can refuse with, and what a client should do about each.
Every refusal in the standard envelope carries a stable error code. Switch on that code, never
on message — the prose is written for humans and will change. (Field-validation failures are
the exception: they use a different shape entirely, with no error key at all — see
Two different 422 shapes below.)
{ "success": false, "message": "…", "error": "ingest_daily_audio_allowance_exhausted", "data": { … } }The refusal codes
The status depends on which endpoint you called, because the two doors were designed separately and genuinely differ.
error | POST /calls | POST /uploads | What to do |
|---|---|---|---|
ingest_daily_audio_allowance_exhausted | 429 | 402 | Retry after midnight |
ingest_monthly_audio_allowance_exhausted | 429 | 402 | Wait for the month to turn — midnight will not help |
ai_budget_exhausted | 429 | 402 | Stop. Only an operator can clear it |
ingest_allowance_unknown | 429 | 402 | One more attempt, then escalate |
ingest_subscription_lapsed | 402 | 402 | Stop. Needs a renewal, not a retry |
insufficient_scope | 403 | 403 | Fix the key's scopes. Never retry |
upload_ticket_invalid | 422 | — | The ticket is unreadable, expired, or was not issued to you. Request a new upload |
upload_not_received | 422 | — | No file has arrived at that upload yet. Complete the PUT and submit again — the ticket is still good |
upload_too_large | 422 | — | The object exceeds 200 MB. Stop. It has been discarded and the same file will never be accepted |
upload_not_audio | 422 | — | The object does not begin like a recognised audio container. Stop. It has been discarded |
upload_unsupported | — | 503 | This deployment's storage cannot presign uploads. An operator problem; retry after a deploy |
webhook_url_refused | — | — | Fix the URL. Never retry (webhook routes) |
webhook_url_taken | — | — | This URL is already registered for your organization. Not retryable; reuse the existing endpoint or pick a different URL |
webhook_endpoint_not_found | — | — | No endpoint with that id. Not retryable |
A dash means that door never answers with that code — not that the status is unknown. The three
webhook_* codes belong to the /webhooks routes, upload_unsupported to the presign door alone,
and the four upload_* refusals to POST /calls, which is where a ticket is opened and the object
it names is measured. See uploading audio for the full upload
sequence.
The upload_* family is not the same branch as an allowance refusal, and conflating them is
expensive. A 402 says the recording was fine and your account was not, so retrying after the
window resets works. upload_too_large and upload_not_audio say the file itself will never be
accepted — the object has already been discarded — so a client that retries them burns its upload
budget forever. upload_not_received is the one exception in that family: the ticket is still
good, so completing the transfer and submitting again succeeds.
The allowance figures come back in audio minutes, and the two doors shape them differently.
POST /calls returns them flat, and only the two calendar-bound codes carry a full set:
error | What data contains |
|---|---|
ingest_daily_audio_allowance_exhausted | limit_audio_minutes, used_audio_minutes, remaining_audio_minutes, resets_at |
ingest_monthly_audio_allowance_exhausted | the same four |
ai_budget_exhausted | used_audio_minutes only, always 0 |
ingest_allowance_unknown | used_audio_minutes only, always 0 |
ingest_subscription_lapsed | used_audio_minutes only, always 0 |
The last three carry no figures because there are none to report — a spend ceiling, an unreadable
organization and a lapsed subscription are not measured in audio minutes. A key whose value would be
null is dropped from the response rather than sent as null, so test for a key's presence, not for
a null value.
POST /uploads nests them instead — data.allowance.limit, .used, .remaining, .resets_at —
and keeps all four keys, using null where /calls would omit.
On POST /calls, a missing key means there is nothing to report, not that the value is null.
Only ingest_daily_audio_allowance_exhausted and ingest_monthly_audio_allowance_exhausted are
calendar-bound; the other three clear when an operator acts, not when a clock turns, so resets_at
— and every other figure but used_audio_minutes — is simply absent. Check for a key's presence,
not for a null value. (POST /uploads differs here too: it keeps every key, resets_at included,
and sets it to null.)
ingest_subscription_lapsed is not a suspension. Reads, exports and sign-in keep working — only new
calls are refused. It clears when the subscription is renewed in the platform console.
A 429 is not always a rate limit
This is the one that breaks integrations. On POST /calls, two different things return 429, and
they need opposite handling:
Throttled. The bare framework body, plus a Retry-After header:
{ "message": "Too Many Attempts." }Back off and retry. This is what rate limits describes.
Refused on allowance. The full envelope, and no Retry-After at all:
{
"success": false,
"message": "…",
"error": "ai_budget_exhausted",
"data": { "used_audio_minutes": 0 }
}Note how little is there: no limit_audio_minutes, no remaining_audio_minutes, no resets_at —
a budget refusal has no ceiling and no window to report, so those keys are omitted rather than sent
as null, and used_audio_minutes reports a bare 0 rather than a real count. A daily or monthly
refusal carries the full four-key set instead. Since a budget refusal's data carries essentially
nothing to branch on, the discriminator has to be the error key itself, not anything inside data.
The discriminator is the presence of an error key. A client that assumes every 429 means
"throttled, back off exponentially" will retry ai_budget_exhausted forever — nothing about time
clears it. Check for error before you schedule a retry.
Two different 422 shapes
POST /webhooks can fail validation two ways, and they do not share a body.
A field failing validation returns the framework's own shape — note there is no success, no
error, no data:
{ "message": "The url field is required.", "errors": { "url": ["The url field is required."] } }A value that is well-formed but refused by application logic returns the standard envelope, with
a hint restating the rule that was violated:
{
"success": false,
"message": "…",
"error": "webhook_url_refused",
"data": { "hint": "Endpoints must be https and must resolve to a public address." }
}Branch on the presence of errors versus error.
Authentication and authorisation
401 means the key is missing, malformed, unknown, or revoked. All four return the same message on
purpose, so responses cannot be used to discover which keys exist.
403 means the key is valid but lacks a scope; data.required_scope names the one needed. Scopes
are fixed when the key is minted, so this is never worth retrying.