Call the API from a browser
The chat completions endpoint answers browser preflight requests, so a web page can call it directly with fetch. Whether it should depends on who is allowed to spend your credits. This recipe covers the safe default (a backend in between), and the narrower case where a browser key with an origin allowlist is acceptable.
Choose the pattern
| Pattern | Key lives in | Use when |
|---|---|---|
| Backend relay (recommended) | Your server | Anything public-facing, anything with sign-in, anything that costs real money. |
| Browser key with origin allowlist | The page’s code | Internal tools, prototypes and demos on your own domain, where everyone who can open the page may use the key. |
Anything shipped to a browser can be read by whoever loads the page. An origin allowlist stops other websites from using your key from their visitors’ browsers; it does not stop a person from copying the key and sending requests with a forged Origin header from a script. Only use a browser key you are prepared to see used outside your page, and give it a low spend limit.
Pattern 1: backend relay
The browser calls your server; your server adds the key and calls Syntic. The server is also the place to check the user is signed in and to limit how much each user can use.
// server.js (Node 18+, Express)
import express from 'express'
const app = express()
app.use(express.json())
app.post('/api/chat', async (req, res) => {
// Authenticate your user here and apply per-user limits.
const upstream = await fetch('https://app.syntic.ai/v1/chat/completions', {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.SYNTIC_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'Amara Quick',
max_tokens: 500,
messages: [
{ role: 'system', content: 'You are the help assistant for the Acme dashboard.' },
...req.body.messages.slice(-20), // cap history from the client
],
}),
})
res.status(upstream.status)
res.setHeader('Content-Type', upstream.headers.get('content-type') ?? 'application/json')
for await (const chunk of upstream.body) res.write(chunk) // stream straight through
res.end()
})
app.listen(3000)The page calls /api/chat on its own origin, so no CORS setup is needed at all.
Pattern 2: a browser key with an origin allowlist
Create a separate key for the browser
In the Console, Settings → API Keys, create a private key used only by this page. Never reuse your server key.
Restrict its origins
Add the sites that may use it, for example https://tools.example.com and, for development, http://localhost:5173. Requests from any other origin, or with no Origin header, are refused with 403 origin_not_allowed. Entry syntax and wildcard rules are on Managing API keys.
Set a spend limit
Give the organization or the key’s owner a spend limit you are comfortable losing. See Billing & credits.
Call the API with plain fetch
<script type="module">
const KEY = 'sk_live_...' // origin-restricted browser key; see the warning above
async function ask(question, onText) {
const res = await fetch('https://app.syntic.ai/v1/chat/completions', {
method: 'POST',
headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ model: 'Amara Quick', messages: [{ role: 'user', content: question }] }),
})
if (!res.ok) throw new Error(`${res.status} ${(await res.json()).error}`)
const reader = res.body.pipeThrough(new TextDecoderStream()).getReader()
let buffer = ''
for (let r = await reader.read(); !r.done; r = await reader.read()) {
buffer += r.value
const lines = buffer.split('\n')
buffer = lines.pop()
for (const line of lines) {
if (!line.startsWith('data: ') || line === 'data: [DONE]') continue
const piece = JSON.parse(line.slice(6)).choices?.[0]?.delta?.content
if (piece) onText(piece)
}
}
}
ask('Suggest a name for an internal wiki.', (t) => (document.body.textContent += t))
</script>CORS details
| Item | Value |
|---|---|
| Preflight | OPTIONS requests are answered without a key. |
| Allowed methods | POST, OPTIONS |
| Allowed request headers | Authorization, Content-Type |
| Allowed origin | Any (*). Your key’s allowlist is what restricts origins. |
| Headers readable from JavaScript | x-syntic-request-id, x-syntic-billing-source |
Two consequences:
- Send only
AuthorizationandContent-Type. Client libraries that add their own headers (telemetry or version headers, for example) fail the preflight in a browser. Use plainfetchin browser code. x-syntic-model-downgradedis not readable from browser JavaScript. If you need to detect model fallbacks, do it on a server.
Errors you may see
| Status | error | Cause |
|---|---|---|
403 | origin_not_allowed | The page’s origin is not on the key’s list (check scheme and port), or the browser sent Origin: null (for example from a file:// page). |
403 | public_key_rejected | A pk_live_ key was used. Public keys cannot call the chat endpoint. |
| CORS error in the console, no status | — | A header other than Authorization or Content-Type was sent. |
Related
- Authentication: origin allowlists
- Stream in Node.js
- Build a support assistant: a full backend-relay example.