# Conventions

> How the EntryBit REST API works across every endpoint: base URL, the two auth schemes, error envelopes, rate limits, and keyset pagination (limit / cursor / next_cursor / has_more / search).

Conventions that hold across every endpoint of the resource API. Read this once and the individual endpoint pages ([Passes](/docs/api-reference/passes/), [Invitations](/docs/api-reference/invitations/), [Organization](/docs/api-reference/organization/)) are just field lists.

## Base URL

```
https://entrybit.net
```

All traffic is JSON over HTTPS (TLS required). Credential-bearing responses set `Cache-Control: no-store`. Never place a credential in a URL.

## Authentication

Two schemes, depending on who is acting — see [Authentication](/docs/get-started/authentication/) for the full picture:

| Scheme | Header | Acts as | Endpoints |
|---|---|---|---|
| **OAuth access token** | `Authorization: Bearer <JWT>` | A signed-in user | `/api/v1/*` |
| **Organization API key** | `Authorization: Bearer eb_sk_…` (or `X-API-Key`) | The organization | `/api/v1/org/*` |

An access token is a short-lived RS256 JWT from the [token endpoint](/docs/oauth/token/); an API key is a long-lived secret from [Settings → API keys](/docs/api-keys/create-and-manage/).

## Error envelopes

EntryBit uses different error shapes for protocol vs business failures. In all of them, **branch on the code, never on the human-readable text** — descriptions are deliberately generic.

**Bearer challenges (RFC 6750)** — auth failures on any resource endpoint. A `WWW-Authenticate: Bearer` header accompanies the body:

```json
{ "error": "insufficient_scope" }
```

The `error` is `invalid_token` (`401`) or `insufficient_scope` (`403`).

**Business errors (`AppError`)** — validation, not-found, and conflict failures come in one of two equivalent shapes:

```json
{ "success": false, "error": "Facility not found" }
```
```json
{ "code": "…", "message": "Facility not found" }
```

Either form carries a machine-readable field (`error` or `code`) and a human-readable message; the exact `code` value depends on the endpoint.

**Quota (`402`)** — exhausting the monthly invite allowance returns a distinct shape:

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

The full mapping of status codes to reactions is in [Errors](/docs/api-reference/errors/).

## Rate limits

Per-credential and per-IP buckets; exceeding one returns `429` with `{ "error": "temporarily_unavailable" }`. Back off and retry (exponential backoff with jitter).

| API | Limit |
|---|---|
| User-delegated (`/api/v1/*`) | 120 requests / minute / user |
| Organization (`/api/v1/org/*`) | 300 requests / minute / source IP |

## Keyset pagination

List endpoints are **keyset (cursor) paginated**, newest first — stable and efficient even as rows are added underneath you.

**Query parameters:**

| Parameter | Notes |
|---|---|
| `limit` | Page size, 1–100. Default `30`. |
| `cursor` | The previous page's `next_cursor`. Omit for the first page. |
| `search` | Case-insensitive match on guest name / email / phone. |

**Response envelope:**

| Field | Meaning |
|---|---|
| `success` | `true`. |
| `items` | The page of results. |
| `total` | Total count. Unreliable (may be `null`) while `search` is set. |
| `next_cursor` | Opaque cursor for the next page (`null` on the last page). |
| `has_more` | Whether another page exists. |
| `usage` | Monthly allowance block — first page of a user-delegated pass listing only. |

Loop until `has_more` is `false`:

```bash
# First page
curl "https://entrybit.net/api/v1/passes?limit=50" \
  -H "Authorization: Bearer $ACCESS_TOKEN"

# Next page — feed next_cursor back as cursor
curl "https://entrybit.net/api/v1/passes?limit=50&cursor=eyJpZCI6…" \
  -H "Authorization: Bearer $ACCESS_TOKEN"
```

Or as a lazy async generator — `api()` is the authenticated fetch wrapper from [Errors](/docs/api-reference/errors/):

```ts
type Page<T> = {
  success: true;
  items: T[];
  total: number | null;
  next_cursor: string | null;
  has_more: boolean;
};

/** Yields every pass, fetching pages only as they're consumed. */
async function* eachPass<T>(search?: string): AsyncGenerator<T> {
  let cursor: string | undefined;
  do {
    const qs = new URLSearchParams({ limit: '100' });
    if (search) qs.set('search', search);
    if (cursor) qs.set('cursor', cursor); // opaque — pass back verbatim

    const page = await api<Page<T>>(`/api/v1/passes?${qs}`);
    yield* page.items;
    cursor = page.has_more && page.next_cursor ? page.next_cursor : undefined;
  } while (cursor);
}

for await (const pass of eachPass('dana')) {
  // …
}
```

Treat `next_cursor` as **opaque** — don't parse or construct it. When `has_more` is `false`, you've read every row.

## Identifiers

Public resources use prefixed, opaque public ids — for example a pass is `gst_9f1c2ab34cd56ef7`. Use these in URLs and store them as strings; the numeric `id` some responses also include is an internal, backward-compatibility field.