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 behaves like the SDK — the only difference is there's nothing to install.
This is the lean path: all 16 severity methods, fire-and-forget delivery, and basic retry in a single file. It intentionally drops the SDK's background queue, sampling,
beforeSend, bounded concurrency, and shutdown flush — see What this leaves out. For the full-featured client, 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 Reporter = ( title: string, description?: unknown, tags?: string[] | string, ) => void; function describe(description?: unknown): string | undefined { if (description == null) return undefined; const text = description instanceof Error ? `${description.message}\n${description.stack ?? ''}`.trim() : String(description); 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<void> { 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, }); // Success, or a non-retryable client error (bad key/payload) — stop. if ( res.ok || (res.status >= 400 && res.status < 500 && res.status !== 429) ) return; const retryAfter = Number(res.headers.get('Retry-After')) || 2 ** attempt; await new Promise((r) => setTimeout(r, retryAfter * 1000)); } catch { await new Promise((r) => setTimeout(r, 2 ** attempt * 1000)); // network error — retry } } } // 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) => { void send(severity, priority, title, description, tags).catch( () => {}, ); // 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; const text = description instanceof Error ? `${description.message}\n${description.stack ?? ''}`.trim() : String(description); 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. 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 || (res.status >= 400 && res.status < 500 && res.status !== 429) ) return; const retryAfter = Number(res.headers.get('retry-after')) || 2 ** attempt; await new Promise((r) => setTimeout(r, retryAfter * 1000)); } catch { await new Promise((r) => setTimeout(r, 2 ** attempt * 1000)); } } } const bugboard = {}; for (const severity of SEVERITIES) { for (const [suffix, priority] of Object.entries(PRIORITIES)) { bugboard[`${severity}${suffix}`] = (title, description, tags) => void deliver(severity, priority, title, description, tags).catch( () => {}, ); } } 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?] */ public static function __callStatic(string $method, array $args): void { foreach (self::SEVERITIES as $severity) { foreach (self::PRIORITIES as $suffix => $priority) { if ($method === $severity.$suffix) { self::deliver($severity, $priority, ...$args); return; } } } } /** @param array<int, string>|string $tags */ private static function deliver(string $severity, string $priority, string $title, string|\Throwable|null $description = null, array|string $tags = []): void { $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] = self::post($body); if ($status >= 200 && $status < 300) { return; } if ($status >= 400 && $status < 500 && $status !== 429) { return; // bad key/payload — non-retryable } usleep((int) (($retryAfter ?: 2 ** $attempt) * 1_000_000)); } } /** @return array{0: int, 1: int} [http status, retry-after seconds] */ private static function post(string $body): array { $timestamp = (string) time(); $signature = hash_hmac( '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); }, ]); curl_exec($handle); $status = (int) curl_getinfo($handle, CURLINFO_RESPONSE_CODE); curl_close($handle); return [$status, $retryAfter]; } private static function describe(string|\Throwable|null $description): ?string { if ($description === null) { return null; } $text = $description instanceof \Throwable ? $description->getMessage()."\n".$description->getTraceAsString() : $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; } // 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 Reporter = ( title: string, description?: unknown, tags?: string[] | string, ) => void; function describe(description?: unknown): string | undefined { if (description == null) return undefined; const text = description instanceof Error ? `${description.message}\n${description.stack ?? ''}`.trim() : String(description); 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<void> { 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 || (res.status >= 400 && res.status < 500 && res.status !== 429) ) return; const retryAfter = Number(res.headers.get('Retry-After')) || 2 ** attempt; await new Promise((r) => setTimeout(r, retryAfter * 1000)); } catch { await new Promise((r) => setTimeout(r, 2 ** attempt * 1000)); } } } 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) => { void send(severity, priority, title, description, tags).catch( () => {}, ); }; } } 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.
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');
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.
All four files above already 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.
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.