All posts
2026-04-30

Shipping the BoomSauce API

The full v1 API is live. The Node SDK is on npm. Four lines of TypeScript to send your first email.

```ts import { BoomSauce } from 'boomsauce'; const bs = new BoomSauce({ apiKey: process.env.BOOMSAUCE_API_KEY! }); const send = await bs.sends.create({ from_brand_id: 1, to: { email: 'jane@example.com', first_name: 'Jane' }, subject: 'Quick question, Jane', html: '<p>Hey Jane, mind if I send a 2-line pitch?</p>', }); ```

That returns a typed response with the send_id, the provider message_id, the mailbox used, and a request_id you can paste into a support ticket if anything goes wrong. The SDK handles 429s and 5xx with exponential backoff, stamps an Idempotency-Key on every write so retries are safe, and exposes every error as a typed class you can catch by kind. Webhook signature verification is one function: `verifyWebhook(rawBody, header, secret)`. Zero runtime dependencies. Works on Node 18+, Bun, Deno, Cloudflare Workers.

This post is a walk-through of what we built, the decisions behind it, and the things we deliberately didn't ship.

## Why an API at all

The BoomSauce dashboard is the right product for ninety percent of operators. The remaining ten percent — RevOps engineers wiring email into a sales pipeline, lifecycle teams sending behavior-triggered messages, agencies that manage hundreds of campaigns programmatically — need to drive the rails from code. Without an API, they hand-roll a headless browser, scrape responses, paginate by guessing, and hate every minute of it.

We had a v1 surface for months, but it was a thin wrapper. No scopes. No IP allowlist. No call log. No SDK. Documentation was a hand-written page with curl snippets that drifted out of sync with the code. The API existed in the technical sense — you could call it — and didn't exist in the developer-experience sense, which is the only sense that matters.

This sprint closed the gap.

## What "pro grade" meant

We benchmarked against four operators that set the standard in their lanes — Stripe, Resend, Postmark, SendGrid — and built a punch list of every gap. The gaps were predictable: no first-party SDK, no per-key scopes, no IP allowlist, no audit log, no call log, no docs you'd want to send to a CTO. We built each one.

**Per-key scopes.** Every key now ships with a scope set: `sends:write`, `contacts:read`, `webhooks:write`, and so on, plus resource-level wildcards (`sends:*`) and a full-access `*` for trusted backends. Endpoints declare what they require. A 403 explains exactly which scope is missing and which scopes the key has, so the integrator's fix is one click in the dashboard, not a support ticket.

**IP allowlist.** Per-key CIDR list, enforced in the database via Postgres' native `<<=` containment check at auth time. NULL means any IP. Populate it with your CI runner's egress range and a key leaked into a Slack channel becomes useless to whoever finds it.

**Per-key rate limit.** Default is 300 requests per minute per tenant. Override per key when one automation needs more headroom and another needs to be capped tightly. Limits surface in `X-RateLimit-Limit / Remaining / Reset` headers on every response.

**Key rotation.** Click rotate. We issue a new key with the same scopes and limits. The old key keeps working for 24 hours so you can swap it across your services without downtime, then auto-revokes. Audit log captures the whole flow.

**Audit log + call log.** Every key lifecycle event — created, revoked, rotated, scopes changed, IP list changed — lands in `mt_api_key_events` with the actor's email and IP. Every authenticated request lands in `mt_api_calls` with the request_id, status, latency, IP, and idempotency-key. Both are visible in the dashboard. Both let support find the exact call when a customer pastes a request_id into a ticket.

**Idempotency replay inspector.** Send the same request twice with the same Idempotency-Key and the second response carries `X-Idempotent-Replay: true` and `X-Original-Request-Id: …` pointing back to the original execution. The call log tags replays distinctly so you can tell cached responses from real ones in your dashboard.

**Errors with docs_url.** Every 4xx and 5xx response includes `error`, `message`, `request_id`, and `docs_url`. The docs_url deep-links to a section in /api-docs where the error code is documented with status, what it means, and how to fix it. Less guessing. Faster integration.

## What the SDK does

The HTTP wrapper inside the SDK is the most important file. It owns:

- Bearer token injection on every request - Auto-stamping a UUID Idempotency-Key on POST/PATCH unless the caller set one - Exponential backoff on 429 and 5xx (honors Retry-After when set) - Timeout enforcement with proper AbortController integration - Pluggable fetch (so tests inject a mock and Cloudflare Workers can pass workerd's fetch)

Above that sits eight resource subclients — `bs.sends`, `bs.contacts`, `bs.webhooks`, etc. — each typed with the exact request and response shapes from the OpenAPI spec. Below it sits an error hierarchy:

```ts import { BoomSauce, RateLimitError, AuthError, ValidationError } from 'boomsauce';

try { await bs.sends.create({ /* ... */ }); } catch (err) { if (err instanceof RateLimitError) await sleep(err.retryAfterSeconds * 1000); else if (err instanceof AuthError) throw new Error('check BOOMSAUCE_API_KEY'); else if (err instanceof ValidationError) showFormError(err.message); else throw err; } ```

For webhooks, one function:

```ts import { verifyWebhook } from 'boomsauce';

export async function POST(req: Request) { const raw = await req.text(); try { const event = verifyWebhook( raw, req.headers.get('BoomSauce-Signature') ?? '', process.env.BOOMSAUCE_WEBHOOK_SECRET!, ); // do stuff with event.event and event.data return new Response(null, { status: 200 }); } catch { return new Response('invalid signature', { status: 400 }); } } ```

It's HMAC-SHA256, timing-safe compare, with a configurable freshness tolerance (default 5 minutes) to defeat replay attacks. The verifier returns a typed event payload on success and throws `SignatureError` on failure.

Twenty-two tests cover the lot — header stamping, retry on 429 with Retry-After, retry exhaustion, error class mapping, signature verify positive and negative, tampered body, expired timestamp.

## Docs that aren't a wall of text

The /api-docs page is now an embedded Scalar reference. Three-pane layout. Search. Try-it-now panel that uses your real API key once you paste it into the auth helper at the top. Four code samples per endpoint — curl, JavaScript fetch, Python requests, Go net/http — auto-generated from the OpenAPI spec on every deploy so they can't drift.

The Postman collection at /api/v1/postman.json is the part most operators don't bother with. Most "we have a Postman collection" stories are mechanical OpenAPI dumps with empty headers and `string` placeholders. Ours has realistic example bodies, a pre-request script that stamps Idempotency-Key on every write, a test script that asserts the response is 2xx and captures the request_id to a collection variable, per-endpoint descriptions with the required scope and likely error codes, and a companion environment file at /api/v1/postman.environment.json. Drop them both into Postman, set your api_key, and you're sending.

## What we deliberately didn't ship

We considered and skipped several things that look like polish but compound into complexity:

**GraphQL.** Adds a second surface area for zero gain at this scale. REST plus the SDK covers every use case we've seen.

**OAuth Connect.** "Apps build on BoomSauce" is a story for when a real partner asks for it. Speculating on it before there's demand burns engineering hours that should fix bugs in the existing surface.

**gRPC.** Same answer.

**Pagination styles other than cursor.** Limit/offset is a foot-gun at scale; opaque cursors are unambiguous. The SDK exposes them as opaque strings — you don't think about it.

**A CLI.** The Stripe CLI is excellent and webhook-listen is the most-loved feature. It's on the roadmap. Building it correctly takes a week and the SDK had to ship first.

**Scoped service tokens for impersonation.** Real need but speculative until enterprise customers ask. We have the audit log to add it cleanly when the moment comes.

## What's next

A short list, in order:

- A test mode that's actually useful — bs_test_* keys returning realistic mock IDs, never touching SMTP or wallet, so customers can run BoomSauce in CI without ceremony. - A webhook delivery UI redesign with a per-event timeline, replay button, and per-event-type pause toggle (currently all-or-nothing). - Webhook event catalog in the OpenAPI spec with full payload schemas. - A /status page rendered from the call log — real uptime per endpoint, public, no marketing fluff. - Versioning policy with Sunset and Deprecation headers per RFC 8594, so customers get six-month deprecation notices in-band when v2 lands.

A Python SDK and a Go SDK come after we have a few weeks of Node SDK in production telling us which patterns hold up and which need a rewrite.

## Try it

Generate a key at /developers, install the SDK, send your first email:

```bash npm install boomsauce ```

```ts import { BoomSauce } from 'boomsauce'; const bs = new BoomSauce({ apiKey: 'bs_live_...' }); console.log(await bs.wallet.retrieve()); ```

If you're integrating BoomSauce into a real system and hit a friction point, send a note to support@boomsauce.com with the request_id from the response. We read every one.

— Michael

Stop renting tools. Own the rails.

Wallet starts at $0. Add a domain — or bring your own free — and you can be sending in under 30 minutes.