BugBoardDocs

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

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:

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:

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

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
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
severity enum yes critical | major | moderate | minor. Set from the method name. Maps to the model's state.
priority enum no medium low | medium | high. Set from the method name.
title 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".
description 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.
tags 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.
file_name 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.
line_number 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)

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

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

{
    "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:

{ "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:

{ "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.
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:

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 429s.
  • 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
apiKey string — (publishable) bb_pub_… token for browser/mobile SDKs (bearer auth).
keyId string — (secret) bbk_… public key id for server SDKs (HMAC auth).
signingSecret string — (secret) bb_sec_… secret used to sign; never transmitted.
encryptionPublicKey string none Project encryption public key (base64). When set, the SDK encrypts every payload (§11).
encryptionKeyId string none The bbek_… id of the encryption key, echoed in the envelope so the server opens it with the right key (enables rotation).
enabled bool true Master switch (e.g. disable in tests/dev).
environment string none Added as tag env:<value>.
release string none Added as tag release:<value>.
defaultTags string[] [] Merged into every card's tags.
sampleRate float 1.0 Probability a report is sent (0–1).
hideApiResponse 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.
captureLocation bool true Attach the call site as file_name + line_number (§4).
maxQueueSize int 100 Buffer cap; overflow drops newest.
concurrency int 3 Parallel in‑flight requests when draining (PHP accepts it for parity but delivers sequentially).
flushIntervalMs int 2000 Background drain cadence (PHP accepts it for parity; it flushes on shutdown / via flush()).
timeoutMs int 5000 Per‑request timeout.
maxRetries int 3 Retry attempts for 429/5xx/network.
beforeSend fn none Mutate/scrub a payload, or return null to drop.
debug bool false Verbose internal logging (key always redacted).
logLocally 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)

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:

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:

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

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)

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 · php.md.