# BugBoard SDK — API Reference & Design Spec

> **This is the canonical source of truth for the SDKs (JavaScript/TypeScript and PHP).**
> Every language guide (`javascript.md`, `php.md`) implements the contract and
> design principles described here. If anything in a language guide disagrees with this file,
> **this file wins.**

---

## 1. What the SDK does

BugBoard is an error‑triage product. Reported errors become **cards** on a project's kanban
board. There is exactly one public ingestion endpoint, and the SDK is a thin, safe, ergonomic
wrapper around it.

| Concept      | Meaning                                                                                      |
| ------------ | -------------------------------------------------------------------------------------------- |
| **Card**     | What you see on the board (the product/UI term).                                             |
| **Task**     | The same thing in the data model and REST API (`/api/v1/tasks`).                             |
| **Project**  | Owns the board and its API keys. A project can hold many named, individually revocable keys. |
| **Severity** | How bad it is: `critical \| major \| moderate \| minor`.                                     |
| **Priority** | How urgently to act: `low \| medium \| high`.                                                |

> **The SDK speaks "cards"; the API speaks "tasks". Every reporting method maps to
> `POST /api/v1/tasks`.**

---

## 2. Endpoint

```text
POST https://bugboard.dev/api/v1/tasks
```

The SDK targets BugBoard's ingestion endpoint automatically — you don't configure a base URL. It
appends `/api/v1/tasks` itself.

### Request headers

| Header               | When                               | Value                                              |
| -------------------- | ---------------------------------- | -------------------------------------------------- |
| `Content-Type`       | always                             | `application/json`                                 |
| `Accept`             | always                             | `application/json`                                 |
| `X-Bb-Hide-Response` | `hideApiResponse` (default **on**) | `true` — withholds the card from the response (§5) |
| `Authorization`      | bearer auth                        | `Bearer bb_pub_…` (§3.1)                           |
| `X-Bb-Key-Id`        | HMAC auth                          | `bbk_…` (§3.2)                                     |
| `X-Bb-Timestamp`     | HMAC auth                          | Unix seconds, as a string                          |
| `X-Bb-Signature`     | HMAC auth                          | Lower-case hex HMAC-SHA256                         |

Exactly one auth scheme is present per request.

`X-Bb-Hide-Response` is deliberately a **header, not a body field**. It therefore stays readable
when the body is encrypted (§11), is out of reach of the `beforeSend` scrubbing hook, and is **not
covered by the HMAC signature**, which spans the body alone.

There is **no bulk endpoint** — one card per request. SDKs achieve throughput with a
bounded-concurrency queue, not a batch call.

---

## 3. Authentication

BugBoard has **two kinds of API key**, chosen by **where the SDK runs**. A project may hold many
keys; each is **named** and **individually revocable** (revoking one never affects the others), so
you can rotate a leaked key without breaking every other integration.

| Where the SDK runs      | Key type        | Prefix    | How it authenticates                                  |
| ----------------------- | --------------- | --------- | ----------------------------------------------------- |
| Browser JS, mobile apps | **Publishable** | `bb_pub_` | Bearer token (+ optional origin allow‑list)           |
| Node / PHP / … servers  | **Secret**      | `bb_sec_` | HMAC request signature (the secret is **never sent**) |

> A secret key **cannot** be used as a bearer token, and a publishable key is **not** accepted on
> the signed path. Use the scheme that matches the key type.

### 3.1 Publishable keys (`bb_pub_…`) — client‑side

For browsers and mobile apps, where the credential is shipped to end users and **cannot be kept
secret**. Sent as a bearer token:

```yaml
Authorization: Bearer bb_pub_<48 hex chars>
```

- **Public by design** and **write‑only** — it can only `POST` tasks to its one project, so a leak
  can't read or exfiltrate anything.
- Contained by an optional **origin allow‑list** (§3.4), the project's **per‑minute burst limit**,
  and its **share of the account's event quota** (§6). Those — not secrecy — are what limit abuse.

### 3.2 Secret keys (`bb_sec_…`) — server‑side

For backend SDKs, where the credential never reaches an end user. **The secret never travels on
the wire** — each request is HMAC‑signed. A secret key is issued as **two values**:

- a **key id** (`bbk_…`) — public; identifies which key signed the request;
- a **signing secret** (`bb_sec_…`) — shown once; used to sign; never transmitted.

Each request carries three headers:

```yaml
X-Bb-Key-Id: bbk_<id>
X-Bb-Timestamp: <unix seconds>
X-Bb-Signature: <hex HMAC-SHA256>
```

#### Signing algorithm — port verbatim into every server SDK

```text
sign(method, path, body, signingSecret, keyId):
    timestamp = current unix time in SECONDS, as a string
    payload   = timestamp + "." + UPPERCASE(method) + "." + path + "." + sha256_hex(body)
    signature = hmac_sha256_hex(key = signingSecret, message = payload)
    return headers:
        X-Bb-Key-Id    = keyId
        X-Bb-Timestamp = timestamp
        X-Bb-Signature = signature
```

- **`path`** — the **constant** `/api/v1/tasks`, with its leading slash and no query string. Sign
  that literal string, not the resolved request path: it does not change even if the SDK is pointed
  at another origin, and a server sitting behind a proxy that rewrites or subpath-mounts the route
  must still verify against it.
- **`body`** — the **exact raw bytes** you transmit. Sign the same bytes you send; re‑serializing
  the JSON differently (key order, whitespace, unicode escaping) **breaks the signature**. When the
  payload is encrypted (§11), the bytes you sign are the **envelope** — encrypt first, then sign.
- **`method`** — upper‑cased before signing, so a lower‑case `post` produces the same signature.
- **`sha256_hex` / `hmac_sha256_hex`** — lower‑case hex digests.
- The server rejects any timestamp more than **±300 seconds** from its clock (replay protection),
  so keep the client clock roughly in sync. A `Retry-After` is not returned for a bad signature —
  it's a hard `401`.
- **Re‑sign on every retry.** Because of that ±300 s window, recompute the timestamp and signature
  for each attempt while transmitting the **same body bytes** — an old signature will expire.

#### Reference vector

Pin this in your test suite. It reproduces with plain `openssl`, so you can check an implementation
without a network call:

| Field              | Value                                                              |
| ------------------ | ------------------------------------------------------------------ |
| `key_id`           | `bbk_test123`                                                      |
| `signing_secret`   | `bb_sec_0123456789abcdef`                                          |
| `timestamp`        | `1750000000`                                                       |
| `method`           | `POST`                                                             |
| `path`             | `/api/v1/tasks`                                                    |
| `body`             | `{"severity":"major","title":"SDK smoke test"}`                    |
| `sha256_hex(body)` | `9070dce6abd7e9819456eee1d61339f697b070b89e3e743a97ec66bf8754480e` |
| **signature**      | `c9436e5c768e0cbea09119c0b112088f348f45aeb1c1ffcccecd62e65e2f3fc1` |

```bash
BODY='{"severity":"major","title":"SDK smoke test"}'
SECRET='bb_sec_0123456789abcdef'
HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 -hex | awk '{print $NF}')
printf '%s' "1750000000.POST./api/v1/tasks.${HASH}" \
  | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}'
# c9436e5c768e0cbea09119c0b112088f348f45aeb1c1ffcccecd62e65e2f3fc1
```

### 3.3 Getting a key

1. Open your project → **Settings → API Keys** (requires the `manageApiKey` ability).
2. **Create key**, choose **Publishable** (browser/mobile) or **Secret** (server), name it, and for
   publishable keys optionally add **allowed origins**.
3. Copy the shown value(s) **immediately** — the publishable token, or the **key id + signing
   secret** for a secret key. BugBoard stores only a hash and can never show them again.
4. **Revoke** any key at will (password‑confirmed). Revocation is immediate and per‑key.

### 3.4 Origin allow‑list (publishable keys only)

A publishable key may carry a list of allowed origins. When the list is non‑empty, the server only
accepts requests whose `Origin` (falling back to `Referer`) matches an entry; an **empty list
accepts any origin**. Entries may be a full origin (`https://app.example.com`) or a bare host
(`app.example.com`); a mobile identifier sent in the `X-Bb-App-Id` header is matched too.

> **This is a junk filter, not a security wall.** A real browser can't forge `Origin`, so it blocks
> _other sites_ embedding your key — but `curl`/a script can send any `Origin`, so it does **not**
> stop a determined attacker. Note `https://bugboard.dev` and `https://www.bugboard.dev` are
> **different origins**. The real cap on a leaked key is the **quota** (§6) — and, because that
> quota is shared across your account, the **per‑project share cap** that stops one leaked key
> starving your other projects.

### 3.5 Security model — read before shipping

- **Server SDKs** use a **secret** key, read from env (`BUGBOARD_KEY_ID` +
  `BUGBOARD_SIGNING_SECRET`); never hardcode or log them. The secret is only ever used to compute
  a signature — it is never placed in a header or body.
- **Browser / mobile SDKs** embed a **publishable** key. It is publicly extractable _by design_ —
  contain the blast radius with a **dedicated publishable key per app**, an **origin allow‑list**,
  **aggressive sampling**, and the **quota** (§6); rotate instantly on abuse. The quota is your
  account's, but no single project may draw more than **80%** of it, so a leaked key cannot silence
  the rest of your projects. For anything sensitive, prefer **payload encryption** (§11).

---

## 4. Request body

The body is deliberately small. `severity` is the only required field; the SDK fills it and
`priority` from the **method name**. Everything else about where the card lands and how it's
classified is decided by the server, **not** the caller:

- the card always enters the project's **`todo`** column;
- it is created **unassigned**;
- `description_type` is **detected server‑side** from the description (see §7).

A request may only carry the fields below. Any other key (`status`, `description_type`, an
assigned-to, a column) is **silently ignored** — it cannot move the card, assign it, or override
the detected type.

| Field                                               | Type                    | Required (API) | Default             | Constraints / notes                                                                                                                                                                                             |
| --------------------------------------------------- | ----------------------- | -------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| <code style="white-space:nowrap">severity</code>    | enum                    | **yes**        | —                   | `critical \| major \| moderate \| minor`. Set from the method name. Maps to the model's `state`.                                                                                                                |
| <code style="white-space:nowrap">priority</code>    | enum                    | no             | `medium`            | `low \| medium \| high`. Set from the method name.                                                                                                                                                              |
| <code style="white-space:nowrap">title</code>       | string                  | **yes (SDK)**  | derived server‑side | ≤ 255 chars. The SDK always sends a title. If a raw request omits it, the server derives it from the first non‑empty description line, then "Untitled error".                                                   |
| <code style="white-space:nowrap">description</code> | string                  | no             | `null`              | ≤ 65535 chars (server cap; oversized → `422`). The SDKs clamp to **60000** — deliberately under the cap, so a multi-byte encoding can't push the wire size over it.                                             |
| <code style="white-space:nowrap">tags</code>        | array **or** CSV string | no             | `[]`                | Each tag ≤ 50 chars. Accepts **either** an array (`["ui","android"]`) **or** a CSV string (`"ui,android"`); the server normalizes a string into an array (trimmed, de‑duped). Use tags to group related errors. |
| <code style="white-space:nowrap">file_name</code>   | string                  | no             | `null`              | ≤ 255 chars. The source file the error was raised in — the SDKs fill it from the call site when `captureLocation` is on. Shown on the card.                                                                     |
| <code style="white-space:nowrap">line_number</code> | integer                 | no             | `null`              | ≥ 1. The call-site line, likewise from `captureLocation`. Shown alongside `file_name`.                                                                                                                          |

> **What the SDKs actually send.** Only `severity` is required by the _API_, but an SDK always sends
> `severity`, `priority`, `title`, and `tags` (possibly `[]`) on every request — severity and
> priority come from the method name, so they are always valid. The looser server contract is what
> lets a hand-rolled client post the bare minimum.
>
> An SDK is **not** a trust boundary: it clamps client-side, but the server validates every limit
> independently, because a custom client will not clamp anything.

`environment` and `release` are not separate fields — the SDKs fold them into `tags` as
`env:<value>` and `release:<value>`.

### Enums (authoritative values)

```text
severity / state   : critical | major | moderate | minor      (default moderate)
priority           : low | medium | high                       (default medium)
```

> `description_type` (`plain | code | json`) is a **response** field — the server detects it
> (§7); you never send it.

### Example request

```json
{
    "severity": "critical",
    "priority": "high",
    "title": "Checkout failed: gateway timeout",
    "description": "TimeoutError: upstream did not respond within 30s\n    at PaymentGateway.charge (/app/payments.js:88:14)",
    "tags": ["checkout", "stripe"]
}
```

---

## 5. Response

Status `201` for a newly created card, `200` when the report was **deduplicated** into an
existing card. The body is wrapped in `data`, with a sibling `deduplicated` flag.

```json
{
    "data": {
        "id": 4213,
        "title": "Checkout failed: gateway timeout",
        "description": "TimeoutError: upstream did not respond within 30s\n    at …",
        "description_type": "code",
        "priority": "high",
        "severity": "critical",
        "tags": ["checkout", "stripe"],
        "status": "todo",
        "occurrence_count": 1,
        "created_at": "2026-06-13T10:21:55+00:00",
        "last_seen_at": "2026-06-13T10:21:55+00:00"
    },
    "deduplicated": false
}
```

All `data` fields are **snake_case**. Timestamps are ISO‑8601. An ingested card always comes
back in the **`todo`** column (`"status": "todo"`) and unassigned — these are set by the server,
not the request. `description_type` reflects what the server **detected** (§7).

> **A `200` is not always a stored card.** A report can be accepted and then **dropped** — the
> server returns `200` with `{ "dropped": true, "reason": "…" }` and **no `data`** (§6). So a `200`
> means _either_ a deduplicated card (`data` present) _or_ a drop (`dropped` present). `reason` is
> one of `quota`, `paused` or `archived`. `quota_exceeded: true` is emitted alongside `dropped` as
> a **legacy alias**, so SDK versions predating `reason` still know not to retry.

### Hiding the card — `X-Bb-Hide-Response`

A report can carry exactly the data you'd rather not have travelling back to you, and echoing the
stored card into the response would hand a plaintext copy straight back to a client that may have
gone to the trouble of **encrypting** the request (§11). So the SDKs send
`X-Bb-Hide-Response: true` **by default** (`hideApiResponse`), and the server then **omits `data`
entirely**:

```json
{ "deduplicated": true }
```

The **outcome flags survive**. A hidden response carries no card, but the relevant outcome flag is
still returned — `deduplicated` on a created or deduplicated `2xx` (`false` then `true`
respectively), or `dropped` + `reason` (plus the `quota_exceeded` alias) on a drop. The two never
coexist: **a drop body omits `deduplicated`**, so a client should treat its absence as `false` and
base its no‑retry decision on `dropped` / `quota_exceeded`. The SDKs read these flags to decide
whether to retry, so hiding the card must never hide them. Status codes are unchanged: `201`
created, `200` deduplicated.

Set `hideApiResponse: false` (or simply omit the header on a raw request) when you want the card
back — handy for a smoke test, where seeing what got stored is the entire point.

### Deduplication (important for SDK design)

A report is folded into an existing card in the same project when **either** its `title` exactly
matches that card's title, **or** its `description` exactly matches that card's description. The two
are checked independently — matching _either one_ is enough. (A blank description never matches; a
report with no description groups on its title alone.)

On a match the server **does not create a duplicate**. It increments `occurrence_count`, refreshes
`last_seen_at`, merges any newly reported `tags`, and returns `200` with `"deduplicated": true`.

The card's own `title` and `description` are **never rewritten** — they are frozen as the
_representative_ payload, taken from the first report that created the card. That matters because
they are themselves the dedupe keys: rewriting them would silently change what future reports group
onto.

#### The payload isn't thrown away — it becomes a variant

Because matching on _either_ field is enough, grouped reports are usually **not identical**. The
classic case is a stable title with a description that carries per-event data:

```json
{ "severity": "critical", "title": "Charge failed", "description": "card_declined: ch_1AAA ($42.00)" }
{ "severity": "critical", "title": "Charge failed", "description": "card_declined: ch_2BBB ($9.99)" }
```

Both land on one card (the titles match). If the differing descriptions were simply discarded, the
card would be useless for diagnosis — you'd know the error happened 4,000 times and see exactly one
example of it. So instead, **every distinct payload is retained beneath the card as a variant**,
each with its own count, first-seen and last-seen time, browsable in the app.

A variant is identified by the combination of **`title` + `description` + `file_name` +
`line_number`**. Two reports differing in _any_ of those four are two variants; reports matching on
all four are the same variant and just bump its count.

| Behaviour         | Detail                                                                                                                                                                                                                                   |
| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Variant identity  | `title` + `description` + `file_name` + `line_number`                                                                                                                                                                                    |
| Stored body       | The **full** reported `description` is kept, up to the API's own 65535-char limit — a variant shows you the exact payload that was reported, never an abridged one. Two reports differing _anywhere_ in the description are two variants |
| How many are kept | A card keeps a bounded number of distinct variants (**20** by default)                                                                                                                                                                   |
| Past that         | Further novel payloads fold into a single **"Other variants"** row, which counts them without storing each body                                                                                                                          |
| Eviction          | When a card is over the cap, the **least recently seen** variants are pruned back down to it; the "Other variants" row is exempt                                                                                                         |

The cap is what makes this safe under a report storm. A caller whose descriptions embed a timestamp
or a UUID makes _every_ event a novel payload; without a ceiling that would be one stored row per
event. With it, the first 20 distinct shapes are kept and the rest are counted in aggregate.

Consequences to design for:

- **Use stable, deterministic titles.** Name the _bug_, not the _occurrence_: the exception class
  and message, not a title with a timestamp, UUID, or interpolated id in it. A title that varies per
  event never deduplicates, and every event becomes its own card.
- **Per-event detail belongs in the `description`, not the title.** That's the field variants are
  built to preserve. A stable title plus a varying description gives you one card, an accurate
  occurrence count, and the individual payloads still available underneath.
- **`occurrence_count` counts every report**, including ones that folded into "Other variants". It
  is not the number of variants.
- **Retries are safe.** If a request succeeds but the SDK never sees the response (a dropped
  connection) and retries, the retry deduplicates rather than creating a second card — worst case
  the occurrence count is off by one. There is no idempotency key, and none is needed.

---

## 6. Errors, rate limits & quota

All `/api/*` responses are JSON (errors included).

| Status | Meaning                                                  | Body shape                                             |
| ------ | -------------------------------------------------------- | ------------------------------------------------------ |
| `401`  | Missing/invalid key, bad signature, or expired timestamp | `{ "message": "Invalid or missing project API key." }` |
| `403`  | Publishable key used from a disallowed origin            | `{ "message": "Origin not allowed." }`                 |
| `422`  | Validation failure (or the project has no board column)  | `{ "message": "...", "errors": { "field": ["..."] } }` |
| `429`  | Per‑minute burst limit exceeded                          | `{ "message": "..." }` + headers below                 |
| `5xx`  | Server error                                             | `{ "message": "..." }`                                 |

> **Not in the table:** a **dropped** report returns `200` with `{ "dropped": true, "reason": "…" }`
> (no `data`), not an error — it is silently discarded. A report is dropped when the account's
> event quota is spent (`quota`), or when the project is **paused** or **archived** and so accepts
> no events at all. See _Two independent abuse controls_ below.

### Two independent abuse controls

Ingestion is capped by **two separate layers**, bucketed **differently**, and behaving
**differently** when you go over. The **burst limit is per project**; the **event quota is per
account**, shared by every project you own.

**1. Per‑minute burst limit** — smooths spikes (a bad deploy that makes thousands of users error at
once). Bucketed **per project** (not per IP, not per key), so one noisy project cannot eat another's
budget. Scaled by the project's plan:

| Plan | Burst (events / minute, per project) |
| --- | --- |
| Hobby | 60 |
| Indie | 100 |
| Professional | 200 |
| Team | 500 |

Every response carries `X-RateLimit-Limit` and `X-RateLimit-Remaining`. Going over returns
`429` with a `Retry-After` of a **small number of seconds** — back off and retry.

**2. Event quota** — the hard cap on total volume / cost. It belongs to your **account**, not to
any one project: **every project you own draws on the same pool.** Scaled by plan:

| Plan | Daily events (across all projects) |
| --- | --- |
| Hobby | 300 |
| Indie | 1,000 |
| Professional | 3,000 |
| Team | 20,000 |

The allowance is measured over a **day**, which bounds what a runaway loop can cost.
Exhausting it does **not** error: the server returns `200` with
`{ "dropped": true, "reason": "quota" }` (no `data`) and **silently drops** the report — a
monitoring SDK should never surface a billing limit as a failure in the app it watches. There is
**no `Retry-After`**; the quota resets at midnight UTC. **Do not retry** a dropped report, and
don't expect a card on the board for it. The project's owner, admins and members are emailed at
**75%, 90% and 100%** of the allowance, each warning naming the task cards drawing the most
events.

Because the pool is shared, no single project may consume more than **80%** of it once an account
has more than one active project — a containment ceiling that stops one leaked key from silencing
every other project on the account. An account with a single project is not subject to it.

**Only an active project accepts events.** A **paused** or **archived** project is read-only, and
that applies here exactly as it does in the dashboard: it returns `200` with
`{ "dropped": true, "reason": "paused" }` (or `"archived"`), creates no card, and **spends none of
your quota**. Pausing is reversible — resume the project and ingestion resumes with it.

> **Designing for bursts (every SDK):** errors are bursty, so (a) **honor `Retry-After`** and back
> off, (b) **sample** under load (`sampleRate < 1`) so a spike sends a representative fraction, (c)
> keep **bounded concurrency** so you never self‑inflict the burst limit, and (d) rely on server
> **deduplication** (§5) to collapse repeats. There is no bulk endpoint; smooth volume with the
> queue, sampling, and backoff.

### Error → exception mapping (implement in every SDK)

| HTTP                    | JavaScript                | PHP                   | Carries                                    | Retried? |
| ----------------------- | ------------------------- | --------------------- | ------------------------------------------ | -------- |
| 401, 403                | `BugBoardAuthError`       | `AuthException`       | message                                    | no       |
| 422                     | `BugBoardValidationError` | `ValidationException` | `fieldErrors` (the `errors` map)           | no       |
| 429                     | `BugBoardRateLimitError`  | `RateLimitException`  | `retryAfter` (seconds, from `Retry-After`) | **yes**  |
| other 4xx               | `BugBoardServerError`     | `ServerException`     | message                                    | no       |
| 5xx / network / timeout | `BugBoardServerError`     | `ServerException`     | message, underlying cause                  | **yes**  |

Error text is read from the JSON `message` key; if it is absent the SDKs fall back to
`HTTP {status}`.

**Only `429`, `5xx`, and network failures are retried.** Everything else is a config or payload bug
and is dropped immediately — so a server must **never use a 4xx for a transient condition**, or the
report is discarded rather than retried.

Backoff: `Retry-After` is honored **verbatim, in seconds**, and takes precedence over the SDK's own
schedule; a non‑numeric value is ignored. Otherwise the SDKs back off exponentially with equal
jitter from 500 ms, capped at 30 s, up to `maxRetries` (default 3).

> Because reporting is **fire‑and‑forget** by default (see §8), these exceptions are surfaced
> through the SDK's internal logger/`debug` channel, **not** thrown into the host app's call
> stack. A monitoring SDK must never crash the app it monitors. A `200` carrying
> `{ "dropped": true }` is **not** an error — it maps to no exception, but the report went nowhere,
> so don't retry it, whatever the `reason` (see _Event quota_ above).

---

## 7. `description_type` is detected server‑side

You never pass `description_type` — the **server** derives it from the description content and
returns it on the card. Clients can't override it; a `description_type` in the request body is
ignored. The detection is purely content‑based:

1. Empty description → **`plain`**.
2. Trimmed text starts `{`…`}` or `[`…`]` **and** parses as valid JSON → **`json`**.
3. Looks like a stack trace / source location (an `at` frame on its own line, or a
   `file:line:col` pattern) → **`code`**.
4. Otherwise → **`plain`**.

```text
detectDescriptionType(description):
    if description is empty:            return "plain"
    t = description.trim()
    if (t starts "{" and ends "}") or (t starts "[" and ends "]"):
        if parses as JSON:             return "json"
    if t matches /\n\s+at\s/  or  t matches /[^\s:]+:\d+:\d+/:
                                        return "code"
    return "plain"
```

---

## 8. Shared design principles (every SDK implements these)

### Public API surface

No `createCard`, no generic `report()`. The reporting surface is exactly **16 severity/priority
methods** — nothing else reports a card. Alongside them, a client exposes only lifecycle helpers:
`flush()` (drain the queue now; see the language guides), and in PHP `droppedCount()` (how many
reports overflow has dropped).

**Named methods** (severity + priority encoded in the name):

|              | low           | medium (default)              | high           |
| ------------ | ------------- | ----------------------------- | -------------- |
| **critical** | `criticalLow` | `critical` / `criticalMedium` | `criticalHigh` |
| **major**    | `majorLow`    | `major` / `majorMedium`       | `majorHigh`    |
| **moderate** | `moderateLow` | `moderate` / `moderateMedium` | `moderateHigh` |
| **minor**    | `minorLow`    | `minor` / `minorMedium`       | `minorHigh`    |

- Casing is camelCase: `criticalHigh`, `minorLow`, etc.
- A bare `severity` name (e.g. `critical`) is shorthand for `severityMedium`.

**One call form** for every named method — positional:

```text
name(title, description?, tags?)
```

Rules:

- `title` is **required** and is always a `string`.
- `description` is optional: a `string`, or an error object (`Error` / `Throwable` /
  `Exception`) whose message + stack the SDK extracts into the description text.
- `tags` is optional — an array (`["ui","android"]`) or a CSV string (`"ui,android"`).
- The **method name** sets `severity` + `priority`; you never pass those.
- The SDK does **not** accept `status`, an assigned-to, or `description_type` — the server fixes
  the column to `todo`, leaves the card unassigned, and detects the type (§7).

### Security

- Server SDKs: **secret** key from `BUGBOARD_KEY_ID` + `BUGBOARD_SIGNING_SECRET`; never hardcode,
  never log them (redact in debug output). Browser SDKs embed a **publishable** key per §3 with the
  documented mitigations.
- Provide a **`beforeSend(payload)`** hook so apps can scrub PII or drop a report
  (return `null`/`None` to drop).
- **Clamp to API limits** before sending: `title` ≤ 255, each tag ≤ 50;
  truncate oversized `description`.
- The `severity`/`priority` an SDK sends come from the method name, so they are always valid —
  there is no user‑supplied enum to validate before the call.

### Performance & reliability

- **Non‑blocking & crash‑safe.** Reporting returns immediately; work happens on a background
  queue/worker. The SDK must never throw into, or crash, the host app.
- **Bounded queue** (`maxQueueSize`, default 100) with an explicit **drop policy** on overflow
  (drop newest + count drops in debug).
- **Persistent HTTP client** (keep‑alive / connection pooling) reused across reports.
- **Per‑request timeout** (default 5 s).
- **Retry** on `429`, `5xx`, and network errors with **exponential backoff + jitter**, capped
  (default 3 attempts), and **honor `Retry-After`**. Never retry `4xx` other than `429`.
- **Bounded concurrency** when draining the queue (default 3) to stay well under the per‑minute
  burst limit (§6) and avoid self‑inflicted `429`s.
- **Sampling** via `sampleRate` (0–1, default 1).
- **Graceful‑shutdown flush** so buffered reports aren't lost: Node `beforeExit`, PHP
  `register_shutdown_function`.

### DX

- Typed config with sane defaults and an `enabled` flag (default `true`).
- `environment` and `release` config values are folded into `tags` automatically
  (e.g. `env:production`, `release:1.4.2`).
- Generate the 16 methods from the severity×priority table — don't hand‑write
  16 near‑identical methods — but expose typed declarations so editors autocomplete them.
- `logLocally` (default `false`) turns the SDK into a **log-only dry run**: reports flow through
  the full pipeline (sampling, `beforeSend`, tag/title/description building) but are logged
  locally instead of sent, so developers can debug their instrumentation without creating cards.
  The log point is the top of the transport's `send` — the readable payload is logged before
  serialization/encryption — and it is emitted even when `debug` is off.

---

## 9. Config reference (names are identical across SDKs)

| Option                                                      | Type     | Default         | Purpose                                                                                                                                                                   |
| ----------------------------------------------------------- | -------- | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| <code style="white-space:nowrap">apiKey</code>              | string   | — (publishable) | `bb_pub_…` token for browser/mobile SDKs (bearer auth).                                                                                                                   |
| <code style="white-space:nowrap">keyId</code>               | string   | — (secret)      | `bbk_…` public key id for server SDKs (HMAC auth).                                                                                                                        |
| <code style="white-space:nowrap">signingSecret</code>       | string   | — (secret)      | `bb_sec_…` secret used to sign; never transmitted.                                                                                                                        |
| <code style="white-space:nowrap">encryptionPublicKey</code> | string   | none            | Project encryption **public key** (`base64`). When set, the SDK encrypts every payload (§11).                                                                             |
| <code style="white-space:nowrap">encryptionKeyId</code>     | string   | none            | The `bbek_…` id of the encryption key, echoed in the envelope so the server opens it with the right key (enables rotation).                                               |
| <code style="white-space:nowrap">enabled</code>             | bool     | `true`          | Master switch (e.g. disable in tests/dev).                                                                                                                                |
| <code style="white-space:nowrap">environment</code>         | string   | none            | Added as tag `env:<value>`.                                                                                                                                               |
| <code style="white-space:nowrap">release</code>             | string   | none            | Added as tag `release:<value>`.                                                                                                                                           |
| <code style="white-space:nowrap">defaultTags</code>         | string[] | `[]`            | Merged into every card's tags.                                                                                                                                            |
| <code style="white-space:nowrap">sampleRate</code>          | float    | `1.0`           | Probability a report is sent (0–1).                                                                                                                                       |
| <code style="white-space:nowrap">hideApiResponse</code>     | bool     | **`true`**      | Send `X-Bb-Hide-Response`, so the server withholds the created card from the response body (§5). On by default: a report you encrypted should not come back in plaintext. |
| <code style="white-space:nowrap">captureLocation</code>     | bool     | `true`          | Attach the call site as `file_name` + `line_number` (§4).                                                                                                                 |
| <code style="white-space:nowrap">maxQueueSize</code>        | int      | `100`           | Buffer cap; overflow drops newest.                                                                                                                                        |
| <code style="white-space:nowrap">concurrency</code>         | int      | `3`             | Parallel in‑flight requests when draining (PHP accepts it for parity but delivers sequentially).                                                                          |
| <code style="white-space:nowrap">flushIntervalMs</code>     | int      | `2000`          | Background drain cadence (PHP accepts it for parity; it flushes on shutdown / via `flush()`).                                                                             |
| <code style="white-space:nowrap">timeoutMs</code>           | int      | `5000`          | Per‑request timeout.                                                                                                                                                      |
| <code style="white-space:nowrap">maxRetries</code>          | int      | `3`             | Retry attempts for `429`/`5xx`/network.                                                                                                                                   |
| <code style="white-space:nowrap">beforeSend</code>          | fn       | none            | Mutate/scrub a payload, or return `null` to drop.                                                                                                                         |
| <code style="white-space:nowrap">debug</code>               | bool     | `false`         | Verbose internal logging (key always redacted).                                                                                                                           |
| <code style="white-space:nowrap">logLocally</code>          | bool     | `false`         | Log each report locally instead of sending it (local debugging / dry run). Emitted even when `debug` is off.                                                              |

> Provide **either** `apiKey` (publishable) **or** `keyId` + `signingSecret` (secret) — the SDK
> picks the bearer or HMAC scheme automatically from which is set. With no credentials the client
> disables itself with a warning instead of throwing, so a misconfigured SDK never sends an
> unauthenticated report.

### Environment variable conventions

Servers use a **secret** key (key id + signing secret); browser/mobile clients embed a
**publishable** key.

| Context                        | Variables                                                                                                         |
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------- |
| Server (Node/PHP/Laravel/…)    | `BUGBOARD_KEY_ID`, `BUGBOARD_SIGNING_SECRET`                                                                      |
| Next.js (client, embedded)     | `NEXT_PUBLIC_BUGBOARD_API_KEY`                                                                                    |
| Vite (React/Vue/Svelte client) | `VITE_BUGBOARD_API_KEY`                                                                                           |
| Nuxt                           | `runtimeConfig.bugboardKeyId` + `bugboardSigningSecret` (server) / `runtimeConfig.public.bugboardApiKey` (client) |

> The `*_API_KEY` / `NEXT_PUBLIC_*` / `VITE_*` client variables hold a **`bb_pub_…`** token (safe to
> expose). The server `BUGBOARD_SIGNING_SECRET` holds a **`bb_sec_…`** secret (never expose).

---

## 10. Quick verification (any SDK)

A first `POST` returns `201`; an identical second `POST` returns `200` with
`"deduplicated": true` and a bumped `occurrence_count`.

These examples deliberately **omit** `X-Bb-Hide-Response`, so the stored card comes back in `data` —
on a smoke test, seeing what got stored is the whole point. Add `-H 'X-Bb-Hide-Response: true'` to
see what the SDKs get by default: the same status, the same flags, no card.

### Publishable key (bearer)

```bash
curl -i -X POST "https://bugboard.dev/api/v1/tasks" \
  -H "Authorization: Bearer $BUGBOARD_PUBLISHABLE_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d '{"severity":"major","title":"SDK smoke test"}'
```

> If the key has an origin allow‑list, add a matching `-H "Origin: https://your-allowed-origin"`.

### Secret key (HMAC signature)

The body that is hashed must be byte‑identical to the body that is sent:

```bash
BODY='{"severity":"major","title":"SDK smoke test"}'
TS=$(date +%s)
BODY_HASH=$(printf '%s' "$BODY" | openssl dgst -sha256 | awk '{print $NF}')
SIG=$(printf '%s' "$TS.POST./api/v1/tasks.$BODY_HASH" | openssl dgst -sha256 -hmac "$BUGBOARD_SIGNING_SECRET" | awk '{print $NF}')

curl -i -X POST "https://bugboard.dev/api/v1/tasks" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -H "X-Bb-Key-Id: $BUGBOARD_KEY_ID" \
  -H "X-Bb-Timestamp: $TS" \
  -H "X-Bb-Signature: $SIG" \
  --data-raw "$BODY"
```

---

## 11. Encrypted payloads (optional)

A report body can carry genuinely sensitive data — user emails, ids, and code
blocks. TLS hides it from passive sniffers, but it is still readable in the
**browser DevTools network tab**, at any TLS‑terminating proxy/CDN, and in
access logs. To close that gap, the SDK can **encrypt the body** before it
leaves the client so it is an opaque blob everywhere on the wire. BugBoard
decrypts it on receipt and then dedupes, stores, and displays the card exactly
as for a plaintext report.

> This protects the payload **in transit**. It is **not zero‑knowledge** —
> BugBoard decrypts the report to build the card. It composes with both key
> types and is the recommended choice for browser SDKs, where the network tab
> exposes the request body.

### 11.1 The encryption key

Each project can hold one or more **encryption keys** — an X25519 keypair,
separate from your API keys. Generate one in **Settings → API Keys → Payload
encryption**. You get:

- a **public key** (`base64`, 32 bytes) — used to encrypt; **safe to embed**
  in client code exactly like a publishable key;
- a **key id** (`bbek_…`) — public; tells the server which key to decrypt with;
- the **private key** never leaves BugBoard.

Configure the SDK with `encryptionPublicKey` (and optionally `encryptionKeyId`).
When `encryptionPublicKey` is set, **every** report is encrypted.

### 11.2 The envelope

Instead of the plaintext body (§4), the SDK transmits:

```json
{
    "encrypted": {
        "v": 1,
        "alg": "x25519-sealedbox",
        "key_id": "bbek_…",
        "ciphertext": "<base64( sealed_box( utf8_json_of_normal_body ) )>"
    }
}
```

- The plaintext sealed inside is the **exact same object** you would otherwise
  POST (the §4 body).
- `key_id` is optional; if omitted the server uses the project's most recent
  active key.
- **Auto‑detected:** the server treats a body as encrypted only when it has a
  top‑level `encrypted` object with a `ciphertext` string. Anything else is a
  plaintext report, so a project can mix encrypted and plaintext clients.

### 11.3 Sealing algorithm — port verbatim into every SDK

Uses a libsodium **sealed box** (`crypto_box_seal`, X25519 + XSalsa20‑Poly1305):
anonymous public‑key encryption with a fresh ephemeral key per message. No
shared secret is needed, so it is safe in the browser.

```text
seal(body, encryptionPublicKey, encryptionKeyId):
    plaintext  = utf8( json_encode(body) )          # the normal §4 body
    pk         = base64_decode(encryptionPublicKey) # 32-byte X25519 public key
    sealed     = crypto_box_seal(plaintext, pk)     # libsodium sealed box
    return {
        "encrypted": {
            "v": 1,
            "alg": "x25519-sealedbox",
            "key_id": encryptionKeyId,               # omit if not configured
            "ciphertext": base64_encode(sealed)
        }
    }
```

Bindings: JS `tweetnacl-sealedbox-js` (`crypto_box_seal`-compatible), PHP `sodium_crypto_box_seal`.

### 11.4 Encryption + authentication compose

Encryption changes the **body**, not the auth. Apply auth to the bytes you
actually transmit:

- **Publishable (bearer):** unchanged — send the same `Authorization` header.
  The bearer token is still visible in the network tab; the **body** is not.
- **Secret (HMAC):** sign the **envelope** you transmit. `body` in the signing
  payload (§3.2) is the raw envelope JSON, so encrypt **first**, then sign the
  result.

A failure to decrypt (missing/revoked key, tampered ciphertext) is a `422` with
`{ "message": "Could not decrypt the request payload." }`.

### 11.5 Quick verification (encrypted, publishable key)

```bash
PUBLIC_KEY="<your base64 encryption public key>"
BODY='{"severity":"major","title":"Encrypted smoke test"}'

# Seal the body with libsodium, then wrap it in the envelope.
CIPHERTEXT=$(php -r '
  $pk = base64_decode($argv[1]);
  echo base64_encode(sodium_crypto_box_seal($argv[2], $pk));
' "$PUBLIC_KEY" "$BODY")

curl -i -X POST "https://bugboard.dev/api/v1/tasks" \
  -H "Authorization: Bearer $BUGBOARD_PUBLISHABLE_KEY" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json" \
  -d "{\"encrypted\":{\"v\":1,\"alg\":\"x25519-sealedbox\",\"ciphertext\":\"$CIPHERTEXT\"}}"
```

---

Now read your language guide: [javascript.md](javascript.md) · [php.md](php.md).
