Skip to content
Docs menu

Guides

Features

API

Integrations

Node.js

The wakio package sends email from Node, Bun, Deno, Cloudflare Workers and Vercel Edge. It has no dependencies, and on Node it needs version 22 or later.

Install

npm install wakio

Create a client

import { Wakio } from "wakio"

const wakio = new Wakio()

The client reads your API key from WAKIO_API_KEY, or you pass it as apiKey. It checks for the key when it first sends, not when you create it, so creating the client once at the top of a module is fine even during a build that has no secrets.

Cloudflare Workers have no process.env unless the nodejs_compat flag is on, so pass the key from the handler's env:

export default {
  async fetch(request: Request, env: { WAKIO_API_KEY: string }) {
    const user = await currentUser(request, env)
    const wakio = new Wakio({ apiKey: env.WAKIO_API_KEY })
    await wakio.emails.send({ to: user.email, template: "welcome" })
    return new Response(null, { status: 204 })
  },
}
  • apiKey, default WAKIO_API_KEY: Your Wakio API key.
  • baseUrl, default https://api.wakio.net: Must be https, except on localhost, so your key never travels in cleartext.
  • render, default none: Renders React Email templates. See below.
  • timeout, default 10000: Milliseconds to wait for each attempt.
  • maxRetries, default 2: How many times to retry a failure that a retry can fix.
  • retryDelay, default 500: The longest wait, in milliseconds, before the first retry. Each later retry can wait up to twice as long. Set it to 0 in your tests.
  • fetch, default the global fetch: Pass your own for a proxy or for tests.

The client refuses to run in a browser, because every visitor could read your API key there. Call it from your server. Test environments that simulate a browser, such as jsdom, trip the same check; pass dangerouslyAllowBrowser: true in those tests.

Send an email

const result = await wakio.emails.send({
  to: user.email,
  subject: "Welcome to Acme",
  html: "<p>Thanks for signing up, {{name}}.</p>",
  text: "Thanks for signing up, {{name}}.",
  mergeData: { name: user.firstName },
})

to, cc and bcc each take an address, a named address like Maya Lee <[email protected]>, or a list of them; put a name that contains a comma in double quotes. replyTo takes bare addresses only. Leave out from and the email goes from no-reply@ on one of your sending domains. Every field and limit is in Send an email; the package uses the same names in camelCase, so reply_to is replyTo and merge_data is mergeData.

Send to an address your app already trusts, such as the email on a signed-in user's account. An address taken straight from a form or request body lets anyone use your app, and your sending reputation, to email whoever they like.

To give up on a send that takes too long, pass a signal: wakio.emails.send(message, { signal: AbortSignal.timeout(5000) }).

To send a template instead, pass its key:

await wakio.emails.send({ to: user.email, template: "welcome", mergeData: { plan: "Pro" } })

React Email

Pass React Email's render when you create the client, then send an element as react. Wakio renders the HTML and the plain-text version for you.

import { render } from "@react-email/render"
import { Wakio } from "wakio"
import { WelcomeEmail } from "./emails/welcome"

const wakio = new Wakio({ render })

await wakio.emails.send({ to: user.email, subject: "Welcome", react: <WelcomeEmail name={user.firstName} /> })

render is passed in rather than built in, so apps that do not use React Email never need it installed.

Attachments

await wakio.emails.send({
  to: user.email,
  subject: "Your invoice",
  text: "Your invoice is attached.",
  attachments: [{ filename: "invoice.pdf", contentType: "application/pdf", content: pdfBytes }],
})

content can be a Uint8Array, an ArrayBuffer, a Node Buffer, or a string that is already base64.

The result

{ deliveries: [{ email: "[email protected]", id: 272531, status: "queued" }], suppressed: [] }

An address on your suppressed list is not sent to. It appears in suppressed, and the send does not throw, even when every address was suppressed. Check suppressed when that matters to you.

Errors

Every error is a WakioError with status, code and body. The class tells you what to do about it.

  • WakioRejectedError: the request can never succeed (a missing field, a bad address, a reused idempotency key). Fix the request. Retrying sends the same mistake.
  • WakioMisconfiguredError: your account or setup is the problem (a missing or wrong key, no plan, no verified domain, an unknown template). Fix the setup. The same request works afterward.
  • WakioError: a network failure, a timeout, a server error, or a rate limit. The client already retried it. Retry later with error.idempotencyKey.

When Wakio rate-limited or throttled the request and said how long to wait, the error's retryAfter holds that wait in seconds.

When your signal aborts a send, the error is a WakioError with your abort reason as cause and the send's idempotencyKey, since the email may already be on its way: retry with that key rather than a new one.

import { WakioRejectedError } from "wakio"

try {
  await wakio.emails.send(message)
} catch (error) {
  if (error instanceof WakioRejectedError) console.error("Wakio refused this email:", error.body)
  else throw error
}

Retries and idempotency

The client retries network failures, timeouts, server errors and rate limits twice, waiting a random, growing time between tries, or as long as the response's Retry-After asks, up to a minute. Every send carries an idempotency key, so a retry never sends the same email twice.

When a send still fails, the error carries the key it used. isRetryable(error) is true only for the failures a later retry can fix, not for rejected, misconfigured or suppressed sends. If your own code retries later, for example a background job that runs again, retry with that key:

import { isRetryable } from "wakio"

try {
  await wakio.emails.send(message)
} catch (error) {
  if (isRetryable(error) && error.idempotencyKey) await queueRetry(message, error.idempotencyKey)
  else throw error
}

// later
await wakio.emails.send(message, { idempotencyKey })

Or name the email yourself from something your app already has, so every attempt uses the same key:

await wakio.emails.send(message, { idempotencyKey: `order-${order.id}-receipt` })

Use a new key for a new email. A key is remembered for 24 hours. Do not put secrets such as reset tokens in a key as they are: keys are visible to everyone on your Wakio account. Hash them first.

Contacts

const contact = await wakio.contacts.create({
  email: user.email,
  firstName: user.firstName,
  ...(user.wantsNewsletter && { subscribed: true, consentedAt: new Date(), consentIp: request.ip }),
})

consentedAt and consentIp go only with subscribed: true; sent without it, the request is refused. subscribed: true records that this person agreed to receive your campaigns, with when and where they agreed. Send it only for someone your app has seen agree, such as a signed-in user ticking a box. For a public signup form, use a Wakio subscribe form, which confirms each address by email first. See Add a contact.

contact.created is true when this call added the contact and false when it already existed. If the client had to retry, a contact this call did add can read false, because the retry finds it.

Sending domains

const domains = await wakio.sendingIdentities.list()
// [{ domain: "acme.com", sendable: true, verificationStatus: "verified" }]

Nodemailer

If your app already sends with Nodemailer, change the transport and keep the rest:

import nodemailer from "nodemailer"
import { wakioTransport } from "wakio/nodemailer"

const transporter = nodemailer.createTransport(wakioTransport())

It works with Nodemailer 6 to 10. Names on from, to, cc and bcc reach the recipient; a reply-to address is sent without its name. Suppressed addresses come back in info.rejected. When every address was suppressed, sendMail rejects with WakioSuppressedError. To make a send idempotent, set the X-Wakio-Idempotency-Key header. transporter.verify() checks your API key with Wakio.

The transport refuses a message that uses something Wakio cannot send, rather than dropping it: custom headers, calendar invites, AMP, inline images and inline attachments, attachment headers, a custom date, threading headers and a custom envelope. The error names the field.

Check your key

await wakio.ping() // { account: "acme" }