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 mailbay
The mailbay 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,
});
OptionTypeDescription
apiKeystringThe first argument. Defaults to MAILBAY_API_KEY; a client without a key throws missing_api_key.
baseUrlstringThe API origin. Defaults to MAILBAY_BASE_URL, else https://api.mailbay.dev. A trailing /v1 is fine.
maxRetriesnumberRetries after the first attempt. Default 2. See Retries.
timeoutMsnumberPer attempt timeout in milliseconds. Default 60000.
fetchtypeof fetchA fetch to use instead of the global one.
headersobjectExtra 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
MethodRequestReturns
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 place

Webhooks 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 from

Errors

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.

RuntimeVersionNotes
Node.js18 and laterOn Node 18, verifyWebhook needs Web Crypto: use Node 20, or start Node 18 with --experimental-global-webcrypto.
BunWorks as is.
DenoMAILBAY_API_KEY is read only with --allow-env; otherwise pass the key.
Cloudflare WorkersPass the key from env: new Mailbay(env.MAILBAY_API_KEY).
Edge runtimesVercel Edge Functions, Next.js middleware and others with a global fetch.

Something unclear or wrong? The API answers every error with a fix, and so should these docs. Back to mailbay.dev