# 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](#what-this-leaves-out). When you need those, install
> [`bugboard`](https://github.com/bug-board/bugboard-js) or
> [`bugboard/sdk`](https://github.com/bug-board/bugboard-php) instead.

## 1. Get a key

Where the code runs decides which key type you need (full detail in
**[Choosing a key type](/docs/installation/choosing-a-key)** and
**[Authentication](/docs/authentication/overview)**):

- **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.

<!-- tabs:JavaScript,Node.js,PHP,React Native -->

```ts
// 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;
```

```js
// 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
<?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)]);
    }
}
```

```ts
// 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.

<!-- tabs:JavaScript,Node.js,PHP,React Native -->

```dotenv
VITE_BUGBOARD_API_KEY=bb_pub_xxxxxxxx
```

```dotenv
BUGBOARD_KEY_ID=bbk_xxxxxxxx
BUGBOARD_SIGNING_SECRET=bb_sec_xxxxxxxx
```

```dotenv
BUGBOARD_KEY_ID=bbk_xxxxxxxx
BUGBOARD_SIGNING_SECRET=bb_sec_xxxxxxxx
```

```dotenv
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.

<!-- tabs:JavaScript,Node.js,PHP,React Native -->

```ts
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
```

```js
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');
```

```php
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');
```

```ts
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:

```ts
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):

```ts
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](/docs/api-reference) asserts:

<!-- tabs:Node.js,PHP -->

```js
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
<?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](/docs/security/encryption), so it needs libsodium available at runtime:

- **PHP** — `sodium_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](/docs/security/encryption#enabling-phps-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](/docs/security/encryption)**.

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 **`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](/docs/sdks)** or read the full contract in the **[API Reference](/docs/api-reference)**.
