Custom Implementation (one file, no dependency)
Don't want a package in your dependency tree? Copy one self-contained file into your app and report errors against the same endpoint the official SDKs use. It speaks the API correctly — with nothing to install — but it is not a full replacement for the SDK (more on that just below).
⚠️ This is not a one-to-one replacement for the SDK. This single file gets the essentials right — all 16 severity methods, the correct endpoint and auth, fire-and-forget delivery, and basic retry — but it only covers what matters most for talking to the API correctly. It will never fully replace the SDK: it intentionally drops the background queue, sampling,
beforeSend, bounded concurrency, and shutdown flush — see What this leaves out. When you need those, installbugboardorbugboard/sdkinstead.
1. Get a key
Where the code runs decides which key type you need (full detail in Choosing a key type and Authentication):
- Client platforms — browser, React Native — embed a publishable key (
bb_pub_…), sent as a bearer token. Public by design, so pair it with an origin allow-list and your project quota. - Servers — Node.js, PHP — use a secret key: a key id (
bbk_…) plus a signing secret (bb_sec_…). The secret never leaves your server; each request is HMAC-signed.
2. Copy the file
Pick your platform. This is the only file you need — drop it in and you're done.
// bugboard.ts — single-file BugBoard client (browser). No dependencies. const ENDPOINT = 'https://bugboard.dev/api/v1/tasks'; const API_KEY = import.meta.env.VITE_BUGBOARD_API_KEY; // bb_pub_… const SEVERITIES = ['critical', 'major', 'moderate', 'minor'] as const; const PRIORITIES = { Low: 'low', '': 'medium', Medium: 'medium', High: 'high', } as const; type Severity = (typeof SEVERITIES)[number]; type Priority = 'low' | 'medium' | 'high'; type Outcome = 'created' | 'deduplicated' | 'dropped'; type Reporter = ( title: string, description?: unknown, tags?: string[] | string, ) => Promise<Outcome | undefined>; function describe(description?: unknown): string | undefined { if (description == null) return undefined; let text: string; if (description instanceof Error) { text = `${description.message}\n${description.stack ?? ''}`.trim(); } else if (typeof description === 'string') { text = description; } else { try { text = JSON.stringify(description, null, 2); // objects/arrays → readable JSON } catch { text = String(description); // fall back for cycles/bigint/etc. } } return text.slice(0, 60000); // under the server's 65535 cap, so multi-byte can't overflow it } function normalizeTags(tags?: string[] | string): string[] { const list = Array.isArray(tags) ? tags : typeof tags === 'string' ? tags.split(',') : []; return [ ...new Set( list .map((t) => `${t}`.trim()) .filter(Boolean) .map((t) => t.slice(0, 50)), ), ]; } async function send( severity: Severity, priority: Priority, title: string, description?: unknown, tags?: string[] | string, ): Promise<Outcome | undefined> { const body = JSON.stringify({ severity, priority, title: `${title}`.slice(0, 255), description: describe(description), tags: normalizeTags(tags), }); for (let attempt = 0; attempt <= 3; attempt++) { try { const res = await fetch(ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-Bb-Hide-Response': 'true', // don't echo the report back to us Authorization: `Bearer ${API_KEY}`, }, body, keepalive: true, }); if (res.ok) { // A drop is a 2xx: the server accepted then discarded the report. Never retry it. const data: Record<string, unknown> = await res .json() .catch(() => ({})); if (data.dropped === true || data.quota_exceeded === true) return 'dropped'; if (data.deduplicated === true) return 'deduplicated'; return 'created'; } // A non-retryable client error (bad key/payload) — stop. if (res.status >= 400 && res.status < 500 && res.status !== 429) return undefined; await backoff(attempt, Number(res.headers.get('Retry-After'))); } catch { await backoff(attempt); // network error — retry } } return undefined; } // Exponential backoff with equal jitter, capped at 30s; honors Retry-After when present. function backoff(attempt: number, retryAfterSeconds = 0): Promise<void> { const cap = Math.min(500 * 2 ** attempt, 30000); const wait = retryAfterSeconds > 0 ? retryAfterSeconds * 1000 : cap / 2 + Math.random() * (cap / 2); return new Promise((r) => setTimeout(r, wait)); } // Generate the 16 severity/priority methods. `critical` == `criticalMedium`. const bugboard = {} as Record<string, Reporter>; for (const severity of SEVERITIES) { for (const [suffix, priority] of Object.entries(PRIORITIES) as [ string, Priority, ][]) { bugboard[`${severity}${suffix}`] = (title, description, tags) => send(severity, priority, title, description, tags).catch( () => undefined, // never throw into the host app ); } } export default bugboard;
// bugboard.js — single-file BugBoard client (Node 20+). No dependencies. const crypto = require('crypto'); const ENDPOINT = 'https://bugboard.dev/api/v1/tasks'; const PATH = '/api/v1/tasks'; const KEY_ID = process.env.BUGBOARD_KEY_ID; // bbk_… const SIGNING_SECRET = process.env.BUGBOARD_SIGNING_SECRET; // bb_sec_… const SEVERITIES = ['critical', 'major', 'moderate', 'minor']; const PRIORITIES = { Low: 'low', '': 'medium', Medium: 'medium', High: 'high' }; function describe(description) { if (description == null) return undefined; let text; if (description instanceof Error) { text = `${description.message}\n${description.stack ?? ''}`.trim(); } else if (typeof description === 'string') { text = description; } else { try { text = JSON.stringify(description, null, 2); // objects/arrays → readable JSON } catch { text = String(description); // fall back for cycles/bigint/etc. } } return text.slice(0, 60000); // under the server's 65535 cap, so multi-byte can't overflow it } function normalizeTags(tags) { const list = Array.isArray(tags) ? tags : typeof tags === 'string' ? tags.split(',') : []; return [ ...new Set( list .map((t) => `${t}`.trim()) .filter(Boolean) .map((t) => t.slice(0, 50)), ), ]; } // HMAC-sign the exact bytes we transmit. Re-signed per attempt to keep the timestamp fresh. // Two rules the server enforces: the timestamp is integer seconds, the signature is lowercase hex. function sign(body) { const timestamp = Math.floor(Date.now() / 1000).toString(); const bodyHash = crypto.createHash('sha256').update(body).digest('hex'); const signature = crypto .createHmac('sha256', SIGNING_SECRET) .update(`${timestamp}.POST.${PATH}.${bodyHash}`) .digest('hex'); return { 'X-Bb-Key-Id': KEY_ID, 'X-Bb-Timestamp': timestamp, 'X-Bb-Signature': signature, }; } async function deliver(severity, priority, title, description, tags) { const body = JSON.stringify({ severity, priority, title: `${title}`.slice(0, 255), description: describe(description), tags: normalizeTags(tags), }); for (let attempt = 0; attempt <= 3; attempt++) { try { const res = await fetch(ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-Bb-Hide-Response': 'true', // not covered by the signature — a header, not body ...sign(body), }, body, }); if (res.ok) { // A drop is a 2xx: the server accepted then discarded the report. Never retry it. const data = await res.json().catch(() => ({})); if (data.dropped === true || data.quota_exceeded === true) return 'dropped'; if (data.deduplicated === true) return 'deduplicated'; return 'created'; } if (res.status >= 400 && res.status < 500 && res.status !== 429) return undefined; await backoff(attempt, Number(res.headers.get('retry-after'))); } catch { await backoff(attempt); } } return undefined; } // Exponential backoff with equal jitter, capped at 30s; honors Retry-After when present. function backoff(attempt, retryAfterSeconds = 0) { const cap = Math.min(500 * 2 ** attempt, 30000); const wait = retryAfterSeconds > 0 ? retryAfterSeconds * 1000 : cap / 2 + Math.random() * (cap / 2); return new Promise((r) => setTimeout(r, wait)); } const bugboard = {}; for (const severity of SEVERITIES) { for (const [suffix, priority] of Object.entries(PRIORITIES)) { bugboard[`${severity}${suffix}`] = (title, description, tags) => deliver(severity, priority, title, description, tags).catch( () => undefined, ); } } module.exports = bugboard;
<?php // BugBoard.php — single-file BugBoard client (PHP 8.2+). No dependencies. final class BugBoard { private const ENDPOINT = 'https://bugboard.dev/api/v1/tasks'; private const PATH = '/api/v1/tasks'; private const SEVERITIES = ['critical', 'major', 'moderate', 'minor']; private const PRIORITIES = ['Low' => 'low', '' => 'medium', 'Medium' => 'medium', 'High' => 'high']; /** * @param array<int, mixed> $args [title, description?, tags?] * @return string|null 'created' | 'deduplicated' | 'dropped', or null when nothing was delivered */ public static function __callStatic(string $method, array $args): ?string { foreach (self::SEVERITIES as $severity) { foreach (self::PRIORITIES as $suffix => $priority) { if ($method === $severity.$suffix) { return self::deliver($severity, $priority, ...$args); } } } return null; } /** @param array<int, string>|string $tags */ private static function deliver(string $severity, string $priority, string $title, mixed $description = null, array|string $tags = []): ?string { $body = json_encode(array_filter([ 'severity' => $severity, 'priority' => $priority, 'title' => mb_substr($title, 0, 255), 'description' => self::describe($description), 'tags' => self::normalizeTags($tags), ], static fn ($value): bool => $value !== null), JSON_UNESCAPED_SLASHES); // OPTIONAL — seal the body in transit. Uncomment and set a base64 X25519 public key. // $body = self::encrypt($body, (string) getenv('BUGBOARD_ENCRYPTION_PUBLIC_KEY'), getenv('BUGBOARD_ENCRYPTION_KEY_ID') ?: null); for ($attempt = 0; $attempt <= 3; $attempt++) { [$status, $retryAfter, $data] = self::post($body); if ($status >= 200 && $status < 300) { // A drop is a 2xx: the server accepted then discarded the report. Never retry it. if (($data['dropped'] ?? false) === true || ($data['quota_exceeded'] ?? false) === true) { return 'dropped'; } return ($data['deduplicated'] ?? false) === true ? 'deduplicated' : 'created'; } if ($status >= 400 && $status < 500 && $status !== 429) { return null; // bad key/payload — non-retryable } self::backoff($attempt, $retryAfter); } return null; } /** * @return array{0: int, 1: int, 2: array<string, mixed>} [http status, retry-after seconds, decoded body] */ private static function post(string $body): array { $timestamp = (string) time(); // integer seconds — a millisecond timestamp is rejected $signature = hash_hmac( // lowercase hex 'sha256', $timestamp.'.POST.'.self::PATH.'.'.hash('sha256', $body), (string) getenv('BUGBOARD_SIGNING_SECRET'), ); $retryAfter = 0; $handle = curl_init(self::ENDPOINT); curl_setopt_array($handle, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5, CURLOPT_HTTPHEADER => [ 'Content-Type: application/json', 'Accept: application/json', // A header, not a body field, so it stays outside the signature. 'X-Bb-Hide-Response: true', 'X-Bb-Key-Id: '.getenv('BUGBOARD_KEY_ID'), 'X-Bb-Timestamp: '.$timestamp, 'X-Bb-Signature: '.$signature, ], CURLOPT_HEADERFUNCTION => function ($handle, string $header) use (&$retryAfter): int { if (stripos($header, 'retry-after:') === 0) { $retryAfter = (int) trim(substr($header, 12)); } return strlen($header); }, ]); $response = curl_exec($handle); $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE); curl_close($handle); $data = is_string($response) ? json_decode($response, true) : null; return [$status, $retryAfter, is_array($data) ? $data : []]; } private static function describe(mixed $description): ?string { if ($description === null) { return null; } if ($description instanceof \Throwable) { $text = $description->getMessage()."\n".$description->getTraceAsString(); } elseif (is_string($description)) { $text = $description; } else { // arrays/objects → readable JSON; fall back to the type name if it won't encode. $text = json_encode($description, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); if ($text === false) { $text = '['.get_debug_type($description).']'; } } // Under the server's 65535 cap, so multi-byte can't overflow it. return mb_substr($text, 0, 60000); } /** * @param array<int, string>|string $tags * @return array<int, string> */ private static function normalizeTags(array|string $tags): array { $list = is_array($tags) ? $tags : explode(',', $tags); $clean = []; foreach ($list as $tag) { $tag = mb_substr(trim((string) $tag), 0, 50); if ($tag !== '' && ! in_array($tag, $clean, true)) { $clean[] = $tag; } } return $clean; } /** Exponential backoff with equal jitter, capped at 30s; honors Retry-After when present. */ private static function backoff(int $attempt, int $retryAfterSeconds): void { $cap = (int) min(500 * 2 ** $attempt, 30000); $waitMs = $retryAfterSeconds > 0 ? $retryAfterSeconds * 1000 : intdiv($cap, 2) + random_int(0, intdiv($cap, 2)); usleep($waitMs * 1000); } // OPTIONAL — built-in payload encryption (libsodium ships with PHP). Sign the envelope, not the plaintext. private static function encrypt(string $body, string $publicKeyBase64, ?string $keyId): string { $sealed = sodium_crypto_box_seal($body, base64_decode($publicKeyBase64)); return json_encode(['encrypted' => array_filter([ 'v' => 1, 'alg' => 'x25519-sealedbox', 'key_id' => $keyId, 'ciphertext' => base64_encode($sealed), ], static fn ($value): bool => $value !== null)]); } }
// bugboard.ts — single-file BugBoard client (React Native / Expo). No dependencies. const ENDPOINT = 'https://bugboard.dev/api/v1/tasks'; const API_KEY = process.env.EXPO_PUBLIC_BUGBOARD_API_KEY; // bb_pub_… const SEVERITIES = ['critical', 'major', 'moderate', 'minor'] as const; const PRIORITIES = { Low: 'low', '': 'medium', Medium: 'medium', High: 'high', } as const; type Severity = (typeof SEVERITIES)[number]; type Priority = 'low' | 'medium' | 'high'; type Outcome = 'created' | 'deduplicated' | 'dropped'; type Reporter = ( title: string, description?: unknown, tags?: string[] | string, ) => Promise<Outcome | undefined>; function describe(description?: unknown): string | undefined { if (description == null) return undefined; let text: string; if (description instanceof Error) { text = `${description.message}\n${description.stack ?? ''}`.trim(); } else if (typeof description === 'string') { text = description; } else { try { text = JSON.stringify(description, null, 2); // objects/arrays → readable JSON } catch { text = String(description); // fall back for cycles/bigint/etc. } } return text.slice(0, 60000); // under the server's 65535 cap, so multi-byte can't overflow it } function normalizeTags(tags?: string[] | string): string[] { const list = Array.isArray(tags) ? tags : typeof tags === 'string' ? tags.split(',') : []; return [ ...new Set( list .map((t) => `${t}`.trim()) .filter(Boolean) .map((t) => t.slice(0, 50)), ), ]; } async function send( severity: Severity, priority: Priority, title: string, description?: unknown, tags?: string[] | string, ): Promise<Outcome | undefined> { const body = JSON.stringify({ severity, priority, title: `${title}`.slice(0, 255), description: describe(description), tags: normalizeTags(tags), }); for (let attempt = 0; attempt <= 3; attempt++) { try { const res = await fetch(ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-Bb-Hide-Response': 'true', // don't echo the report back to us Authorization: `Bearer ${API_KEY}`, }, body, }); if (res.ok) { // A drop is a 2xx: the server accepted then discarded the report. Never retry it. const data: Record<string, unknown> = await res .json() .catch(() => ({})); if (data.dropped === true || data.quota_exceeded === true) return 'dropped'; if (data.deduplicated === true) return 'deduplicated'; return 'created'; } if (res.status >= 400 && res.status < 500 && res.status !== 429) return undefined; await backoff(attempt, Number(res.headers.get('Retry-After'))); } catch { await backoff(attempt); } } return undefined; } // Exponential backoff with equal jitter, capped at 30s; honors Retry-After when present. function backoff(attempt: number, retryAfterSeconds = 0): Promise<void> { const cap = Math.min(500 * 2 ** attempt, 30000); const wait = retryAfterSeconds > 0 ? retryAfterSeconds * 1000 : cap / 2 + Math.random() * (cap / 2); return new Promise((r) => setTimeout(r, wait)); } const bugboard = {} as Record<string, Reporter>; for (const severity of SEVERITIES) { for (const [suffix, priority] of Object.entries(PRIORITIES) as [ string, Priority, ][]) { bugboard[`${severity}${suffix}`] = (title, description, tags) => send(severity, priority, title, description, tags).catch( () => undefined, ); } } export default bugboard;
3. Configure
Set the key in your environment. Client platforms hold a publishable token; servers hold the key id + signing secret.
VITE_BUGBOARD_API_KEY=bb_pub_xxxxxxxx
BUGBOARD_KEY_ID=bbk_xxxxxxxx BUGBOARD_SIGNING_SECRET=bb_sec_xxxxxxxx
BUGBOARD_KEY_ID=bbk_xxxxxxxx BUGBOARD_SIGNING_SECRET=bb_sec_xxxxxxxx
EXPO_PUBLIC_BUGBOARD_API_KEY=bb_pub_xxxxxxxx
4. Use it
Call a severity method with a title (required); optionally pass a description — a string or
the caught error/exception — and tags. The call returns immediately and delivers in the
background. Each severity has …Low, …Medium (the default), and …High variants —
criticalHigh, minorLow, and so on.
Keep titles stable. Dedup is server-side and groups by exact title (or description), so put variable data — ids, timestamps, counts — in the description or tags, never the title. A unique title per occurrence spawns a new card each time instead of bumping one card's count.
import bugboard from './bugboard'; try { await payments.charge(order); } catch (err) { bugboard.criticalHigh('Payment failed', err, ['payment', 'backend']); } bugboard.major('Checkout is slow'); // title is all you need bugboard.minor('Tooltip misaligned', null, 'ui,polish'); // tags as a CSV string
const bugboard = require('./bugboard'); try { await payments.charge(order); } catch (err) { bugboard.criticalHigh('Payment failed', err, ['payment', 'backend']); } bugboard.major('Checkout is slow'); bugboard.minor('Tooltip misaligned', null, 'ui,polish');
require __DIR__.'/BugBoard.php'; try { $payments->charge($order); } catch (\Throwable $e) { BugBoard::criticalHigh('Payment failed', $e, ['payment', 'backend']); } BugBoard::major('Checkout is slow'); BugBoard::minor('Tooltip misaligned', null, 'ui,polish');
import bugboard from './bugboard'; try { await payments.charge(order); } catch (err) { bugboard.criticalHigh('Payment failed', err, ['payment', 'backend']); } bugboard.major('Checkout is slow'); bugboard.minor('Tooltip misaligned', null, 'ui,polish');
The four medium-priority methods cover most apps:
bugboard.critical('Payment failed', err); // highest severity bugboard.major('Checkout is slow', err); bugboard.moderate('Slow image upload', err); bugboard.minor('Tooltip misaligned');
Each call is fire-and-forget and never throws into your app. If you want the outcome, await it —
it resolves to 'created', 'deduplicated', or 'dropped' (a dropped result means the server
accepted and discarded the report, e.g. over quota):
const outcome = await bugboard.critical('Payment failed', err); if (outcome === 'dropped') console.warn('BugBoard is dropping reports (quota?)');
Verify your signing
Signing is the one part that's easy to get subtly wrong, and a bad signature just looks like a
401. The server is strict about two things: the timestamp is integer seconds (a millisecond
Date.now() is rejected — it silently falls back to bearer auth and fails), and the signature is
lowercase hex. This pinned vector lets you check your HMAC byte-for-byte before wiring anything up
— it's the same one the API reference asserts:
const crypto = require('crypto'); const body = '{"severity":"major","title":"SDK smoke test"}'; const bodyHash = crypto.createHash('sha256').update(body).digest('hex'); // 9070dce6abd7e9819456eee1d61339f697b070b89e3e743a97ec66bf8754480e const signature = crypto .createHmac('sha256', 'bb_sec_0123456789abcdef') .update(`1750000000.POST./api/v1/tasks.${bodyHash}`) .digest('hex'); console.log( signature === 'c9436e5c768e0cbea09119c0b112088f348f45aeb1c1ffcccecd62e65e2f3fc1', );
<?php $body = '{"severity":"major","title":"SDK smoke test"}'; $hash = hash('sha256', $body); // 9070dce6abd7e9819456eee1d61339f697b070b89e3e743a97ec66bf8754480e $signature = hash_hmac('sha256', "1750000000.POST./api/v1/tasks.$hash", 'bb_sec_0123456789abcdef'); // c9436e5c768e0cbea09119c0b112088f348f45aeb1c1ffcccecd62e65e2f3fc1 var_dump($signature === 'c9436e5c768e0cbea09119c0b112088f348f45aeb1c1ffcccecd62e65e2f3fc1');
Three ways to break it: signing the resolved request URL instead of the literal /api/v1/tasks;
re-serializing the JSON before signing instead of hashing the exact bytes you send; or reusing one
timestamp and signature across retries — recompute both on every attempt (the file above does).
Encryption (optional)
BugBoard can decrypt a sealed payload so the request body is opaque on the wire — useful for client apps where the body is visible in the network tab. The seal uses a libsodium sealed box, so it needs libsodium available at runtime:
- PHP —
sodium_crypto_box_seal()comes from thesodiumextension, which is bundled with PHP but not always enabled on the host: most distributions ship it as a separate package. Check withphp -m | grep sodium, and if it's missing follow Enabling PHP's sodium extension. Once it's loaded, the file above includes a readyencrypt()helper: setBUGBOARD_ENCRYPTION_PUBLIC_KEY(and optionallyBUGBOARD_ENCRYPTION_KEY_ID), then uncomment the one line indeliver(). Remember the HMAC signature is computed over the envelope you transmit, which this file already does. - Everywhere else — Node, the browser, and React Native need a sealed-box binding
(
tweetnacl-sealedbox-js), which defeats the no-dependency goal. If you need encryption there, use the official SDK or follow the verbatim sealing algorithm in Encrypted Transport.
On the wire the server reads only the envelope's key_id and ciphertext; the v and alg fields
are advisory. All four files above also send X-Bb-Hide-Response, so the server withholds the
stored card from the response — sealing the request body would achieve little if the report came
straight back in plaintext.
What this leaves out
To stay one readable, dependency-free file, this version drops several things the official SDKs do for you:
- a background queue with a bounded size and drop policy;
- sampling (
sampleRate) to thin out error storms; - a
beforeSendhook to scrub PII or drop reports; - bounded concurrency and a graceful-shutdown flush;
- source locations —
file_nameandline_numberare simply never sent.
That last one is worth a moment's thought if you report from a single global handler. The SDKs fill
file_name from a reported error's throw site, so each card names the code that failed; a
hand-rolled client that sends no location gets cards with no file at all. You can add it yourself —
in PHP, $e->getFile() and $e->getLine() off the caught exception (following getPrevious() to
the root cause) is most of the job; in JavaScript, parsing the first non-internal frame out of
err.stack is the equivalent. Send paths project-relative and keep them stable across deploys:
file_name is part of variant identity, so a path containing a release timestamp splits one card's
history across every deploy. See
where the fields come from.
It keeps the essentials — all 16 methods, title/tag clamping, fire-and-forget delivery, and
retry with backoff that honors Retry-After. Need the rest? Install the SDK or read the full contract in the API Reference.