BugBoardDocs

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, install bugboard or bugboard/sdk instead.

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:

  • PHPsodium_crypto_box_seal() comes from the sodium extension, which is bundled with PHP but not always enabled on the host: most distributions ship it as a separate package. Check with php -m | grep sodium, and if it's missing follow Enabling PHP's sodium extension. Once it's loaded, the file above includes a ready encrypt() helper: set BUGBOARD_ENCRYPTION_PUBLIC_KEY (and optionally BUGBOARD_ENCRYPTION_KEY_ID), then uncomment the one line in deliver(). 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 beforeSend hook 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.