Quickstart · Bun

Send your first email from a pure TypeScript server on Bun.serve. No framework — Bun's native HTTP and crypto are enough.

Prerequisites

  • Bun 1.1 or later
  • A Bird test API key from the dashboardbird_test_xxxxxxxx

Install the SDK

bun add @bird/sdk

Set the env vars

Create .env at the project root:

BIRD_API_KEY=bird_test_xxxxxxxx
BIRD_WEBHOOK_SECRET=whsec_xxxxxxxx

Send + receive in one file

Create server.ts:

import { BirdClient } from "@bird/sdk";

const bird = new BirdClient({ apiKey: process.env.BIRD_API_KEY! });

function timingSafeEqual(a: string, b: string): boolean {
  if (a.length !== b.length) return false;
  let mismatch = 0;
  for (let i = 0; i < a.length; i++) mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
  return mismatch === 0;
}

Bun.serve({
  port: 3000,
  routes: {
    "/api/send-welcome": {
      POST: async (req) => {
        const { to } = (await req.json()) as { to: string };
        const { data, error } = await bird.email.send({
          from: "Bird <onboarding@bird.dev>",
          to: [to],
          subject: "Welcome",
          html: "<p>It works.</p>",
        });
        if (error) return Response.json({ error }, { status: 500 });
        return Response.json({ id: data.id }, { status: 202 });
      },
    },
    "/webhooks/bird": {
      POST: async (req) => {
        const raw = await req.text();
        const header = req.headers.get("bird-signature") ?? "";
        const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));

        const age = Math.abs(Date.now() / 1000 - parseInt(parts.t ?? "0", 10));
        if (!parts.t || !parts.v1 || age > 300) {
          return Response.json({ error: "invalid_signature" }, { status: 401 });
        }

        const hasher = new Bun.CryptoHasher("sha256", process.env.BIRD_WEBHOOK_SECRET!);
        const expected = hasher.update(`${parts.t}.${raw}`).digest("hex");

        if (!timingSafeEqual(parts.v1, expected)) {
          return Response.json({ error: "invalid_signature" }, { status: 401 });
        }

        const event = JSON.parse(raw) as { type: string; data: { id: string } };
        // event.type === 'email.delivered' | 'email.bounced' | ...
        return Response.json({ received: true }, { status: 200 });
      },
    },
  },
});

console.log("listening on http://localhost:3000");

Run it:

bun --env-file=.env run server.ts
curl -X POST http://localhost:3000/api/send-welcome \
  -H 'content-type: application/json' \
  -d '{"to":"delivered@bird.dev"}'

What just happened

delivered@bird.dev is a sanctioned test recipient — it accepts the send, returns an email_* id, and emits the delivery event back to your webhook. The handler compares HMAC-SHA256 digests in constant time so a forged request never reaches the parser.

Next steps

Start with one channel.
Add the others when you're ready.

A test API key is yours immediately. Production unlocks when you add a payment method and verify a sender.

Get startedRead docsor

Using Claude Code, Cursor, or Codex? Point it at our MCP server — 141 tools, one per API endpoint, with scoped agent keys.