# Web SDK errors

> What the Runbear chat widget does on each API error, how to recover from each sessionFailed reason, rate limits, and the RunbearApiError fields.

Source: https://docs.runbear.io/api/web-sdk/errors

Last updated: 2026-09-30

The widget handles most failures itself: it renews an expired pass, retries your session endpoint
with backoff, and shows a short error message when a reply fails. This page covers what it does on
each status, which failures your page hears about, and how to recover.

## What your page can observe

Your page hears about **session** failures through the `sessionFailed` event. It does **not** hear
about a failed message: when a reply fails, the widget shows a generic "An unknown error occurred"
message to the visitor and logs the error to the browser console, and there is no error event for
the host. To watch for failed turns, check the console while you develop, or read the
conversation's server-side history.

## Session failures

In session mode, `sessionFailed { reason }` means the session has ended. It is **terminal**: the SDK
never retries by itself, or the renewal loop the circuit breaker exists to stop would come straight
back. Recovery is yours.

| `reason`        | What happened                                                                                                                                                                                                        | Stored session | How to recover                                                                                                                   |
| --------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `declined`      | Your `fetchSessionToken` threw `SessionDeclinedError`, or Runbear refused the pass: a `401` that renewal can't heal, a `403`, or a `429` with a lifetime-cap code                                                    | Cleared        | Sign the visitor in again if that was the cause, then call `retrySession()`, which starts a new conversation                     |
| `expired`       | The session's conversation is gone: a `404` on a session-authorized route                                                                                                                                            | Cleared        | Call `retrySession()` to start a new conversation                                                                                |
| `network`       | Your endpoint failed on 5 attempts in a row (in 0.5.0 and later, not yet released, after one more round without a stale resume token; see [Declining a session](/api/web-sdk/authentication.md#declining-a-session)) | Kept           | Call `retrySession()`, which resumes the same conversation once your endpoint works again                                        |
| `misconfigured` | Your endpoint returned something the SDK can't use as a credential, such as Runbear's response body unmapped or an `expiresIn` under 10 seconds                                                                      | Cleared        | Fix the endpoint's [mapping](/api/web-sdk/authentication.md#sessioncredentials); `retrySession()` then starts a new conversation |

```ts
chat.on("sessionFailed", ({ reason }) => {
  if (reason === "network") showRetryButton(() => chat.retrySession())
  else if (reason === "declined") showSignInAgain()
  else chat.retrySession()
})
```

`retrySession()` is the only way out of the failed state: `reset()` and `startWithMessage()` don't
clear it. It does nothing outside session mode or when the session hasn't failed.

## Errors and renewal

The SDK handles renewal for you; this is what it does with each status in session mode.

| Status                              | Meaning                                                                                                                                                                                                | What happens                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                 |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `401` with `code: "pass_expired"`   | The pass aged out.                                                                                                                                                                                     | The SDK calls your `fetchSessionToken` again with the stored resume token. It replays the request once only if it was a read (`GET`, such as loading the history). A `401` on sending a message (`POST …/runs/stream`) renews the pass, but that message fails with an error and the visitor's next send succeeds. The SDK renews ahead of expiry, so this is rare.                                                                                                                                                                                                          |
| `401` with **no** `code`            | An older API build that predates the `code` vocabulary.                                                                                                                                                | Treated exactly like `pass_expired`: one bounded renewal and at most one replay, never a loop.                                                                                                                                                                                                                                                                                                                                                                                                                                                                               |
| `401` with any other `code`         | The credential isn't usable.                                                                                                                                                                           | Terminal. The SDK clears stored credentials and emits `sessionFailed { reason: "declined" }`.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
| `403`                               | The pass isn't allowed to do this.                                                                                                                                                                     | Terminal, `reason: "declined"`. Renewing can't change an authorization decision.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                             |
| `404` on a session-authorized route | The bound thread is gone, or the request named a different one.                                                                                                                                        | Terminal, `reason: "expired"`. The SDK does **not** silently start a new conversation.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                       |
| `404` from `POST /v1/sessions`      | The `assistant_id` doesn't name an agent your organization owns, or a `thread_id` you passed doesn't belong to that agent. Runbear answers the same way for both, and for an agent that doesn't exist. | Your endpoint sees this. Check the agent id first; it is the usual cause.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    |
| `429`                               | Rate-limited.                                                                                                                                                                                          | A `429` with `code: "rate_limited"` is transient: the message fails with the widget's error message, and the SDK does **not** retry it automatically. A `429` with `code: "session_turn_cap_exhausted"` or `"session_refresh_cap_exhausted"` means the session used up its lifetime allowance, and is terminal (`reason: "declined"`): start a new session. See [Session limits](/api/web-sdk/authentication.md#session-limits) for the numbers, and [Errors and limits](/api/errors-and-limits.md#rate-limits) for the organization-wide limits and the rate-limit headers. |
| `5xx`                               | A server error.                                                                                                                                                                                        | The message fails with the widget's error message. The session is not affected.                                                                                                                                                                                                                                                                                                                                                                                                                                                                                              |

What your endpoint's failures do depends on what `fetchSessionToken` throws: `SessionDeclinedError`
ends the session at once (`reason: "declined"`); any other error is retried and ends with
`reason: "network"`. See [Declining a session](/api/web-sdk/authentication.md#declining-a-session).

## Errors the SDK throws

These are thrown synchronously, so they surface in your own code:

| Where           | Message                                            | Cause                                                          |
| --------------- | -------------------------------------------------- | -------------------------------------------------------------- |
| `new Runbear()` | "Runbear: apiKey must not be set in session mode…" | A top-level `apiKey` together with `auth: { mode: "session" }` |
| `new Runbear()` | "Runbear: missing apiKey…"                         | No `auth`, no `apiKey` and no `baseUrl`                        |
| `chat.mount()`  | "Could not find element to mount chat widget"      | The selector matched nothing                                   |
| `chat.mount()`  | "Chat widget is already mounted"                   | `mount()` was called twice on one chat                         |

## Console warnings

The widget logs a warning, prefixed `Runbear:`, for problems it can work around:

- its stylesheet is missing, usually a Content-Security-Policy without `style-src 'unsafe-inline'`
  (see [Install](/api/web-sdk/install.md#content-security-policy));
- the mount element wasn't empty (0.5.0 and later, not yet released);
- `auth` was passed together with the legacy `apiKey` or `baseUrl` options;
- `config.suggestions.enabled` is set in session mode;
- browser storage is unavailable, or another widget already uses the same `sessionKey`, so the
  session won't survive a reload;
- a message was sent before the widget had a conversation;
- a response component failed to render and its `fallbackText` is shown instead.

## RunbearApiError

`RunbearApiError` is the error the SDK's API client rejects with for every non-2xx response. The
widget logs it to the console when a message fails. It extends `Error` and adds:

| Field               | Type                  | Notes                                                                                                                           |
| ------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `status`            | number                | The HTTP status                                                                                                                 |
| `code`              | string or `undefined` | The API's machine-readable `code`, when the response had one. See [Errors and limits](/api/errors-and-limits.md)                |
| `body`              | string or `undefined` | The response body as text. Untrusted server text: branch on `status` and `code`, and never show `body` or `message` to visitors |
| `retryAfterSeconds` | number or `undefined` | The `Retry-After` header, when it was a whole number of seconds. The SDK never retries on it                                    |

Which fields are filled depends on the request:

| Request                                                             | `code`                       | `body`             |
| ------------------------------------------------------------------- | ---------------------------- | ------------------ |
| JSON requests: creating a thread, reading a transcript, suggestions | Set when the body has one    | Set                |
| Sending a message (the streaming run)                               | Set for `401` and `429` only | Always `undefined` |
| File uploads                                                        | Always `undefined`           | Always `undefined` |

`retryAfterSeconds` is set on all three whenever the server sent a whole-number `Retry-After`.
`RunbearApiError` isn't the only error on these paths: an aborted file upload rejects with the
browser's `DOMException`, and an upload whose `2xx` response isn't JSON rejects with a plain
`Error`. A streaming run whose successful response has no body rejects with a `RunbearApiError`
carrying that success status.

The client that produces these errors, `runbear.api.v1.*`, is internal to the widget and isn't a
supported public API. To call Runbear yourself, use the [REST API](/api/overview.md) from your server.

## Related

- [Authentication](/api/web-sdk/authentication.md): renewal, declining a session, and session limits
- [Chat API](/api/web-sdk/chat-api.md): the `sessionFailed` event and `retrySession()`
- [Errors and limits](/api/errors-and-limits.md): the API's error envelope, codes and rate limits
