# Errors

> The complete EntryBit error catalog: OAuth protocol codes, Bearer challenges (invalid_token / insufficient_scope), and business errors (400 / 402 / 404 / 409 / 429) — with how your app should react to each.

Every error EntryBit returns, and how to react. One rule governs all of them:

> **Branch on the `error` code, never on the human-readable text.** Descriptions are deliberately generic — they never reveal *why* a credential failed (expired vs revoked vs unknown look identical), which removes enumeration oracles. The prose can change; the codes are the contract.

## Error shapes

| Shape | Where | Body |
|---|---|---|
| **OAuth error** (RFC 6749) | [`/authorize`](/docs/oauth/authorize/), [`/token`](/docs/oauth/token/) | `{ "error": "…", "error_description": "…" }` |
| **Bearer challenge** (RFC 6750) | Any resource endpoint (`/api/v1/*`) | `{ "error": "invalid_token" \| "insufficient_scope" }` + `WWW-Authenticate` header |
| **Business error** (`AppError`) | Resource endpoints | `{ "success": false, "error": "…" }` or `{ "code": "…", "message": "…" }` |
| **Quota error** | Pass creation (`402`) | `{ "code": "CAPACITY_EXCEEDED", "dimension": "guest_invites", "limit", "current", "requested" }` |

## OAuth protocol errors

Returned by the [authorize](/docs/oauth/authorize/) and [token](/docs/oauth/token/) endpoints. On `/authorize`, protocol errors after client validation ride back on the redirect as `error`/`error_description`/`state`/`iss`; an invalid `client_id`/`redirect_uri` pair is a flat `400` (never redirected).

| `error` | Typical status | Meaning | What to do |
|---|---|---|---|
| `invalid_request` | 400 | A required parameter is missing or malformed. | Fix the request (usually `redirect_uri` or PKCE). |
| `invalid_client` | 401 | Client authentication failed. | Check `client_id` / `client_secret`. |
| `invalid_grant` | 400 | Code or refresh token expired, reused, mismatched, or family-revoked. | Clear tokens, start login again. |
| `unauthorized_client` | 400 | The client may not use this grant. | Check the client's configuration. |
| `unsupported_grant_type` | 400 | `grant_type` isn't `authorization_code` or `refresh_token`. | Use a supported grant. |
| `unsupported_response_type` | — | `response_type` isn't `code`. | Use `response_type=code`. |
| `invalid_scope` | 400 | Requested a scope the client isn't allowed. | Remove it from your scope list. |
| `access_denied` | — | The user declined consent. | Show "sign-in cancelled". |
| `login_required` | — | `prompt=none` but a login was needed. | Retry without `prompt=none`. |
| `consent_required` | — | `prompt=none` but consent was needed. | Retry without `prompt=none`. |
| `interaction_required` | — | `prompt=none` but UI was needed. | Retry without `prompt=none`. |
| `server_error` | 500 | Unexpected server error. | Retry later. |
| `temporarily_unavailable` | 429/503 | Overloaded or rate limited. | Back off and retry. |

## Bearer challenges

Returned by every resource endpoint ([Passes](/docs/api-reference/passes/), [Invitations](/docs/api-reference/invitations/), [Organization](/docs/api-reference/organization/), [UserInfo](/docs/oauth/id-token-userinfo/)) when the credential is the problem. A `WWW-Authenticate: Bearer` header carries the same code, and names the missing scope on `403`.

| Status | `error` | Meaning | What to do |
|---|---|---|---|
| `401` | `invalid_token` | Access token or API key missing, expired, revoked, malformed, or (for keys) IP-restricted — indistinguishable by design. | OAuth: [refresh](/docs/oauth/token/) then retry once; if that fails, re-login. Key: check the key/expiry/allowlist. |
| `403` | `insufficient_scope` | The credential lacks a required scope, **or** the acting user's organization role was revoked. | Don't retry as-is. Add the [scope](/docs/oauth/scopes/) (or mint a key that has it); if it's a revoked role, show "not available for your account". |

```http
HTTP/1.1 403 Forbidden
WWW-Authenticate: Bearer error="insufficient_scope", scope="passes:write"
```

## Business errors

Returned once the caller is authenticated but the operation can't proceed.

| Status | Code / meaning | When | What to do |
|---|---|---|---|
| `400` | Validation error | A body field is missing or invalid (create pass). | Fix the field named in `message`/`error`. |
| `402` | `CAPACITY_EXCEEDED` | The monthly guest-invite allowance can't fit the batch (all-or-nothing). | Show the quota message; the body carries `limit`/`current`/`requested`. |
| `404` | Not found | No such resource in the caller's scope (pass or facility). | Treat as absent — don't leak existence. |
| `409` | Conflict | Facility deactivated/suspended on create, or the pass is no longer active on revoke. | Show the message; the state changed underneath you. |
| `429` | `temporarily_unavailable` | Rate limit exceeded. | Back off (exponential + jitter) and retry. |

### The `402` quota body

```json
{
  "code": "CAPACITY_EXCEEDED",
  "message": "Monthly guest-invite allowance exceeded",
  "dimension": "guest_invites",
  "limit": 50,
  "current": 50,
  "requested": 3
}
```

## Reacting in code

The decision tree for any API call:

1. **`401`** → refresh the access token and retry once. Still `401`? Send the user to log in.
2. **`403`** → don't retry the same request. Fix scopes, or surface "not available for your account" if the org role was pulled.
3. **`402`** → surface the allowance message; the batch was rejected in full.
4. **`400` / `404` / `409`** → show the `message` from the body; these are request- or state-specific.
5. **`429`** → back off with jitter, then retry.

And the same tree as a fetch wrapper. `getAccessToken()` is your token layer — the [quickstart](/docs/get-started/quickstart-react-native/) has a complete implementation with single-flight refresh. (For an [API key](/docs/api-keys/authenticating/), send the key instead and drop the `401` branch — keys aren't refreshable.)

```ts
export class ApiError extends Error {
  constructor(
    readonly status: number,
    readonly code: string,  // machine-readable — branch on this
    message: string,        // human-readable — display only
    readonly body: unknown, // full payload (e.g. 402 quota details)
  ) {
    super(message);
    this.name = 'ApiError';
  }
}

const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
  let refreshed = false;

  for (let attempt = 1; ; attempt++) {
    const res = await fetch(`https://entrybit.net${path}`, {
      ...init,
      headers: { ...init.headers, Authorization: `Bearer ${await getAccessToken()}` },
    });
    if (res.ok) return res.json() as Promise<T>;

    // 1. 401 → refresh once, retry once. A second 401 falls through as an error.
    if (res.status === 401 && !refreshed) {
      refreshed = true;
      await getAccessToken({ forceRefresh: true });
      continue;
    }
    // 5. 429 → exponential backoff with jitter, then retry.
    if (res.status === 429 && attempt < 4) {
      await sleep(2 ** attempt * 250 + Math.random() * 250);
      continue;
    }
    // 2–4. Everything else is not retryable as-is — surface it.
    const body = (await res.json().catch(() => ({}))) as {
      code?: string; error?: string; message?: string;
    };
    throw new ApiError(
      res.status,
      body.code ?? body.error ?? String(res.status),
      body.message ?? body.error ?? `HTTP ${res.status}`,
      body,
    );
  }
}
```

At the call site, branch on `status` and `code` — never on the message text:

```ts
try {
  await api('/api/v1/passes', { method: 'POST', body: JSON.stringify(draft) });
} catch (err) {
  if (!(err instanceof ApiError)) throw err; // network error or your token layer's sign-out — rethrow
  switch (err.status) {
    case 401: return routeToSignIn();          // refresh already failed once
    case 403: return showNotAvailable();       // missing scope or role revoked — do NOT retry
    case 402: return showQuota(err.body);      // { limit, current, requested }
    default:  return showMessage(err.message); // 400 / 404 / 409 — state-specific
  }
}
```

The same philosophy runs through the [token endpoint](/docs/oauth/token/), the [React Native quickstart](/docs/get-started/quickstart-react-native/), and the [API-key challenges](/docs/api-keys/authenticating/).