Quickstart · Express

Send your first email from an Express 5 + TypeScript server. One POST route to send, one to receive the delivery webhook.

Prerequisites

  • Node 20 or later
  • pnpm 9 or later
  • A Bird test API key from the dashboardbird_test_xxxxxxxx

Install the SDK

pnpm add express @bird/sdk
pnpm add -D typescript tsx @types/express @types/node
# npm install express @bird/sdk
# yarn add express @bird/sdk
# bun add express @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 src/server.ts:

import express, { Request, Response } from "express";
import { BirdClient } from "@bird/sdk";
import crypto from "node:crypto";

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

app.use("/webhooks/bird", express.raw({ type: "application/json" }));
app.use(express.json());

app.post("/api/send-welcome", async (req: Request, res: Response) => {
  const { to } = req.body 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 res.status(500).json({ error });
  return res.status(202).json({ id: data.id });
});

app.post("/webhooks/bird", (req: Request, res: Response) => {
  const header = String(req.headers["bird-signature"] ?? "");
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const raw = req.body as Buffer;

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

  const expected = crypto
    .createHmac("sha256", process.env.BIRD_WEBHOOK_SECRET!)
    .update(`${parts.t}.${raw.toString("utf8")}`)
    .digest("hex");

  const sigBuf = Buffer.from(parts.v1, "hex");
  const expBuf = Buffer.from(expected, "hex");
  if (sigBuf.length !== expBuf.length || !crypto.timingSafeEqual(sigBuf, expBuf)) {
    return res.status(401).json({ error: "invalid_signature" });
  }

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

app.listen(3000, () => console.log("listening on http://localhost:3000"));

Run it:

pnpm tsx src/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 every send and emits the full event sequence without touching a real inbox. The webhook handler verifies the Bird-Signature header with HMAC-SHA256 and crypto.timingSafeEqual before parsing the body, so a forged request never reaches your business logic.

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.