Patterns the SDKs handle for you — and how to reproduce them when you're calling the endpoint directly.
Token refresh strategy
Tokens expire. The expected pattern, regardless of language or platform:
- Mint a token from your backend by calling /api/auth/token with the secret key. Cache the access_token alongside its expires_at.
- Reuse the cached token until you're within 30 seconds of expires_at, then mint a fresh one.
- If multiple requests need a token at the same time, deduplicate so only one mint is in flight — pending requests wait for that single result.
// Mint on your backend, cache with the returned expires_at (unix seconds),// and refresh 30s early. Dedupe concurrent mints so only one is in flight.let cached = null; // { accessToken, expiresAt }let pending = null; // a mint already in flightasync function getAccessToken() {const now = Math.floor(Date.now() / 1000);if (cached && now < cached.expiresAt - 30) return cached.accessToken;if (pending) return pending; // wait for the in-flight mintpending = fetch("https://api.ai-autocomplete.com/api/auth/token", {method: "POST",headers: {Authorization: "Bearer " + process.env.MAGICX_SECRET_KEY,"Content-Type": "application/json",},body: JSON.stringify({ product_id: process.env.MAGICX_PRODUCT_ID }),}).then((r) => r.json()).then((token) => {cached = { accessToken: token.access_token, expiresAt: token.expires_at };return token.access_token;}).finally(() => {pending = null;});return pending;}
401 retry
If a token is rejected mid-flight (clock skew, server-side revocation), force-refresh once and retry the original request. This mirrors what the SDKs do internally.
// Force-refresh once on a 401, then retry the original request. Never loop.async function suggest(body) {let res = await callSuggest(await getAccessToken(), body);if (res.status === 401) {cached = null; // drop the rejected tokenres = await callSuggest(await getAccessToken(), body); // one retry only}if (res.status === 401) {throw new Error("Token rejected after refresh"); // surface it, don't retry again}return res.json();}function callSuggest(token, body) {return fetch("https://api.ai-autocomplete.com/api/suggest", {method: "POST",headers: {Authorization: "Bearer " + token,"Content-Type": "application/json",},body: JSON.stringify(body),});}
Managing requests during fast typing
Fast keystrokes can fire requests faster than the API can respond. The SDKs handle three concerns in concert; HTTP-direct callers should too.
- Debounce keystrokes — Wait briefly after the last keystroke before sending — long enough to collapse rapid bursts, short enough that the user doesn't perceive the delay. Tune to your UX; 100–200ms works well for most autocomplete interfaces — lean toward 100ms when you want a snappier, more responsive feel, and toward 200ms to trim request volume during fast typing.
- Cancel in-flight — When a newer request is about to fire, abort any in-flight request first. Use whichever request-cancellation mechanism your platform provides, or a request-tag scheme that lets you ignore late completions.
- Discard stale responses — Track the request_id (or request_at) you sent on the most recent call. When a response arrives, compare its meta.request_id — if it doesn't match the latest, drop the response without applying it. Protects against network reordering even when cancellation works correctly.
let debounceTimer = null;let inFlight = null; // AbortController for the current requestlet latestRequestId = null;// Debounce: wait 150ms after the last keystroke before firing.function onKeystroke(rawQuery, completedParams) {clearTimeout(debounceTimer);debounceTimer = setTimeout(() => send(rawQuery, completedParams), 150);}async function send(rawQuery, completedParams) {inFlight?.abort(); // cancel any in-flight requestinFlight = new AbortController();const requestId = crypto.randomUUID();latestRequestId = requestId;const res = await fetch("https://api.ai-autocomplete.com/api/suggest", {method: "POST",headers: {Authorization: "Bearer " + (await getAccessToken()),"Content-Type": "application/json",},body: JSON.stringify({data: { raw_query: rawQuery, completed_params: completedParams },meta: { request_id: requestId, request_at: new Date().toISOString() },}),signal: inFlight.signal,});const json = await res.json();// Discard stale responses that arrive out of order.if (json.meta.request_id !== latestRequestId) return;render(json.data.suggestions);}
Relaying on-screen suggestions
When the user answers a suggestion by typing instead of tapping, the request carries the words but nothing about what was on screen while they were typed. data.recently_suggested closes that gap — it relays the suggestions the user could see while typing the current unresolved trailing text, so those words can be recognized as an answer to one of them. Text that already matches a parameter or a known option is unaffected; the relay only changes the outcome for words that match nothing else, which is what lets "no special instructions" count as an answer to the special instructions suggestion instead of that field being suggested again. The field is optional and omitting it changes nothing else about the response.
{"data": {"raw_query": "Create a {{TYPE_1}} no special instructions","completed_params": [{ "placeholder": "{{TYPE_1}}", "type": "type", "text": "email" }],"recently_suggested": [{ "type": "special_instructions", "text": "special instructions" }]},"meta": {"request_id": "5a0d1c1e-1b3f-4a9e-8a4f-001a1c3b7d20","request_at": "2026-05-26T18:30:00Z","session_id": "9e5b7c0e-2a1b-4f8e-9c2d-3e4f5a6b7c8d"}}
- When to start — On the first keystroke of text that isn't already covered by a completed param — the point where the user starts answering in prose. Snapshot the suggestions on screen at that moment and keep sending them even after later responses replace what's displayed, since the words being typed were prompted by the snapshot.
- What to send — The snapshot plus whatever suggestions are on screen now, snapshot entries first, deduplicated by type. Send each suggestion's own type and text — not the words the user typed. Skip "placeholder" suggestions; they're prompt hints, not answerable fields.
- When to stop — As soon as the trailing text resolves — it becomes a completed param, the user deletes it back to where they started typing, or they pick an option instead. Omit the field entirely from then on rather than relaying a hint the user has moved past.
Reporting dismissed suggestions
Users dismiss suggestions as well as answer them — the → key in the SDKs, whatever decline affordance you build. Report a dismissal and that parameter counts as handled, so it stops coming back in the next response's suggestions; leave it out and the user gets asked the same thing again. The SDKs do this on every request and on the submitted result, so a hand-rolled client should match them if you want the same behavior.
{"data": {"raw_query": "Create a {{TYPE_1}}","completed_params": [{ "placeholder": "{{TYPE_1}}", "type": "type", "text": "email" },{ "placeholder": "", "type": "goal", "text": "skipped" }]},"meta": {"request_id": "5a0d1c1e-1b3f-4a9e-8a4f-001a1c3b7d20","request_at": "2026-05-26T18:30:00Z","session_id": "9e5b7c0e-2a1b-4f8e-9c2d-3e4f5a6b7c8d"}}
- What to send — Append an entry to completed_params carrying the suggestion's type, the literal text "skipped", and an empty placeholder — nothing was substituted into raw_query, so there is no token to name. Put these after your real params, which do have positions in the query.
- When to drop it — Leave the dismissal out once the user fills that parameter after all — an entry that is both answered and declined contradicts itself. Re-derive the list on each request rather than discarding the dismissal for good: if the user deletes that value again, it should come back.
Personalizing suggestions with additional_context
Your app usually knows more about the request than the query alone — who the user is, what they have ordered or built before, what is open on screen. Send that as data.additional_context, a free-form JSON object, and the server tailors the parameters and option values it suggests to it: if the context names a value for a field it is about to suggest, that value is listed first. The server never interprets the object itself — only you know what is worth conditioning on — and it is not remembered between requests, so include it on every request you want personalized.
{"data": {"raw_query": "I want a","additional_context": {"tier": "gold","home_store": "Gangnam Station","allergies": ["peanut"],"preferences": { "milk": "oat milk", "size": "grande" },"favorites": ["iced americano", "cold brew"],"recent_orders": [{ "drink": "iced caramel macchiato", "size": "grande", "at": "2026-08-18" }]}},"meta": {"request_id": "0c6a3b8e-5d2f-4e1a-9b7c-2d4e6f8a0b1c","request_at": "2026-08-19T09:15:00Z","session_id": "9e5b7c0e-2a1b-4f8e-9c2d-3e4f5a6b7c8d"}}
- What to send — Keys that match the product's catalog field names (e.g. size, milk) are honoured most reliably; free-form prose is weaker. Nest as you like — the shape is yours. Omit the field entirely when there is nothing to add; an empty object is treated the same as absent.
- Size cap — 2000 bytes, measured on the compacted JSON (whitespace is free), roughly a full profile plus a couple dozen history entries. Non-ASCII text costs 3 bytes per character. Anything past the cap is truncated with a trailing "…" rather than rejected, so lead with the most useful keys.
- It is data, not instructions — The server treats the object strictly as data. Instructions, role changes, or prompt-like text inside it are ignored, and its values are never written into the query on the user's behalf — they only steer which options are offered.
- Personal data — Send only what you want the model to condition on. The same masking rule as completed_params applies: leave out names, emails, and other identifiers that add nothing to the suggestions.
PII masking
By default, each completed_params[].text carries the literal value the user picked (e.g. "alex@example.com"). To keep that out of server logs — for compliance or privacy — omit the text field on entries you want to mask. The request still validates with the placeholder and type intact, but the server no longer sees the specific value, which can reduce suggestion quality. Use selectively where the privacy benefit outweighs the ranking cost.
{"data": {"raw_query": "Draft an email to {{CONTACT_1}}","completed_params": [{ "placeholder": "{{CONTACT_1}}", "type": "contact" }]},"meta": {"request_id": "5a0d1c1e-1b3f-4a9e-8a4f-001a1c3b7d20","request_at": "2026-05-26T18:30:00Z","session_id": "9e5b7c0e-2a1b-4f8e-9c2d-3e4f5a6b7c8d"}}
Session lifecycle
A session_id groups one user's autocomplete activity for analytics and event recording. Reuse the same value across keystrokes within one autocomplete interaction; mint a new one when:
- The user commits — presses Enter, taps a Submit button, or any equivalent finalize action in your UI.
- The user navigates away, closes the surface, or otherwise abandons the current input.
- A long idle period elapses — there's no server-enforced TTL, so pick a window that matches your UX (e.g. 10–15 minutes since the last keystroke).
// Reuse one session_id across a user's keystrokes; rotate when they finalize.let sessionId = crypto.randomUUID();function metaForKeystroke() {return {request_id: crypto.randomUUID(), // fresh per requestrequest_at: new Date().toISOString(),session_id: sessionId, // stable across the interaction};}function onSubmit() {// User committed (Enter / Submit) — start a new session for the next input.sessionId = crypto.randomUUID();}
Choosing an auth mode
Pick the credential that matches where the call originates:
- Public key — drop straight into a client application. Scoped and rate-limited; safe to ship in client bundles.
- Secret key + access token — your server holds the secret, mints short-lived tokens, and the client only ever sees the token. Use for production: token leaks have a small blast radius.
- Server-to-server — call /api/suggest directly with the secret key when there's no end-user client in the loop (back-office, batch jobs).