Authentication
Bearer keys with the aac_live_ prefix, scope model, index binding, CORS and the headers every response returns.
Every endpoint under /api/v1/indexes/*, /api/v1/analytics/*, /api/v1/billing/*, /api/v1/organization/*, /api/v1/admin/*, /api/v1/jobs/*, /api/v1/api-keys/* and /api/v1/recent requires an API key. /health, /status, /capabilities, /openapi.json, /embed/* (token-authenticated) and /widget.js are public.
Sending the key
curl -sS "$AAC_API/indexes/documentation/search?q=pricing" \
-H "Authorization: Bearer aac_live_9f3c2a1bD4eF6gH8iJ0kL2mN4oP6qR8s"The header must start with Bearer and the key must match ^aac_(live|test)_[A-Za-z0-9_-]{20,}$. Anything else is rejected before a database lookup:
{
"error": {
"code": "UNAUTHORIZED",
"message": "Missing or invalid API key",
"request_id": "req_2f8a1c4e6b9d0e3f5a7c8b1d"
}
}The same 401 is returned for a well-formed key that is unknown or revoked (revokedAt set). Keys are stored as SHA-256 hashes; the plaintext exists only in the response that created it.
Scopes
A key carries a list of scopes. * grants everything. The console offers these:
| Scope | Grants |
|---|---|
search:read | /search, /suggest, /stats, GET /documents/{id}, conversations, assistant, answer, voice, image, scoped keys for reads |
documents:read | GET /documents/export, GET /snapshots, GET /snapshots/{id} |
documents:read_private | Private fields in hits, documents and export (see Private fields) |
documents:write | POST /documents, /documents/bulk, DELETE /documents…, /webhook, /docsearch/preview; with settings:write also snapshot create, delete and restore |
settings:write | POST /reindex, /scoped-key, GET /conversation-models, POST /api-keys/{keyId}/rotate, reading and cancelling reindex, file_import, snapshot and restore jobs |
analytics:read | Analytics reads (the overview also accepts search:read) |
analytics:write | POST /events (clicks and conversions) |
billing:read | /billing/usage, /billing/wallet, /billing/invoices, /billing/invoices/{id}[.pdf] |
A missing scope is a 403, not a 404 — the endpoint exists, the key just may not use it:
curl -sS -X POST "$AAC_API/indexes/documentation/documents" \
-H "Authorization: Bearer aac_live_…read_only_key…" \
-H "Content-Type: application/json" \
-d '{"documents":[{"external_id":"x","content":"y"}]}'{
"error": {
"code": "FORBIDDEN",
"message": "Missing scope: documents:write",
"request_id": "req_7b3e9d2a5c8f1a4b6d0e3c7a"
}
}Index binding
A key created for one index (indexId set) can only touch that index. Requesting a sibling index of the same organization returns:
{
"error": {
"code": "FORBIDDEN",
"message": "API key is scoped to a different index",
"request_id": "req_4d8c2a6f0b1e3d5c7a9f2b4e"
}
}An index slug that does not exist inside the key's organization returns 404 INDEX_NOT_FOUND — including slugs that exist in another tenant. Tenancy is resolved from the key, never from the path or body.
CORS
The API is key-authenticated and never uses cookies, so it echoes the caller's Origin back and allows GET, POST, PUT, DELETE, OPTIONS. Preflight is answered with 204 before authentication. Exposed headers: x-request-id, x-aac-quota-remaining, x-aac-quota-limit.
For browser keys, restrict the allowed origins on the key in the console instead of relying on CORS alone.
Response headers
| Header | Meaning |
|---|---|
x-request-id | Request identifier; also in the body as request_id |
x-ratelimit-limit | Requests allowed per window for this key (600 by default) |
x-ratelimit-remaining | Requests left in the current window |
x-ratelimit-reset | Window reset, as a Unix timestamp in seconds |
x-aac-quota-limit | Billable units included by the plan for the metric of this call |
x-aac-quota-remaining | Units left in the current period |
x-aac-index | Index the call resolved to |
x-aac-profile | Search profile used, or default |
x-aac-charged-cents | Overage charged from the wallet by this call |
Key lifecycle
- Create in the console with a name, scopes and an optional index binding. The limit is per plan: 3 keys on Free, 10 on Pro, 100 on Enterprise.
- Rotate with
POST /api-keys/{keyId}/rotate(scopesettings:write) or the console's rotate dialog. The successor gets the same scopes, allowed origins and index binding; its plaintext is returned once. The old key keeps working forgrace_days(default 7, 0–30), after which it answers401 KEY_ROTATED. Rotation never widens access: a key cannot mint a successor with scopes or allowed origins it does not hold, an index-bound key can only rotate keys of its own index, a key cannot rotate a key that would outlive it (403 KEY_LIFETIME_EXCEEDS_CALLER), and a key that is itself in a rotation grace window cannot rotate keys or issue scoped keys (403 KEY_IN_GRACE). - Organization owners are reminded once when a key will stop working within 7 days (expiry or end of a rotation grace period).
- Revoke in the console. Revocation is immediate: the next request with that key is a 401. Revoking a key that is in its rotation grace window ends the grace too.
lastUsedAtis updated on every authenticated call, which makes stale keys easy to spot in the console.
Rotating a key
curl -sS -X POST "$AAC_API/api-keys/clkey123/rotate" \
-H "Authorization: Bearer $AAC_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{ "grace_days": 7 }'{
"id": "clkey456",
"name": "Backend",
"prefix": "aac_live_9f3c",
"scopes": ["search:read", "documents:write"],
"allowed_origins": [],
"created_at": "2026-10-25T10:00:00.000Z",
"expires_at": null,
"key": "aac_live_…shown once…",
"previous_key": { "id": "clkey123", "valid_until": "2026-11-01T10:00:00.000Z" },
"request_id": "req_…"
}Status 201. Rotating a key twice is 409 KEY_ALREADY_ROTATED; rotating a revoked or expired key is 409 KEY_NOT_ACTIVE. After the grace period the old key gets:
{
"error": {
"code": "KEY_ROTATED",
"message": "This API key was rotated and its grace period has ended. Use the replacement key issued at rotation.",
"request_id": "req_…"
}
}The body never names the successor: a leaked old key must not point at the live one.
Platform administration
/api/v1/admin/* (engine snapshots, POST /admin/jobs/run) and POST /billing/invoices/generate are for the operator of the deployment, not for tenants. They need an API key and the header x-platform-admin-token equal to the server's SEARCH_PLATFORM_ADMIN_TOKEN. Without it — or when the variable is unset — the answer is 403 FORBIDDEN, and the attempt is written to the audit log.
Refreshing keys for a browser
Never ship a aac_live_… key to a browser. Use POST /indexes/{slug}/scoped-key server-side (scope settings:write) or, better, POST /embed/session: it returns a short-lived, search-only session token (default 15 minutes, at most 1 hour) that the widget uses through the /embed/* proxy, so no engine credential reaches the page either. The scoped key has the tenant filter and a hit limit baked in. See Embed session.