Calling from the browser
Calling Oberik directly from your frontend is a first-class path: the end-user token already bounds what the caller can do, and it keeps streaming traffic off your servers.
Mint on your server, never in the browser
Your project key signs tokens, so it stays server-side. Your frontend receives only a short-lived JWT.
app.post("/api/ai-token", requireLogin, async (req, res) => {
const r = await fetch(
`https://oberik.com/api/projects/${PROJECT_ID}/token`,
{
method: "POST",
headers: { "content-type": "application/json", "X-API-Key": PROJECT_KEY },
body: JSON.stringify({
subject: `${req.user.orgId}:${req.user.id}`,
scope: `${req.user.orgId}:${req.user.id}`,
capabilities: ["chat", "documents:read"],
expiresIn: 900,
}),
},
);
res.json(await r.json()); // { access_token, expires_in, capabilities, … }
});
Short TTLs are the point: a leaked 15-minute token scoped to one user is a small problem.
Let the SDK handle the refresh — it calls getToken({ expired: true }) when the server
rejects a token and replays the request, so a long-lived tab never breaks:
let cached: string | undefined;
const ai = createClient({
getToken: async ({ expired }) => {
if (!expired && cached) return cached;
cached = (await (await fetch("/api/ai-token", { method: "POST" })).json()).access_token;
return cached;
},
});
See Authentication for the exact guarantees (single in-flight refresh, one replay per request, streams resumed rather than restarted).
Allowed origins
Add each origin your frontend runs on under Project → Browser origins (comma-separated), or via the API:
https://app.example.com, https://*.example.dev, http://localhost:3000
Then tokens from this project are accepted only from those origins.
Details that bite people:
-
An entry must be an origin, not a URL with a path.
https://app.example.com/chatis rejected at save time, because a stray path would silently never match. -
A wildcard covers exactly one label.
https://*.example.commatcheshttps://app.example.com, but nothttps://a.b.example.comand not the apexhttps://example.com— list those separately. -
Scheme and port are part of the origin:
http://localhost:3000andhttps://localhost:3000are different entries. -
Server-to-server calls send no
Originheader and are never affected. -
A project with no origins configured is unrestricted. Add yours before going live.
-
Setting the list replaces it — read it first, and pass the whole list. Setting it to EMPTY removes the restriction entirely, which is a widening nothing downstream can notice (every existing caller keeps working), so it takes
confirm:await oberik.origins.set(["https://app.example.com"]);await oberik.origins.set([]); // 400: this would allow any originawait oberik.origins.set([], { confirm: true }); // 200await oberik.origins.set(["*"]); // 400 too — same outcome, same question[]and["*"]are two spellings of the same thing and both takeconfirm: the refusal names the OUTCOME as the risk, and the stored list cannot tell a slip from intent afterwards.https://*.example.comis not allow-all — it is a single-label wildcard host and a genuine restriction.curl -sX PUT "$CP/api/projects/$OBERIK_PROJECT_ID/origins" \-H "X-API-Key: $OBERIK_PROJECT_KEY" -H "content-type: application/json" \-d '{"origins":[],"confirm":true}'The 400 says how many origins it would have removed, so a deploy script that clears the list on purpose asks for it once and a script that clears it by accident stops.
A mismatch fails with a clear 403 (origin '…' is not allowed for this project) rather
than an opaque CORS error, because the token tells us whose policy applies. CORS itself is
handled for you.
If you watch a disallowed origin in the network tab you will see OPTIONS answer 204 with
Access-Control-Allow-Origin: <your origin>, and the real request that follows answer
403. That looks like the two layers disagreeing, and they are answering different
questions:
| decides from | answers | |
|---|---|---|
| the preflight | the deployment-wide union of every project's origins, plus the operator's | may a browser read a response from this API at all |
| the request | your project's list, read off the token | may this token be used from this origin |
A preflight carries no Authorization header, so at that moment there is no project and
therefore no list to enforce. The union is the only allow-list that exists there. It is a
browser permission, not the boundary — being told a browser may read a response is not
being handed the data, and every project whose list does not match still gets a 403.
Two consequences worth knowing:
- The 403 is the better diagnostic. A preflight refusal would give your app an opaque "blocked by CORS" with no reason; the 403 names the origin and the project.
- The 403 itself carries no
Access-Control-Allow-Origin. A refusal that advertised the origin it was refusing is the one thing here that really was wrong, and it is fixed —Vary: Originstays, so a cache never serves that refusal to an allowed origin.
Access-Control-Allow-Credentials is not set on any response. Tokens travel in the
Authorization header, not in cookies, so nothing here depends on credentialed CORS.
Streaming and uploads from the browser
const ai = createClient({ getToken });
const handle = ai.chat.stream({ message }, { onToken: (_, full) => render(full) });
Streaming is SSE over fetch — nothing to keep alive, no proxy to run. X-Run-Id is exposed
to JavaScript, which is what lets the SDK resume a dropped stream instead of restarting the
answer.
Uploads go straight to storage over a signed URL, so large files never transit your backend:
await ai.documents.upload(file, { filename: file.name, onProgress: (s, t) => setPct(s / t) });
Multipart upload reads the ETag of each part. If you're pointing at your own bucket, expose
it (ExposeHeaders: ["ETag"]) or the upload fails with a clear message about it.