Web SDK authentication
Keep your Runbear API key off the browser with session passes your server mints, or relay the widget through your own proxy. Covers the session endpoint, renewal, storage and declining a session.
On this page
A Runbear API key is an organization-level credential: anyone who can read it can call the API as your organization. The widget runs in the browser, so the key must never be shipped in public browser JavaScript. There are three modes; which one fits depends on where you want the credential to live and whether your server needs to see every message.
| Mode | Where the credential lives | Who relays the chat | Use it when |
|---|---|---|---|
session | A short-lived, thread-bound session pass your server mints | Nobody. The browser talks to api.runbear.io directly | You want your server out of the per-message path |
proxy | Your server; the browser sends no Authorization header | Your server relays every request | You must inspect, log or redact every turn, or you need file attachments or suggested follow-ups |
direct | The organization API key, in the browser | Nobody | Local prototyping and fully internal tools only |
Session mode#
Your backend mints a short-lived session pass for each visitor and hands it to the widget. The
browser then talks to api.runbear.io directly, so your server is out of the per-message path.
The API key never leaves your server, and a stolen pass is worth one conversation for a few
minutes. Session mode needs @runbear-io/react 0.2.0 or newer.
Browser (SDK, holds a pass) ──────────────▶ api.runbear.io (every message)
│
├── at session start ──▶ your backend ──▶ POST /v1/sessions (API key)
└── on each renewal ──▶ your backend ──▶ POST /v1/sessions/refresh (API key)What session mode gives you:
- Your server is out of the per-message path: it is called once to mint the session, then again on each renewal, instead of on every message. It also holds zero streaming connections, where a proxy holds one open stream for every in-flight message.
- One less network hop on every message.
- Each visitor is isolated to their own conversation with one agent. A proxy authenticates every visitor with the same API key and can't express that.
A session pass is bound at mint time to one agent and one thread. It can't be widened, and it can't mint another pass.
What you need#
- An API key with the
chatcapability whose agent allowlist, if it has one, includes the agent. The same key renews sessions, and a renewal is refused once the allowlist no longer covers the session's agent. See Authentication and API keys. - A server endpoint that authenticates your visitor and mints or renews the session: Your session endpoint.
- The widget configured with
auth: { mode: "session", fetchSessionToken }.
import Runbear, { SessionDeclinedError } from "@runbear-io/react"
const client = new Runbear({
assistantId: "…",
auth: {
mode: "session",
fetchSessionToken: async ({ resumeToken, threadId, signal }) => {
const res = await fetch("/api/runbear/session", {
method: "POST",
headers: { "content-type": "application/json" },
credentials: "include",
body: JSON.stringify({ resumeToken, threadId }),
signal,
})
if (res.status === 401 || res.status === 403) {
// Your endpoint refused this visitor: end the session now.
throw new SessionDeclinedError(`session refused: ${res.status}`)
}
// Anything else is treated as transient and retried with backoff.
if (!res.ok) throw new Error(`session endpoint failed: ${res.status}`)
return await res.json()
},
storage: "session", // the default
},
})Session options#
| Option | Type | Default | What it does |
|---|---|---|---|
auth.mode | "session" | Selects session mode | |
auth.fetchSessionToken | (args: FetchSessionTokenArgs) => Promise<SessionCredentials> | Required | Calls your endpoint to mint or renew a session. It must be a function, not a URL: a pass expires and is fetched again mid-conversation |
auth.storage | "session", "local" or "memory" | "session" | How long the widget remembers the visitor's conversation. See Where the session is stored |
auth.sessionKey | string | The assistantId | Names this widget's storage entry. Set a distinct value when two widgets on one site use the same agent |
What fetchSessionToken receives#
The SDK calls fetchSessionToken with one argument, FetchSessionTokenArgs:
| Field | Type | When it is present |
|---|---|---|
resumeToken | string, optional | The SDK is continuing a session: renewing a pass mid-conversation, or resuming a stored session after a reload. Take your refresh branch |
assistantId | string | Always: the agent the widget was created with |
threadId | string, optional | The host asked for a specific conversation, with createChat(threadId) or startWithThread(threadId). Forward it as thread_id on the mint call |
signal | AbortSignal | Always. It aborts when the widget is destroyed mid-fetch; pass it to fetch |
resumeToken is absent, with the key omitted rather than set to undefined, for a genuinely new
conversation: a first visit with nothing stored, reset(), startWithMessage(), a thread-bound mint
(createChat(threadId) or startWithThread()), and a stored session that belongs to a different
agent. So "resumeToken" in args is a safe test.
Your session endpoint#
This is the one piece of code you must write, and the only place your visitors are authenticated.
import type { SessionCredentials } from "@runbear-io/react"
const RUNBEAR_API = "https://api.runbear.io"
/** The nested shape Runbear returns. Mirrors the `SessionCredentials` OpenAPI component. */
interface RunbearSessionResponse {
session: { id: string; assistant_id: string; thread_id: string; expiresAt: string }
pass: { token: string; expiresAt: string; expiresInSeconds: number }
resumeToken?: { token: string; expiresAt: string; expiresInSeconds: number }
}
// POST /api/runbear/session — YOUR server, YOUR auth
export async function POST(req: Request): Promise<Response> {
// 1. REQUIRED: authenticate the caller with your own session / cookie / JWT.
const user = await getSessionUser(req)
if (!user) return Response.json({ error: "unauthorized" }, { status: 401 })
const body: { resumeToken?: string; threadId?: string } = await req.json().catch(() => ({}))
// 2. Mint (no resumeToken) or refresh (resumeToken present). Two routes, never one.
// `endUser` binds the resume token to THIS visitor. Send the same value on
// every call for that visitor. Use your stable user id — never an email.
const callRunbear = (path: string, payload: object): Promise<Response> =>
fetch(new URL(path, RUNBEAR_API), {
method: "POST",
headers: {
"content-type": "application/json",
// Server-only. NEVER return this value to the browser.
authorization: `Bearer ${process.env.RUNBEAR_API_KEY}`,
},
body: JSON.stringify(payload),
})
const mint = (): Promise<Response> =>
callRunbear("/v1/sessions", {
assistant_id: process.env.RUNBEAR_ASSISTANT_ID,
endUser: user.id,
...(body.threadId ? { thread_id: body.threadId } : {}),
})
const isRefresh = typeof body.resumeToken === "string" && body.resumeToken.length > 0
let res = isRefresh
? await callRunbear("/v1/sessions/refresh", { resumeToken: body.resumeToken, endUser: user.id })
: await mint()
if (isRefresh && res.status === 400) {
// `resume_token_expired` is the normal end of a session; `resume_token_invalid`
// means the token can't be used. For both, mint a fresh session WITHOUT the
// resume token and return it. The widget adopts the new session and its
// resume token, emitting `sessionChanged` if it was already showing a
// conversation. Returning an error here instead would NOT clear the stored
// token: the widget would retry, then fail with the old token still stored.
const { code } = await res.clone().json().catch(() => ({ code: undefined }))
if (code === "resume_token_expired" || code === "resume_token_invalid") {
res = await mint()
}
}
if (!res.ok) {
return Response.json({ error: "session_unavailable" }, { status: 502 })
}
const upstream: RunbearSessionResponse = await res.json()
// 3. THE MAPPING. Runbear's response is nested; the SDK's credential is flat.
const credentials: SessionCredentials = {
pass: upstream.pass.token,
threadId: upstream.session.thread_id,
sessionId: upstream.session.id,
expiresIn: upstream.pass.expiresInSeconds,
...(upstream.resumeToken ? { resumeToken: upstream.resumeToken.token } : {}),
}
return Response.json(credentials)
}SessionCredentials#
What fetchSessionToken must resolve to:
| Field | Type | From Runbear's response | Notes |
|---|---|---|---|
pass | string | pass.token | Required. The browser credential, sent as Authorization: Bearer |
threadId | string | session.thread_id | Required. The one thread this pass may read and run |
sessionId | string | session.id | Required. Log it: it is the join key across the session's requests |
expiresIn | number | pass.expiresInSeconds | Required. The pass's remaining life in seconds, measured by the server. Runbear issues 900. It must be at least 10, or the SDK treats the response as misconfigured; sending milliseconds or a timestamp is the usual cause |
resumeToken | string, optional | resumeToken.token | Present on mint, absent on every refresh. When it is absent the SDK keeps the one it has |
pass.expiresAt, resumeToken.expiresAt, resumeToken.expiresInSeconds, session.assistant_id
and session.expiresAt are server-side bookkeeping; don't forward them. Expiry instants are
deliberately not sent to the browser: comparing them to a browser clock is the failure expiresIn
exists to avoid.
Two credentials, two jobs.
| Credential | Current default lifetime | What it can do | Where it lives |
|---|---|---|---|
pass | 15 minutes | Read and run exactly one thread with exactly one agent | The browser, in memory only |
resumeToken | 24 hours when you send endUser; 2 hours without it | Obtain a fresh pass. It can never chat. | Your server, or the SDK's storage |
The resume token's expiry is the session's absolute ceiling. Refreshing issues a new pass but never
moves that instant; when it passes, mint a new session. Treat both lifetimes as current defaults
read from the response (expiresInSeconds), not as guarantees, and never hardcode them.
Other things this endpoint owns:
- Never return the API key in this response, in any field, for any reason.
endUseris your stable user id, never an email address. Runbear salts and hashes it server-side and binds the resume token to it; every later refresh must present the byte-identical value, or the refresh is rejected. Omit it only for genuinely anonymous widgets: the resume token is then a pure bearer credential and is issued with a materially shorter lifetime (2 hours instead of 24).- Forward
threadIdonly when the SDK sends it. If your endpoint always adds a thread id it saved earlier,reset()brings back the old conversation instead of starting a new one. The thread must belong to your organization and to the same agent, or the mint call returns404.
Declining a session#
Your endpoint is the kill switch. To end a visitor's session, have your endpoint refuse it (the
samples above answer 401 or 403) and have fetchSessionToken throw SessionDeclinedError,
exported from @runbear-io/react. The SDK then ends the session immediately, with one call to your
endpoint and no retries: it clears the stored credentials and emits
sessionFailed { reason: "declined" }. The visitor's current pass expires within minutes and can't
be renewed.
The SDK also recognizes a SessionDeclinedError wrapped as another error's cause, such as
new Error("mint failed", { cause: declined }), and one thrown by a second copy of the package in
your bundle. isSessionDeclined(error) is exported if you need to test for it yourself.
A plain error is transient, not a kill switch. Any other error thrown from fetchSessionToken,
including a network failure, is retried: the SDK makes 5 attempts in total, waiting about 0.5, 1, 2
and 4 seconds (±20%) between them. What happens next depends on the version:
- 0.5.0 and later (not yet released): if those attempts were resuming a stored session with its resume token, the
SDK drops the stored token and makes one more round of attempts without it, which reaches your
mint branch. Only if that round fails too does it emit
sessionFailed { reason: "network" }. - Any other failed round, and every version up to 0.4.0: the SDK emits
sessionFailed { reason: "network" }and keeps the stored resume token, so the conversation can be resumed later withretrySession(). On 0.4.0 and earlier, an endpoint that answers an expired resume token with an error therefore fails every page load, until your code callsreset(). The sample above avoids that on every version by minting a fresh session instead.
A response the SDK can't use as a credential, such as a missing field or an expiresIn under 10
seconds, throws SessionCredentialError internally and ends the session with
reason: "misconfigured" after one call. See Errors for how to recover
from each reason.
Renewal#
The SDK renews the pass for you by calling fetchSessionToken with the stored resume token:
- Ahead of expiry. It renews 60 seconds before the pass expires (halfway through, for a pass that
lives under 2 minutes). Each successful renewal emits
sessionRenewed. - Not in a background tab. A renewal that falls due while the tab is hidden waits until the tab is visible again.
- After a
401. A401withcode: "pass_expired", or with nocode, renews the pass once. The failed request is replayed only if it was a read (GET, such as loading the history). A message send that hits the401fails with an error, and the visitor's next send succeeds with the fresh pass. Renewing ahead of expiry makes this rare. - One renewal at a time. Concurrent requests share one in-flight call to your endpoint.
Where the session is stored#
The widget remembers which conversation a visitor is in, so a reload doesn't start a new one.
auth.storage decides how long that memory lasts:
| Value | Backed by | Lasts |
|---|---|---|
"session" (default) | sessionStorage | Until the tab closes; the next visit starts a new conversation |
"local" | localStorage | On the device, so the conversation is still there on the next visit |
"memory" | Nothing | Until the page reloads |
The entry is stored under the key runbear.session.v1.<sessionKey>, where sessionKey defaults to
the agent's assistantId. If another live widget on the page already uses that key, or the browser
blocks storage, the widget falls back to memory and logs a console warning. A stored session that
belongs to a different agent is discarded, and the widget starts a new conversation.
To reopen a specific saved conversation rather than the stored one, see Saving and reopening a conversation.
What a session pass can do#
A pass is accepted on exactly three endpoints, and only for the thread and agent it was minted for:
| Endpoint | Purpose |
|---|---|
GET /v1/threads/{threadId}/messages | Read this conversation's history |
POST /v1/threads/{threadId}/runs | Send a message and wait for the reply |
POST /v1/threads/{threadId}/runs/stream | Send a message and stream the reply |
Everything else returns 403, including creating or listing threads, listing or reading agents
and their instructions, traces and analytics, knowledge and tool configuration, API-key management,
file upload, suggested follow-ups, and minting or refreshing another session. A request naming a
different agent is also a 403. A request naming a different thread, on one of the three
routes above, is a 404, worded and shaped exactly like a thread that doesn't exist: Runbear
deliberately doesn't confirm whether someone else's thread id is real.
Not supported in session mode:
- File attachments. The upload endpoint is denied to a session pass, so the widget hides its attachment button. Use proxy mode if attachments matter to you.
- Suggested follow-up questions. The suggestions endpoint isn't bound to a conversation, so a
session pass can't use it.
config.suggestions.enabledis a no-op in session mode, and the SDK warns once in the console. - Reading agent metadata. The widget's displayed name and avatar come from
config.assistant.nameandconfig.assistant.avatarUrl, not from an API read. - Listing a visitor's past conversations. A pass sees one thread. Keep your own list of thread ids per user if you need a history picker, and mint against the chosen one.
A conversation that is created by a mint call and never receives a message is removed after 7 days. A stored thread id for such an empty conversation stops resolving; mint a fresh session in that case.
Session limits#
Each session has its own limits on top of the organization's:
| Limit | Counted per | What happens when it is reached |
|---|---|---|
| 60 requests per 60 seconds | Session | 429 with code: "rate_limited"; the next window succeeds |
| 100 turns | Session, for its whole life | 429 with code: "session_turn_cap_exhausted". Terminal: mint a new session |
| 240 renewals | Session, for its whole life | 429 with code: "session_refresh_cap_exhausted" from POST /v1/sessions/refresh. Terminal: mint a new session |
| 600 mints per 60 seconds | Organization and agent | POST /v1/sessions answers 429 |
| 600 renewals per 60 seconds | Organization | POST /v1/sessions/refresh answers 429 |
Every run made with a session pass also counts against the organization's chat limit of 600 requests per 60 seconds per agent, which is shared by every visitor of the widget. Contact support@runbear.io before a high-traffic launch. See Errors and limits for the full table and the rate-limit headers.
Proxy mode#
Point the widget at an endpoint on your own backend. The SDK sends every request there with no
Authorization header; your server forwards it to https://api.runbear.io and attaches the API
key server-side.
const client = new Runbear({
assistantId: "…",
auth: { mode: "proxy", baseUrl: "https://app.example.com/api/runbear" },
})The SDK appends the API path to baseUrl, so your relay receives, for example,
https://app.example.com/api/runbear/v1/threads. Forward these routes:
| Route | Notes |
|---|---|
POST /v1/threads | Creates the conversation when the widget mounts, and on reset() |
GET /v1/threads/{threadId}/messages | Loads a conversation's history |
POST /v1/threads/{threadId}/runs/stream | Sends a message. The reply is NDJSON: stream it through, don't buffer it, or the visitor sees nothing until the reply is complete |
POST /v1/chat/suggestions | Only when config.suggestions.enabled is true |
POST /v1/files/upload | Attachments. A multipart/form-data body, up to 50 MB; forward it unchanged |
Proxy mode is fully supported. It is the right choice when you want every message to pass through your own logging, redaction or compliance layer, when you inject server-side context per request, or when you need the features session mode doesn't cover.
Direct mode (development only)#
Pass an API key and the SDK calls api.runbear.io straight from the browser.
const client = new Runbear({
assistantId: "…",
auth: { mode: "direct", apiKey: "<your Runbear API key>" }, // exposed in the browser
})auth: { mode: "direct", apiKey, baseUrl } sends the key to a custom origin, for example a
gateway that expects it. Unlike proxy mode, the credential is still attached.
Legacy options#
Before 0.2.0 the mode was chosen with top-level options. They still work but are deprecated:
baseUrlis proxy mode and sends no credential at all, even whenapiKeyis also set.apiKeywithoutbaseUrlis direct mode.authwins. Whenauthis present together with a top-levelapiKeyorbaseUrl, the SDK ignores the legacy options and logs a console warning.
The constructor throws in two cases:
- A top-level
apiKeytogether withauth: { mode: "session" }, because that combination would ship an API key to the browser. - No
auth, noapiKeyand nobaseUrl: the widget would have no way to authenticate.
Setting config.suggestions.enabled in session mode doesn't throw; it logs a warning and has no
effect.
Migrating from proxy mode#
The whole migration is: delete the catch-all forwarder, add one route, change the client options.
Before: your server forwards every message.
// /api/runbear/[...path] — one request per message, one open stream per reply
const upstream = new URL(path, "https://api.runbear.io")
const res = await fetch(upstream, {
method: req.method,
headers: { ...forwardedHeaders, authorization: `Bearer ${process.env.RUNBEAR_API_KEY}` },
body: req.body,
})After: your server mints once per visitor. Delete the route above and add the single
/api/runbear/session route from Your session endpoint. In the browser,
replace baseUrl (or auth: { mode: "proxy", … }) with the session-mode options from
What you need.
Checklist
- Upgrade to
@runbear-io/react0.2.0 or newer. - Add the session route; keep your existing visitor authentication in front of it.
- Move
RUNBEAR_ASSISTANT_IDnext toRUNBEAR_API_KEYin your server environment. Neither belongs in browser code. - Switch the client to
auth: { mode: "session", … }and remove any top-levelapiKey. - To reopen a saved conversation, send the stored thread id as
threadIdfrom the browser; your endpoint forwards it asthread_idon the mint call. - Delete the catch-all proxy route once traffic has moved.
Migration is reversible at any time: proxy mode is fully supported, so switching the client options back is the rollback. What you give up is listed under Not supported in session mode.
Related#
- Errors: session failures, renewal statuses and recovery
- Chat API: session events and
retrySession() - Authentication and API keys: creating the key your endpoint uses
- OpenAPI reference: exact schemas for
POST /v1/sessions,POST /v1/sessions/refreshand the three session-authorized endpoints