Errors and limits
The error envelope, every code the API returns with its status, rate-limit semantics, plan quotas and what happens when the wallet runs dry.
The error envelope
Every failure — validation, auth, quota, engine — uses the same shape:
{
"error": {
"code": "QUOTA_EXCEEDED",
"message": "Monthly searches quota exhausted (500000/500000 for 2026-10)",
"request_id": "req_3c5e7a9b1d3f5a7c9e1b3d5f",
"details": {
"metric": "searches",
"limit": 500000,
"used": 500000,
"period": "2026-10",
"resetsAt": "2026-11-01T00:00:00.000Z"
}
}
}code is a stable identifier you can branch on; message is for humans and may change; request_id also comes back in the x-request-id header. details is present only when there is something useful to say.
curl -sS -i "$AAC_API/indexes/documentation/search?q=pricing&num_typos=9" \
-H "Authorization: Bearer $AAC_KEY"{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request payload is invalid",
"request_id": "req_7d9f1b3c5e7a9b1d3f5c7a9b",
"details": {
"issues": [
{
"code": "too_big",
"path": ["num_typos"],
"message": "Number must be less than or equal to 3"
}
]
}
}
}Codes by status
400
| Code | Raised by |
|---|---|
INVALID_QUERY | Bad filter, sort, field list or vector_query syntax; filter_by mentioning organization_id; missing filter_by on bulk delete; index without an auto-embedding field when semantic=true; more than 20 searches in multi_search |
FIELD_NOT_IN_SCHEMA | filter_by, sort_by, facet_by, query_by or group_by names a field the index schema does not declare (documents may still carry it). details.field is the field from your request, details.param the parameter. Add the field in Schema and reindex |
INVALID_JSON | Webhook body that is not JSON |
PRIVATE_FIELD | query_by, filter_by, sort_by, facet_by, include_fields or group_by names a field the index marks private and the key lacks documents:read_private (also /embed/search). details.fields, details.params. See Private fields |
INVALID_REQUEST | POST /billing/invoices/generate with a period that is not YYYY-MM or lies in the future |
PRIVATE_FIELDS_PRESENT | POST /indexes/{slug}/scoped-key for an index with private fields, by a key without documents:read_private. details.fields |
401
| Code | Raised by |
|---|---|
UNAUTHORIZED | Missing/malformed Authorization header, unknown or revoked key, missing webhook signature headers |
KEY_ROTATED | The key was rotated and its grace period has ended. Deploy the successor issued at rotation; the error never names it |
EMBED_SESSION_REQUIRED / EMBED_SESSION_EXPIRED / EMBED_SESSION_INVALID | /embed/search, /embed/events, /embed/config, /embed/recommendations called with no, an expired, or an invalid embed session token. Mint a new one with POST /embed/session |
EMBED_SESSION_REVOKED | The API key or saved widget that minted the embed session was revoked, expired or disabled; the token stops working within about 30 seconds of the change |
STALE_SIGNATURE | Webhook timestamp outside the 300-second window |
INVALID_SIGNATURE | Webhook HMAC mismatch |
402
| Code | Raised by |
|---|---|
PAYMENT_REQUIRED | The plan does not include the feature (details.feature, details.upgradeTo), or the wallet balance cannot cover the next overage block (details.blockCents). Both mean “pay or change plan”, not “retry later” |
PLAN_LIMIT_REACHED | Creating another index or API key, or importing documents, would exceed a plan cap (indexes, keys, or documents) |
FEATURE_NOT_AVAILABLE | The organization is suspended after a failed subscription payment (dunning): it runs on Free and the paid feature is refused with details.reason = "past_due" and details.upgradeTo = null — pay the overdue invoice, do not upgrade. See Billing |
403
| Code | Raised by |
|---|---|
FORBIDDEN | Key lacks the required scope or is bound to a different index; embed session used for an operation other than search:read |
FORBIDDEN (admin) | /admin/* or POST /billing/invoices/generate without a valid x-platform-admin-token |
ORIGIN_NOT_ALLOWED | A request whose Origin header is not in the key's allowedOrigins. Enforced on the server for every key-authenticated route (and POST /embed/session), not only through CORS headers; requests without an Origin header (server-to-server) are not affected |
INDEX_MISMATCH | Embed session scoped to a different index than the one requested |
KEY_IN_GRACE | The calling key was rotated and is in its grace window: it cannot rotate keys or issue scoped keys. Use its successor |
KEY_LIFETIME_EXCEEDS_CALLER | POST /api-keys/{keyId}/rotate on a key that would outlive the calling key |
FORBIDDEN (tenant-wide) | GET /organization/export, DELETE /organization/data or /webhooks/* with a key bound to an index |
An acl claim in a search request is ignored — not rejected — when the key has an allowedOrigins list or the request carries an Origin header: a browser-exposed credential cannot choose whose documents it sees. Such searches see only documents without an ACL.
404
| Code | Raised by |
|---|---|
INDEX_NOT_FOUND | Unknown index slug in the key's organization; embed session with no resolvable index. Engine-level “collection not found” is reported under this public code |
DOCUMENT_NOT_FOUND | GET/DELETE on an unknown external_id |
PROFILE_NOT_FOUND | ?profile= naming a profile the index does not have |
CONVERSATION_NOT_FOUND | Assistant conversation id that does not belong to the index |
NOT_FOUND | /metrics without a valid x-metrics-token |
JOB_NOT_FOUND | GET/DELETE /jobs/{jobId} with an id of another tenant or (index-bound key) another index |
SNAPSHOT_NOT_FOUND | Unknown snapshot id, or one of another index or tenant |
409
| Code | Raised by |
|---|---|
INDEX_ALREADY_EXISTS | Engine-level duplicate when creating a collection (reindex and console paths); the physical collection name is never exposed |
CONFLICT | Conflicting write detected by the service layer |
IDEMPOTENCY_IN_FLIGHT | A document import with the same Idempotency-Key is still running — retry after it finishes |
JOB_CONFLICT | A reindex or restore was requested (also with ?sync=true) while a reindex or restore is queued or running, or a whole-index re-embed while one is active. details.job_id names it |
JOB_NOT_CANCELLABLE | DELETE /jobs/{jobId} on a job that finished or already reached the alias swap |
SNAPSHOT_LIMIT_REACHED | The plan's snapshots-per-index limit is reached; retry with ?prune=true |
KEY_ALREADY_ROTATED | POST /api-keys/{keyId}/rotate on a key that was already rotated |
KEY_NOT_ACTIVE | Rotating a revoked or expired key |
413
| Code | Raised by |
|---|---|
AUDIO_TOO_LARGE | Voice clip over the accepted size |
422
| Code | Raised by |
|---|---|
VALIDATION_ERROR | Zod schema rejection — includes details.issues |
IDEMPOTENCY_KEY_REUSED | The Idempotency-Key was already used for a different request (method, path or body differ) |
INVALID_DOCUMENT | Missing content, bad field value, more than 500 documents per import or 1000 per webhook |
INVALID_AUDIO | Audio payload that cannot be decoded |
EMPTY_TRANSCRIPTION | Voice request where no speech was detected |
CLIENT_ID_REQUIRED | POST /recent without x-aac-client-id |
QUERY_TOO_SHORT | POST /recent with a query under 2 characters |
INVALID_SNAPSHOT_NAME | DELETE /admin/snapshots with an unsafe filename |
429
| Code | Raised by |
|---|---|
RATE_LIMITED | Per-key request rate exceeded |
QUOTA_EXCEEDED | Plan allowance for the metric exhausted |
5xx
| Code | Status | Raised by |
|---|---|---|
INTERNAL_ERROR | 500 | Unhandled exception — quote the request_id |
FEATURE_UNSUPPORTED | 501 | Feature needs a newer search engine than the connected node (FEATURE_UNAVAILABLE for features not enabled on this deployment yet) |
SEARCH_UNAVAILABLE | 502 | Engine unreachable or returned an unmapped failure |
AI_UNAVAILABLE | 502 | Model provider failure |
AI_EMPTY_RESULT | 502 | Model returned nothing usable |
SNAPSHOT_FAILED | 502 | Engine snapshot request failed |
SNAPSHOT_NOT_CONFIGURED | 503 | POST /admin/snapshot without SEARCH_SNAPSHOT_DIR on the deployment |
SEARCH_UNAVAILABLE | 503 | Deployment has no search-engine credentials |
AI_NOT_CONFIGURED | 503 | OPENAI_API_KEY missing for answers, image or transcription |
NL_MODEL_NOT_CONFIGURED | 503 | The model required by this feature has not been configured |
VOICE_MODEL_NOT_CONFIGURED | 503 | The model required by this feature has not been configured |
CONVERSATION_MODEL_NOT_CONFIGURED | 503 | The model required by this feature has not been configured |
WEBHOOK_NOT_CONFIGURED | 503 | No webhook secret on index or server |
SEARCH_TIMEOUT | 504 | Engine did not answer in time |
Engine and provider errors are mapped to this public contract before they leave the API, and internal names never appear in a response: an engine 404 becomes INDEX_NOT_FOUND, 409 becomes INDEX_ALREADY_EXISTS, 400/422 become INVALID_QUERY (an unknown field in filter_by/sort_by/facet_by/query_by/group_by becomes FIELD_NOT_IN_SCHEMA), timeouts become SEARCH_TIMEOUT, and everything else (including a missing engine configuration) becomes SEARCH_UNAVAILABLE. Other aliases: AI_PROVIDER_ERROR → AI_UNAVAILABLE, ENGINE_FEATURE_UNSUPPORTED → FEATURE_UNSUPPORTED, INDEX_HAS_NO_EMBEDDING_FIELD → INDEX_NOT_VECTOR_ENABLED. The raw engine message is logged server-side and correlated by request_id; quote that id to support.
Rate limits
Requests are limited per API key in a sliding window:
| Setting | Default | Env |
|---|---|---|
| Window | 60 seconds | SEARCH_RATE_WINDOW_SECONDS |
| Requests per window | 600 | SEARCH_RATE_MAX_REQUESTS |
Every authenticated response carries x-ratelimit-limit, x-ratelimit-remaining and x-ratelimit-reset (Unix seconds), including successful ones, so a client can pace itself. Exceeding the limit is a 429 with a reset time:
curl -sS -i "$AAC_API/indexes/documentation/search?q=pricing" \
-H "Authorization: Bearer $AAC_KEY"{
"error": {
"code": "RATE_LIMITED",
"message": "Rate limit exceeded",
"request_id": "req_9b1d3f5a7c9e1b3d5f7a9c1e",
"details": { "limit": 600, "reset_at": "2026-10-23T10:42:00.000Z" }
}
}The limiter is in-process by default and shared through Redis when REDIS_URL is set. Behind several instances without Redis, each instance enforces its own 600 — plan for that when sizing.
RATE_LIMITED and QUOTA_EXCEEDED are both 429 but mean different things: the first clears in seconds, the second only at the start of the next billing period or when the plan is upgraded. Read error.code, never the status alone.
Plan quotas
Metered metrics are searches, documents, ai_tokens and reindex. Allowances per billing period, from the billing catalog:
| Free | Pro ($29/mo) | Enterprise | |
|---|---|---|---|
| Searches | 10,000 | 500,000 | 5,000,000 |
| Documents | 1,000 | 50,000 | 1,000,000 |
| AI tokens | 10,000 | 100,000 | 2,000,000 |
| Reindex runs | 2 | 50 | 500 |
| Indexes | 5 | 10 | 100 |
| API keys | 3 | 10 | 100 |
| Connectors | 5 | 10 | 100 |
| Max results per page | 50 | 250 | 500 |
| Query-log retention | 7 days | 90 days | 365 days |
Overage prices per block:
| Metric | Block | Free | Pro | Enterprise |
|---|---|---|---|---|
| Searches | 1,000 | — | 50¢ | 25¢ |
| Documents | 1,000 | — | 20¢ | 10¢ |
| AI tokens | 1,000 | — | 2¢ | 1¢ |
| Reindex | 1 run | — | 5¢ | 2¢ |
Free has no overage prices at all: reaching a limit returns 429 QUOTA_EXCEEDED and nothing is charged. Paid plans meter overage against the prepaid wallet.
Features gated by plan: Free includes keyword search, synonyms, stopwords, aiAnswer (RAG, within the 10,000 AI-token allowance) and scheduledSync, but not vectorSearch, hybridSearch, curations, grouping, geoSearch, multiSearch, reindex, exportDocuments, customSort or queryAnalytics. Using a gated feature returns 402 PAYMENT_REQUIRED from the metering layer; a plan that lacks the feature fails the request rather than silently degrading it. Missing API key scope still returns 403 FORBIDDEN.
Wallet and overage
- Usage is counted per period (
YYYY-MM), resetting at00:00 UTCon the first of the month. - Up to the included allowance, a call costs nothing.
- Beyond it, each block is debited from the wallet:
searchesin 1,000-unit blocks,documents/ai_tokenslikewise,reindexper run. - With a zero balance the call fails with
402 PAYMENT_REQUIREDand top-up instructions in the message; a plan feature that needs payment fails the same way. - Top-ups are prepaid credit packs — $10, $50, $200 — bought through the payment provider and reconciled into the ledger.
Read the state before it bites:
curl -sS "$AAC_API/billing/wallet" -H "Authorization: Bearer $AAC_KEY"{
"wallet": { "balanceCents": 4200, "currency": "USD" },
"plan": "pro",
"period": "2026-10",
"resets_at": "2026-11-01T00:00:00.000Z",
"transactions": [
{
"id": "txn_01HZYA",
"type": "credit",
"amount_cents": 5000,
"balance_after_cents": 5000,
"description": "Top-up credits_50",
"metric": null,
"created_at": "2026-10-02T09:12:00.000Z"
},
{
"id": "txn_01HZYB",
"type": "debit",
"amount_cents": -800,
"balance_after_cents": 4200,
"description": "search overage: 16 blocks",
"metric": "searches",
"created_at": "2026-10-21T18:03:11.000Z"
}
],
"request_id": "req_4f6a8c0e2b4d6f8a0c2e4b6d"
}curl -sS "$AAC_API/billing/usage" -H "Authorization: Bearer $AAC_KEY"{
"usage": {
"plan": "pro",
"planName": "Pro",
"period": "2026-10",
"resetsAt": "2026-11-01T00:00:00.000Z",
"searches": { "used": 500000, "limit": 500000 },
"documents": { "used": 51204, "limit": 50000 },
"aiTokens": { "used": 24800, "limit": 100000 },
"indexes": { "used": 3, "limit": 10 },
"apiKeys": { "used": 2, "limit": 10 },
"connectors": { "used": 1, "limit": 10 },
"wallet": { "balanceCents": 4200, "currency": "USD" }
},
"request_id": "req_2b4d6f8a0c2e4b6d8f0a2c4e"
}documents is counted from the document mirror, not from a counter, so it reflects what is actually in the index. wallet is null when the tenant has never had a credit.
Operational guidance
- Back off on 429 only.
Retry-Afteris not sent; usedetails.reset_atorx-ratelimit-reset. - Do not retry 4xx other than 429. A 403 will not become a 200 by repeating it; a 422 payload needs fixing.
- Retry 5xx with jitter. The SDKs retry twice by default with exponential backoff.
- Watch
x-aac-quota-remaining. Failing at the quota boundary is avoidable if you alert at 10% remaining. - Alert on zero-result rate, not on 200s. A search that returns nothing is a success by HTTP and a failure by product.