Skip to content

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

ShapeWhereBody
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 errorPass 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).

errorTypical statusMeaningWhat to do
invalid_request400A required parameter is missing or malformed.Fix the request (usually redirect_uri or PKCE).
invalid_client401Client authentication failed.Check client_id / client_secret.
invalid_grant400Code or refresh token expired, reused, mismatched, or family-revoked.Clear tokens, start login again.
unauthorized_client400The client may not use this grant.Check the client’s configuration.
unsupported_grant_type400grant_type isn’t authorization_code or refresh_token.Use a supported grant.
unsupported_response_typeresponse_type isn’t code.Use response_type=code.
invalid_scope400Requested a scope the client isn’t allowed.Remove it from your scope list.
access_deniedThe user declined consent.Show “sign-in cancelled”.
login_requiredprompt=none but a login was needed.Retry without prompt=none.
consent_requiredprompt=none but consent was needed.Retry without prompt=none.
interaction_requiredprompt=none but UI was needed.Retry without prompt=none.
server_error500Unexpected server error.Retry later.
temporarily_unavailable429/503Overloaded 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.

StatuserrorMeaningWhat to do
401invalid_tokenAccess 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.
403insufficient_scopeThe 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.

StatusCode / meaningWhenWhat to do
400Validation errorA body field is missing or invalid (create pass).Fix the field named in message/error.
402CAPACITY_EXCEEDEDThe monthly guest-invite allowance can’t fit the batch (all-or-nothing).Show the quota message; the body carries limit/current/requested.
404Not foundNo such resource in the caller’s scope (pass or facility).Treat as absent — don’t leak existence.
409ConflictFacility deactivated/suspended on create, or the pass is no longer active on revoke.Show the message; the state changed underneath you.
429temporarily_unavailableRate 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:

  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 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.