Error contract
One body for every error, one stable identifier to branch on.
Every API error returns the same JSON body, without exception — including validation errors, which would otherwise come back in a different, framework-imposed shape.
{
"detail": "invalid or revoked API key",
"error_code": "invalid_api_key"
}Fields that don't apply are omitted, never returned as null.
| Field | Always present | Purpose |
|---|---|---|
| error_code | yes | Stable, language-independent identifier. The only field to branch code on. |
| detail | yes | Human text, for logs and display. Its wording and language may change. |
| scope | no | rate_limit_exceeded only: "ip", "account" or "email". |
| retry_after_seconds | no | rate_limit_exceeded only. Also returned as the standard Retry-After HTTP header. |
| quota_used, quota_limit | no | quota_exceeded only. |
| field_errors | no | validation_error only: the per-field detail. |
Codes reachable with an API key
This is your real surface: a script authenticated by API key can only ever hit these seven codes. The others, listed further down, come from the dashboard or from webhooks.
| error_code | HTTP | When | What to do |
|---|---|---|---|
| missing_bearer_prefix | 401 | Authorization header present but missing the Bearer prefix. | Fix the header. Configuration error, not transient. |
| invalid_api_key | 401 | Unknown or revoked key, or deleted account. | Hard failure. Don't retry: regenerate the key and update your CI secret. |
| validation_error | 422 | Malformed request body, or an unknown field. | Read field_errors: loc gives the path to the offending field. Hard failure. |
| rate_limit_exceeded | 429 | Rate exceeded (60 requests/hour per account). | Retry after retry_after_seconds. The only case where an automatic retry makes sense. |
| quota_exceeded | 402 | Monthly simulation quota exhausted. No audit is created. | Don't insist: nothing frees up before the next cycle. |
| audit_not_found | 404 | Unknown audit id. | Check the id returned at creation. |
| report_not_found | 404 | Report requested before the audit finished. | Wait for status: "done". Not an error — you polled too early. |
Why 402 and not 429 for quota
A 429 invites a retry. An exhausted monthly quota doesn't clear by retrying — hence a distinct, machine-readable code. And no audit is created for that request: no partial score quietly handed to a pipeline using it as a blocking gate. A loud failure beats a degraded result you believe is complete.
quota_used and quota_limit come with the error. That is today the only consumption signal available from a script, since account endpoints don't accept API keys yet.
The other codes
Reachable from a dashboard session or from billing webhooks. Listed so the reference is complete: an API key does not produce them.
| error_code | HTTP | Context |
|---|---|---|
| session_required | 401 | Account endpoint called without a session token. |
| invalid_session | 401 | Unknown, revoked or expired session. |
| internal_token_required | 401 | Endpoint reserved for the application's internal proxy. |
| invalid_magic_link_token | 400 | Sign-in link invalid, expired or already used. |
| api_key_not_found | 404 | Revocation requested while no key is active. |
| session_not_found | 404 | Revoking an unknown or already-revoked session. |
| badge_token_not_found | 404 | Revoking an unknown or already-revoked badge. |
| badge_token_already_exists | 409 | An active badge already exists for this store — revoke it first. |
| already_subscribed | 409 | Opening a checkout while a subscription is already active. |
| no_billing_account | 404 | Opening the portal with no associated billing customer. |
| invalid_stripe_signature | 400 | Invalid webhook signature. |
| billing_provider_error | 502 | Billing provider temporarily unavailable. Transient. |
Message language
detail is in English by default, unlike the rest of the product — audit reports and emails default to French. That's deliberate: the audience for these messages is an integrator, not a merchant.
French is available by passing language: "fr" in the request body, but only for errors raised after that body has been validated. This is a structural limit, not an oversight: authentication and rate limiting run before the body is bound to its schema. At that point, no language signal exists yet.
| Always English | Honors language |
|---|---|
| missing_bearer_prefix, invalid_api_key, validation_error, the 404s, and rate_limit_exceeded on audit creation | quota_exceeded, and rate_limit_exceeded on the sign-in link request |
field_errors is never translated: those messages are produced by the schema validators themselves.
Handling errors properly in CI
response=$(curl -sS -w '\n%{http_code}' \
-X POST https://api.agent-readiness.com/audits \
-H "Authorization: Bearer $CABFY_API_KEY" \
-H 'Content-Type: application/json' \
-d '{"business": "https://my-store.com"}')
status=$(tail -n1 <<< "$response")
body=$(sed '$d' <<< "$response")
code=$(jq -r '.error_code // empty' <<< "$body")
case "$code" in
"") ;; # success
rate_limit_exceeded) sleep "$(jq -r .retry_after_seconds <<< "$body")" ;;
quota_exceeded) echo "Quota exhausted: $(jq -r .quota_used <<< "$body")/$(jq -r .quota_limit <<< "$body")" >&2; exit 1 ;;
*) echo "Failed ($status): $(jq -r .detail <<< "$body")" >&2; exit 1 ;;
esacThree behaviours, only three: retry while respecting the advertised delay (429), fail loudly because nothing is coming back this month (402), fail (everything else). The command line already applies exactly this logic — this page describes what it does, for those integrating the API directly.