Skip to main content

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.

your backend
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/chat is rejected at save time, because a stray path would silently never match.

  • A wildcard covers exactly one label. https://*.example.com matches https://app.example.com, but not https://a.b.example.com and not the apex https://example.com — list those separately.

  • Scheme and port are part of the origin: http://localhost:3000 and https://localhost:3000 are different entries.

  • Server-to-server calls send no Origin header 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 origin
    await oberik.origins.set([], { confirm: true }); // 200
    await oberik.origins.set(["*"]); // 400 too — same outcome, same question

    [] and ["*"] are two spellings of the same thing and both take confirm: the refusal names the OUTCOME as the risk, and the stored list cannot tell a slip from intent afterwards. https://*.example.com is 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.

The preflight succeeds and then the request is refused. That is the design.

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 fromanswers
the preflightthe deployment-wide union of every project's origins, plus the operator'smay a browser read a response from this API at all
the requestyour project's list, read off the tokenmay 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: Origin stays, 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) });
S3 CORS

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.