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
POSTtasks 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‑casepostproduces 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-Afteris not returned for a bad signature — it's a hard401. - 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
- Open your project → Settings → API Keys (requires the
manageApiKeyability). - Create key, choose Publishable (browser/mobile) or Secret (server), name it, and for publishable keys optionally add allowed origins.
- 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.
- 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 — butcurl/a script can send anyOrigin, so it does not stop a determined attacker. Notehttps://bugboard.devandhttps://www.bugboard.devare 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
todocolumn; - it is created unassigned;
description_typeis 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
severityis required by the API, but an SDK always sendsseverity,priority,title, andtags(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
200is not always a stored card. A report can be accepted and then dropped — the server returns200with{ "dropped": true, "reason": "…" }and nodata(§6). So a200means either a deduplicated card (datapresent) or a drop (droppedpresent).reasonis one ofquota,pausedorarchived.quota_exceeded: trueis emitted alongsidedroppedas a legacy alias, so SDK versions predatingreasonstill 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_countcounts 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
200with{ "dropped": true, "reason": "…" }(nodata), 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-Afterand 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/
debugchannel, not thrown into the host app's call stack. A monitoring SDK must never crash the app it monitors. A200carrying{ "dropped": true }is not an error — it maps to no exception, but the report went nowhere, so don't retry it, whatever thereason(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:
- Empty description →
plain. - Trimmed text starts
{…}or[…]and parses as valid JSON →json. - Looks like a stack trace / source location (an
atframe on its own line, or afile:line:colpattern) →code. - 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
severityname (e.g.critical) is shorthand forseverityMedium.
One call form for every named method — positional:
name(title, description?, tags?)
Rules:
titleis required and is always astring.descriptionis optional: astring, or an error object (Error/Throwable/Exception) whose message + stack the SDK extracts into the description text.tagsis 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, ordescription_type— the server fixes the column totodo, 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 (returnnull/Noneto drop). - Clamp to API limits before sending:
title≤ 255, each tag ≤ 50; truncate oversizeddescription. - The
severity/priorityan 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 honorRetry-After. Never retry4xxother than429. - 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, PHPregister_shutdown_function.
DX
- Typed config with sane defaults and an
enabledflag (defaulttrue). environmentandreleaseconfig values are folded intotagsautomatically (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(defaultfalse) 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'ssend— the readable payload is logged before serialization/encryption — and it is emitted even whendebugis 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) orkeyId+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 abb_pub_…token (safe to expose). The serverBUGBOARD_SIGNING_SECRETholds abb_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_idis 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
encryptedobject with aciphertextstring. 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
Authorizationheader. The bearer token is still visible in the network tab; the body is not. - Secret (HMAC): sign the envelope you transmit.
bodyin 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.