DEV Community

Omni Genz
Omni Genz

Posted on Originally published at omnigenz.site

Send transactional email with the Brevo API and plain Node.js fetch (no SDK)

A shorter, code-first version of a guide first published on Omni Genz.

SDKs are handy, but for one or two endpoints they're often more than you need. Brevo's (formerly Sendinblue) REST API is plain JSON over HTTPS, so Node 18+ can talk to it with the built-in fetch. No extra packages.

This post walks through four things: checking your key, sending a simple email, sending with a saved template, and handling errors and rate limits properly.

1. Get a key and keep it out of your code

In Brevo, go to Settings > SMTP & API and create an API key (it starts with xkeysib-). Put it in an environment variable, never in the repo:

export BREVO_API_KEY="xkeysib-..."
Enter fullscreen mode Exit fullscreen mode

Every request sends the key in an api-key header. The base URL is https://api.brevo.com/v3.

Quick sanity check from the terminal before writing any code:

curl -s https://api.brevo.com/v3/account \
  -H "api-key: $BREVO_API_KEY" \
  -H "accept: application/json"
Enter fullscreen mode Exit fullscreen mode

If you get your account details back, the key works. A 401 means the key is wrong, revoked or copied with a stray space.

2. A tiny client

Create brevo.mjs:

// brevo.mjs (Node 18+, no dependencies)
const BASE = "https://api.brevo.com/v3";
const KEY = process.env.BREVO_API_KEY;
if (!KEY) throw new Error("Set BREVO_API_KEY first");

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

export async function brevo(path, { method = "GET", body, retries = 3 } = {}) {
  for (let attempt = 0; ; attempt++) {
    const res = await fetch(BASE + path, {
      method,
      headers: {
        "api-key": KEY,
        "accept": "application/json",
        "content-type": "application/json",
      },
      body: body ? JSON.stringify(body) : undefined,
    });

    // Rate limited: wait for the window to reset, then try again
    if (res.status === 429 && attempt < retries) {
      const reset = parseInt(res.headers.get("x-sib-ratelimit-reset") || "60", 10);
      await sleep((reset + 2 ** attempt + Math.random()) * 1000);
      continue;
    }

    const text = await res.text();
    const data = text ? JSON.parse(text) : null;

    if (!res.ok) {
      // Brevo errors come back as { code, message }
      const err = new Error(`Brevo ${res.status}: ${data?.message || text}`);
      err.status = res.status;
      err.code = data?.code;
      throw err;
    }
    return data;
  }
}
Enter fullscreen mode Exit fullscreen mode

Two details worth calling out:

  • Rate limits. When you hit a limit, Brevo returns 429 with an x-sib-ratelimit-reset header (time until the window resets, usually in seconds). The loop waits that long plus a little exponential backoff. Brevo also sends x-sib-ratelimit-limit and x-sib-ratelimit-remaining, which you can log if you send in bulk.
  • Errors. A 400 body contains a code and a human-readable message, which tells you exactly which field is wrong. Surface it instead of swallowing it.

3. Send a plain transactional email

The endpoint is POST /v3/smtp/email. Without a template, sender, to, subject and some content are needed:

// send.mjs
import { brevo } from "./brevo.mjs";

const result = await brevo("/smtp/email", {
  method: "POST",
  body: {
    sender: { name: "Acme App", email: "no-reply@yourdomain.com" },
    to: [{ email: "jane@example.com", name: "Jane" }],
    subject: "Your login link",
    htmlContent: "<p>Hi Jane, <a href='https://example.com/login'>click here to sign in</a>.</p>",
    textContent: "Hi Jane, sign in here: https://example.com/login",
    tags: ["login-link"],
  },
});

console.log(result); // { messageId: "<...>" }
Enter fullscreen mode Exit fullscreen mode

Run it:

node send.mjs
Enter fullscreen mode Exit fullscreen mode

A 201 response with a messageId means Brevo accepted the message. Keep that ID: it's how you match the email to delivery events later.

Use a sender address on a domain you've added and authenticated in Brevo. Sending "from" an unverified address is the most common reason a first test fails or lands in spam. Always include textContent too, as some clients and spam filters like a plain-text part.

4. Send with a saved template

Hard-coding HTML gets old fast. Build the email in Brevo's template editor, use placeholders like {{ params.firstName }}, and pass values per send:

await brevo("/smtp/email", {
  method: "POST",
  body: {
    templateId: 12, // the ID shown in Brevo's template list
    to: [{ email: "jane@example.com", name: "Jane" }],
    params: { firstName: "Jane", orderId: "A-1042", total: "$48.00" },
    tags: ["order-receipt"],
  },
});
Enter fullscreen mode Exit fullscreen mode

With templateId set, the template's own sender and subject are used, so you can leave those out. params fills in the placeholders.

5. Useful extras on the same endpoint

All of these go in the same JSON body:

  • replyTo, cc, bcc for routing replies and copies.
  • attachment: an array of { url } or { content, name } where content is base64.
  • scheduledAt: an ISO date-time to send later. Brevo answers 202 instead of 201 for scheduled sends, and notes the actual send can lag the requested time by a few minutes.
  • headers: custom headers. A value in X-Mailin-custom comes back in webhook events, which is handy for passing your own order or user ID through.
  • messageVersions: send variations of one template (different to and params) in a single call instead of looping.

Here's a small helper for the attachment case:

import { readFile } from "node:fs/promises";

const pdf = await readFile("./invoice-1042.pdf");
await brevo("/smtp/email", {
  method: "POST",
  body: {
    templateId: 12,
    to: [{ email: "jane@example.com" }],
    params: { orderId: "A-1042" },
    attachment: [{ name: "invoice-1042.pdf", content: pdf.toString("base64") }],
    headers: { "X-Mailin-custom": "order:A-1042" },
  },
});
Enter fullscreen mode Exit fullscreen mode

6. Before you ship

  • Keep the key server-side only. Never call this API from the browser, as anyone could read the key.
  • Log the messageId with your own record (user, order) so support can trace a missing email.
  • Check Transactional > Logs in Brevo when a test doesn't arrive. It shows whether the message was delivered, deferred or bounced.
  • Brevo can disable a key that goes unused for a long time, so if an old integration suddenly returns 401, check the key status first.

Prices, plan limits and the dashboard menus change, so check Brevo's own pages for the current numbers rather than trusting any blog post (including this one).

The full beginner's guide, with key setup screenshots, common errors and limits, is here: Brevo API Explained: A Simple Beginner's Guide.

Top comments (0)