Embed session
POST /api/v1/embed/session — exchange an API key, server-side, for a short-lived, search-only session token that is safe to put in a page. Plus the browser-facing /embed/* endpoints that accept it.
POST /api/v1/embed/session
Authorization: Bearer aac_live_…An aac_live_… key must never reach a browser. This endpoint exists to avoid that: it trades the key for an embed session token — scoped to one index, one action (search:read) and an expiry. The token is what goes into the page (data-aac-token); the key stays on your server.
There is no GET /embed/bootstrap and no ?key= form. A key in a query string, a data- attribute or the page HTML is exactly the leak this design removes: the endpoint reads the key from the Authorization header only and answers 401 UNAUTHORIZED otherwise.
Call it from your own backend — a route handler, an SSR pass or a build step — and hand the resulting token to the page.
Mint a session
curl -sS -X POST "$AAC_API/embed/session" \
-H "Authorization: Bearer $AAC_KEY" \
-H "Content-Type: application/json" \
-d '{ "index": "documentation", "expires_in": 900 }'| Body field | Type | Default | Meaning |
|---|---|---|---|
index | string ≤ 64 | the key's index, or the tenant's oldest index | Index slug to scope the session to. A key bound to one index cannot mint a session for a sibling |
expires_in | integer, seconds | 900 (15 minutes) | Requested lifetime, clamped to 60–3600 |
The body is optional. A non-numeric expires_in or an oversized index is 422 VALIDATION_ERROR.
{
"ok": true,
"token": "aac_sess_…",
"token_type": "embed_session",
"scope": ["search:read"],
"index": { "slug": "documentation", "name": "Documentation" },
"expires_at": "2026-10-23T11:15:02.000Z",
"expires_in": 900,
"settings": {
"placeholder": "Search the docs…",
"perPage": 8,
"queryBy": "title,content",
"facets": ["category"],
"showStats": true,
"showRecent": true,
"showVoice": false,
"showImage": false,
"showRelated": false,
"accent": "#2563eb",
"dark": false,
"sortOptions": [
{ "label": "Relevance", "value": "default" },
{ "label": "Newest first", "value": "created_at:desc" }
]
},
"ui": { "name": "AACSearch UI", "version": "1.0.0" },
"request_id": "req_4a6c8e0b2d4f6a8c0e2b4d6f"
}What the response guarantees:
- The token is search-only. It carries
search:readand nothing else, so it cannot write, delete or reindex. Compromising the page yields a credential that expires on its own and can only read what the page already shows. - The token is pinned to one index and tenant. The tenant filter is applied server-side on every request; the browser never sees a the search service key, host or collection name.
- It expires. Default 900 seconds, at most 3600. Mint a new one before expiry for long-lived pages; an expired token is
401 EMBED_SESSION_EXPIRED. - It is not cached. Responses are
cache-control: no-store; every issuance is written to the audit log (embed.session.issue). - Settings are live.
settingscomes fromSearchIndex.settings.embed, so the console's appearance panel changes the widget on the next page load — no redeploy.
Origin allowlist
If the key has allowedOrigins, a browser call from any other origin is 403 ORIGIN_NOT_ALLOWED and receives no CORS headers. An empty list means "any origin". Because the session endpoint is meant to be called from your backend, a server-to-server call has no Origin header and is unaffected.
Settings
| Key | Type | Default | Effect |
|---|---|---|---|
placeholder | string | Search the docs… | Input placeholder |
perPage | number | 8 | Hits per page |
queryBy | string | title,content | query_by sent with each search |
facets | string[] | [] | Facet fields rendered as checkbox lists (the console limits you to 6) |
showStats | boolean | true | Result count line |
showRecent | boolean | true | Recent-query chips, stored locally per index |
showVoice | boolean | false | Browser voice input via InstantSearch voiceSearch |
showImage | boolean | false | Show a hit image when the document has one |
showRelated | boolean | false | “For you” strip under results (needs recommendations enabled on the index) |
accent | string | #2563eb | Accent colour for buttons, focus ring and links |
dark | boolean | false | Dark palette |
sortOptions | array | relevance + created_at:desc | Sort dropdown; the first entry must use default |
Failures
{
"error": {
"code": "UNAUTHORIZED",
"message": "Send the API key as an Authorization header; embed sessions cannot be minted from a query parameter",
"request_id": "req_7b9d1f3a5c7e9b1d3f5a7c9e"
}
}| Status | Code | When |
|---|---|---|
| 401 | UNAUTHORIZED | Missing, malformed, unknown or revoked key; key sent as a query parameter |
| 403 | ORIGIN_NOT_ALLOWED | Browser origin outside the key's allowedOrigins |
| 404 | INDEX_NOT_FOUND | No index is available for this key, or index names an index the key cannot use |
| 422 | VALIDATION_ERROR | Invalid body |
| 429 | RATE_LIMITED | Per-address limit on /embed/* (anonymous embed traffic has a smaller budget) |
Endpoints that accept the token
Once the page holds a token, the widget talks only to these endpoints. They all authenticate with the token — in the JSON body (token), or as Authorization: Bearer aac_sess_… where noted — never with an API key. Each also answers a CORS preflight (OPTIONS) with 204.
| Endpoint | Purpose |
|---|---|
POST /api/v1/embed/config | Settings for an already-minted token: { "token": "…" } returns index, scope, expires_at and settings |
POST /api/v1/embed/search | Search proxy. Body { token, index, client_id?, personalize?, searches: [...] } with up to 5 searches; the token may also be sent as a Bearer header. page ≤ 20, per_page clamped to 50 |
POST /api/v1/embed/events | Behaviour events: type is one of view, click, conversion, add_to_cart, impression (the widget itself sends view, click and conversion), with document_id, position, query, query_id, session_id, value_cents, items |
POST /api/v1/embed/recommendations | Related and personalised items for a visitor: client_id, seed_document_id, seed_query, limit (≤ 40) |
POST /api/v1/embed/widget-session | For saved widgets in the console: a public widget id (wgt_…) plus an allowed origin yields a session, with no key anywhere on the page |
Search through the proxy goes through the same pipeline as the public API (query logs, rules, defaults, relaxation, did-you-mean), so analytics see widget traffic. Every embed search counts against the tenant's searches quota.
Token failures on these endpoints:
| Status | Code | Meaning |
|---|---|---|
| 401 | EMBED_SESSION_REQUIRED | No token was sent |
| 401 | EMBED_SESSION_EXPIRED | The token's expiry has passed — mint a new one |
| 401 | EMBED_SESSION_INVALID | Malformed or tampered token |
| 403 | FORBIDDEN | Token lacks the search:read scope |
| 403 | INDEX_MISMATCH | The request names an index other than the token's |
Browser access
Use POST /embed/session and /embed/search for the shipped widget or a custom browser interface. Your server exchanges its API key for a short-lived token; the browser receives neither infrastructure credentials nor storage names. The advanced /scoped-key endpoint is not used by the widget and is not a substitute for the platform search API.