Errors
Copy page
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.
Updated
Every error EntryBit returns, and how to react. One rule governs all of them:
Branch on the
errorcode, 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, /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 and 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, Invitations, Organization, 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 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 (or mint a key that has it); if it’s a revoked role, show “not available for your account”. |
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
{
"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:
401→ refresh the access token and retry once. Still401? Send the user to log in.403→ don’t retry the same request. Fix scopes, or surface “not available for your account” if the org role was pulled.402→ surface the allowance message; the batch was rejected in full.400/404/409→ show themessagefrom the body; these are request- or state-specific.429→ back off with jitter, then retry.
And the same tree as a fetch wrapper. getAccessToken() is your token layer — the quickstart has a complete implementation with single-flight refresh. (For an API key, send the key instead and drop the 401 branch — keys aren’t refreshable.)
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:
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, the React Native quickstart, and the API-key challenges.