Node SDK
The mailbay package wraps the whole API with types, safe retries and webhook verification. It has no dependencies and runs anywhere fetch does: Node 18 and later, Bun, Deno, Cloudflare Workers and edge runtimes.
Install
npm i mailbaymailbay package is being published to npm. Until it is there, use the Resend SDK for sending, or call the API with fetch; every page of these docs has the request.It ships ESM and CommonJS builds with TypeScript types, so both import and require work.
Create a client
import { Mailbay } from "mailbay";
// Reads MAILBAY_API_KEY when no key is given.
const mailbay = new Mailbay();
// Or pass the key and options yourself.
const custom = new Mailbay("mb_live_xxxxxxxxx", {
maxRetries: 3,
timeoutMs: 30_000,
});| Option | Type | Description |
|---|---|---|
| apiKey | string | The first argument. Defaults to MAILBAY_API_KEY; a client without a key throws missing_api_key. |
| baseUrl | string | The API origin. Defaults to MAILBAY_BASE_URL, else https://api.mailbay.dev. A trailing /v1 is fine. |
| maxRetries | number | Retries after the first attempt. Default 2. See Retries. |
| timeoutMs | number | Per attempt timeout in milliseconds. Default 60000. |
| fetch | typeof fetch | A fetch to use instead of the global one. |
| headers | object | Extra headers sent with every request. |
Every method also takes a last options argument with idempotencyKey, an AbortSignal as signal, and timeoutMs. Methods return the API's JSON as is, with snake case fields, and throw a MailbayError on failure.
Emails
mailbay.emails is the Emails API: sending from your verified domain, scheduled and batch sends. scheduled_at and scheduledAt both work and take a string or a Date.
// Send now. Every send gets an Idempotency-Key, so a retry never sends twice.
const { id } = await mailbay.emails.send({
from: "Acme <noreply@acme.com>",
to: "bob@example.com",
subject: "Your receipt",
html: "<p>Thanks for your order.</p>",
});
// Send later: an ISO 8601 string, a Date, or "in 2 hours".
const later = await mailbay.emails.send({
from: "Acme <noreply@acme.com>",
to: "bob@example.com",
subject: "Your trial ends tomorrow",
text: "Hi Bob, your trial ends tomorrow.",
scheduledAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
});
await mailbay.emails.reschedule(later.id, "2026-10-21T09:00:00Z");
await mailbay.emails.cancel(later.id);
// Up to 100 emails in one request.
const { data, errors } = await mailbay.emails.batch(
[
{ from: "Acme <noreply@acme.com>", to: "alice@example.com", subject: "Hi Alice", text: "Hello" },
{ from: "Acme <noreply@acme.com>", to: "bob@example.com", subject: "Hi Bob", text: "Hello" },
],
{ batchValidation: "permissive" },
);
// Read the status, and page through the sent log.
const email = await mailbay.emails.get(id); // email.status, email.recipients
const page = await mailbay.emails.list({ limit: 20 }); // page.data, page.next_cursor| Method | Request | Returns |
|---|---|---|
| emails.send(email) | POST /v1/emails | { id } |
| emails.batch(emails, { batchValidation }) | POST /v1/emails/batch | { data, errors? } |
| emails.get(id) | GET /v1/emails/{id} | The email with recipients |
| emails.list({ limit, cursor }) | GET /v1/emails | { data, next_cursor } |
| emails.update(id, { scheduled_at }) | PATCH /v1/emails/{id} | { object, id } |
| emails.reschedule(id, when) | PATCH /v1/emails/{id} | Shorthand for update |
| emails.cancel(id) | POST /v1/emails/{id}/cancel | { object, id } |
Inboxes and messages
inboxes, messages, threads and conversations cover Inboxes, Messages and threads and Sending from an inbox.
const inbox = await mailbay.inboxes.create({ local_part: "signup-test" });
// Block until the code arrives (up to 120 seconds). null when nothing came in time.
const got = await mailbay.messages.wait(inbox.id, { from: "github.com", timeout: 60 });
console.log(got?.codes[0]);
// Send from the inbox and reply in the thread.
const sent = await mailbay.messages.send(inbox.id, {
to: "bob@example.com",
subject: "Hello",
text: "Hi Bob",
});
await mailbay.messages.reply(sent.id, { text: "One more thing", reply_all: true });
const { data: messages, next_cursor } = await mailbay.messages.list(inbox.id, { limit: 50 });
const raw = await mailbay.messages.raw(messages[0].id); // the .eml as a string
const { data: threads } = await mailbay.threads.list(inbox.id);
const thread = await mailbay.threads.get(threads[0].id); // the thread with its messages
// The shared inbox view: open, closed and snoozed conversations.
const open = await mailbay.conversations.list({ status: "open", assignee: "me" });
await mailbay.conversations.update(open.data[0].id, { status: "closed" });Domains
See Domains for the records and what each one does.
const domain = await mailbay.domains.create({ name: "acme.com" });
for (const r of domain.records) console.log(r); // publish these DNS records
// Later: check the records now. Accepts the id or the name.
const checked = await mailbay.domains.verify("acme.com");
console.log(checked.status, checked.next_step); // "verified" when every record is in placeWebhooks and keys
webhooks also lists deliveries(id) and can replay(id, deliveryId). keys, suppressions, events and senders complete the API.
const hook = await mailbay.webhooks.create({
url: "https://example.com/hooks/mailbay",
event_types: ["message.delivered", "message.bounced", "message.scheduled"],
});
// hook.secret is whsec_..., shown once: store it to verify signatures.
const { key, secret } = await mailbay.keys.create({
name: "production sender",
permissions: "sending",
domain_id: "acme.com",
});
// secret is the API key, shown once.
await mailbay.suppressions.delete("bob@example.com");
const { data: events, next_after } = await mailbay.events.list({ limit: 100 });
const { data: senders } = await mailbay.senders.list(); // addresses you can use as fromErrors
Every failure throws a MailbayError with the API's error: status (the HTTP status), code, message and fix, what to do about it. An invalid_batch error also has errors, one entry per bad item, and a 429 has retryAfter in seconds.
import { Mailbay, MailbayError } from "mailbay";
try {
await mailbay.emails.send({ from: "me@unverified.dev", to: "bob@example.com", subject: "Hi", text: "Hi" });
} catch (err) {
if (err instanceof MailbayError) {
console.log(err.status); // 403
console.log(err.code); // "sender_not_verified"
console.log(err.message); // "unverified.dev is not a domain of this project, ..."
console.log(err.fix); // "Add the domain with `mailbay domain add unverified.dev`, ..."
}
throw err;
}When no response arrives, status is 0 and code is network_error, timeout or aborted.
Retries and idempotency
The client retries a request up to maxRetries times on 429, on 5xx and when the network fails, waiting with exponential backoff and jitter, or for as long as a Retry-After header asks. Other errors, such as 400 or 403, are thrown at once.
Calls that send mail (emails.send, emails.batch, messages.send and messages.reply) get an automatic Idempotency-Key, the same one on every attempt, so a retry returns the first result instead of sending twice. Pass your own with { idempotencyKey } to make retries safe across processes too, for example the id of the order the email is about. Other POST requests that create something are not retried after a server error, so they never create a duplicate.
Verify webhooks
verifyWebhook checks the Standard Webhooks signature with the endpoint's whsec_ secret, refuses deliveries more than 5 minutes old, and returns the parsed event. It throws a WebhookVerificationError (a MailbayError) otherwise. It uses Web Crypto, so it runs on every runtime below. mailbay.webhooks.verify is the same function.
import { verifyWebhook } from "mailbay";
// Next.js route handler, Workers, Deno or Bun: any runtime with Request.
export async function POST(req: Request) {
const event = await verifyWebhook({
payload: await req.text(), // the raw body, before any JSON parsing
headers: req.headers,
secret: process.env.MAILBAY_WEBHOOK_SECRET!,
});
if (event.type === "message.bounced") {
// event.data.recipient, event.data.response, ...
}
return new Response(null, { status: 204 });
}Runtimes
The SDK uses only fetch and Web Crypto, with no runtime dependencies.
| Runtime | Version | Notes |
|---|---|---|
| Node.js | 18 and later | On Node 18, verifyWebhook needs Web Crypto: use Node 20, or start Node 18 with --experimental-global-webcrypto. |
| Bun | Works as is. | |
| Deno | MAILBAY_API_KEY is read only with --allow-env; otherwise pass the key. | |
| Cloudflare Workers | Pass the key from env: new Mailbay(env.MAILBAY_API_KEY). | |
| Edge runtimes | Vercel Edge Functions, Next.js middleware and others with a global fetch. |