Skip to content
GitHub

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.

ModeWhere the credential livesWho relays the chatUse it when
sessionA short-lived, thread-bound session pass your server mintsNobody. The browser talks to api.runbear.io directlyYou want your server out of the per-message path
proxyYour server; the browser sends no Authorization headerYour server relays every requestYou must inspect, log or redact every turn, or you need file attachments or suggested follow-ups
directThe organization API key, in the browserNobodyLocal 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 chat capability 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#

OptionTypeDefaultWhat it does
auth.mode"session"Selects session mode
auth.fetchSessionToken(args: FetchSessionTokenArgs) => Promise<SessionCredentials>RequiredCalls 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.sessionKeystringThe assistantIdNames 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:

FieldTypeWhen it is present
resumeTokenstring, optionalThe SDK is continuing a session: renewing a pass mid-conversation, or resuming a stored session after a reload. Take your refresh branch
assistantIdstringAlways: the agent the widget was created with
threadIdstring, optionalThe host asked for a specific conversation, with createChat(threadId) or startWithThread(threadId). Forward it as thread_id on the mint call
signalAbortSignalAlways. 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:

FieldTypeFrom Runbear's responseNotes
passstringpass.tokenRequired. The browser credential, sent as Authorization: Bearer
threadIdstringsession.thread_idRequired. The one thread this pass may read and run
sessionIdstringsession.idRequired. Log it: it is the join key across the session's requests
expiresInnumberpass.expiresInSecondsRequired. 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
resumeTokenstring, optionalresumeToken.tokenPresent 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.

CredentialCurrent default lifetimeWhat it can doWhere it lives
pass15 minutesRead and run exactly one thread with exactly one agentThe browser, in memory only
resumeToken24 hours when you send endUser; 2 hours without itObtain 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.
  • endUser is 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 threadId only 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 returns 404.

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 with retrySession(). 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 calls reset(). 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. A 401 with code: "pass_expired", or with no code, 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 the 401 fails 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:

ValueBacked byLasts
"session" (default)sessionStorageUntil the tab closes; the next visit starts a new conversation
"local"localStorageOn the device, so the conversation is still there on the next visit
"memory"NothingUntil 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:

EndpointPurpose
GET /v1/threads/{threadId}/messagesRead this conversation's history
POST /v1/threads/{threadId}/runsSend a message and wait for the reply
POST /v1/threads/{threadId}/runs/streamSend 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.enabled is 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.name and config.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:

LimitCounted perWhat happens when it is reached
60 requests per 60 secondsSession429 with code: "rate_limited"; the next window succeeds
100 turnsSession, for its whole life429 with code: "session_turn_cap_exhausted". Terminal: mint a new session
240 renewalsSession, for its whole life429 with code: "session_refresh_cap_exhausted" from POST /v1/sessions/refresh. Terminal: mint a new session
600 mints per 60 secondsOrganization and agentPOST /v1/sessions answers 429
600 renewals per 60 secondsOrganizationPOST /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:

RouteNotes
POST /v1/threadsCreates the conversation when the widget mounts, and on reset()
GET /v1/threads/{threadId}/messagesLoads a conversation's history
POST /v1/threads/{threadId}/runs/streamSends 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/suggestionsOnly when config.suggestions.enabled is true
POST /v1/files/uploadAttachments. 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:

  • baseUrl is proxy mode and sends no credential at all, even when apiKey is also set.
  • apiKey without baseUrl is direct mode.
  • auth wins. When auth is present together with a top-level apiKey or baseUrl, the SDK ignores the legacy options and logs a console warning.

The constructor throws in two cases:

  • A top-level apiKey together with auth: { mode: "session" }, because that combination would ship an API key to the browser.
  • No auth, no apiKey and no baseUrl: 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

  1. Upgrade to @runbear-io/react 0.2.0 or newer.
  2. Add the session route; keep your existing visitor authentication in front of it.
  3. Move RUNBEAR_ASSISTANT_ID next to RUNBEAR_API_KEY in your server environment. Neither belongs in browser code.
  4. Switch the client to auth: { mode: "session", … } and remove any top-level apiKey.
  5. To reopen a saved conversation, send the stored thread id as threadId from the browser; your endpoint forwards it as thread_id on the mint call.
  6. 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.

  • 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/refresh and the three session-authorized endpoints