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 dashboard —
bird_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
- Verify a sending domain — graduate from test keys to production sends.
- Send an SMS — same auth, same response envelope.
- Webhooks deep-dive — the full event catalog and retry schedule.
- Drop the MCP server in your IDE — let Claude and Cursor send through Bird.