Node.js integration: send transactional email

Updated

An official SDK is available: @inboxili/node on npm. The sections below also show the plain fetch client, if you prefer no dependency.

The API is a single JSON POST, so the only thing worth building is a thin client that gives you a timeout, typed errors and sane retry behaviour.

Use the official SDK

npm install @inboxili/node
import { Inboxili } from "@inboxili/node";

const inboxili = new Inboxili({ apiKey: process.env.INBOXILI_API_KEY! });

const { status, messageId } = await inboxili.emails.send({
  to: "ada@example.com",
  fromEmail: "hello@yourdomain.com",
  subject: "Welcome, {{first_name}}",
  htmlBody: "<p>Hi {{first_name}}, your account is ready.</p>",
  templateData: { first_name: "Ada" },
});

The SDK is TypeScript-first with no runtime dependencies, throws typed InboxiliError and InboxiliConnectionError, retries HTTP 429 only, and includes verifyWebhookSignature. Source: github.com/inboxili/inboxili-node. Package: @inboxili/node on npm.

Or use plain fetch

Requirements

  • Node.js 18 or later (built-in fetch)
  • An Inboxili API key with the transactional:send scope
  • A verified sending domain
export INBOXILI_API_KEY="ik_live_..."   # from Settings, Developer

The client

Save as inboxili.mjs:

export class InboxiliError extends Error {
  constructor(status, code, message) {
    super(message);
    this.name = "InboxiliError";
    this.status = status;
    this.code = code;
  }
}

export class Inboxili {
  constructor({ apiKey, baseUrl = "https://api.inboxili.com/api/v1", timeoutMs = 10_000 } = {}) {
    if (!apiKey) throw new Error("Inboxili: apiKey is required");
    this.apiKey = apiKey;
    this.baseUrl = baseUrl;
    this.timeoutMs = timeoutMs;
  }

  /** Sends one email. Returns { status, message_id }. */
  async send({ to, fromEmail, fromName, subject, htmlBody, textBody, templateId, templateData }) {
    const res = await fetch(`${this.baseUrl}/transactional/send`, {
      method: "POST",
      signal: AbortSignal.timeout(this.timeoutMs),
      headers: { Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json" },
      body: JSON.stringify({
        to,
        from_email: fromEmail,
        from_name: fromName,
        subject,
        html_body: htmlBody,
        text_body: textBody,
        template_id: templateId,
        template_data: templateData,
      }),
    });

    const body = await res.json().catch(() => ({}));
    if (!res.ok) {
      throw new InboxiliError(res.status, body.error?.code ?? "http_error", body.error?.message ?? res.statusText);
    }
    return body;
  }
}

JSON.stringify drops keys whose value is undefined, so unused fields are not sent.

Send an email

import { Inboxili, InboxiliError } from "./inboxili.mjs";

const inboxili = new Inboxili({ apiKey: process.env.INBOXILI_API_KEY });

try {
  const { message_id } = await inboxili.send({
    to: "ada@example.com",
    fromEmail: "hello@yourdomain.com",
    fromName: "Acme",
    subject: "Welcome, {{first_name}}",
    htmlBody: "<p>Hi {{first_name}}, your account is ready.</p>",
    templateData: { first_name: "Ada" },
  });
  console.log("sent", message_id);
} catch (err) {
  if (err instanceof InboxiliError && err.code === "sender_not_verified") {
    console.error("Verify the sending domain first:", err.message);
  } else {
    throw err;
  }
}

Send from a template

await inboxili.send({
  to: "ada@example.com",
  fromEmail: "hello@yourdomain.com",
  templateId: process.env.WELCOME_TEMPLATE_ID,
  templateData: { first_name: "Ada" },
});

Error handling

| err.status | err.code | Do | |---|---|---| | 401 | unauthorized | Check the key. Do not retry. | | 403 | forbidden | Check scope and IP rules. | | 422 | validation_error, sender_not_verified, send_failed | Fix the request, or inspect the message. | | 429 | rate_limited | Wait and retry. |

Retry only 429, and only with backoff:

export async function sendWithBackoff(client, message, attempts = 4) {
  for (let i = 0; ; i++) {
    try {
      return await client.send(message);
    } catch (err) {
      if (!(err instanceof InboxiliError) || err.status !== 429 || i >= attempts - 1) throw err;
      await new Promise((r) => setTimeout(r, 2 ** i * 1000 + Math.random() * 250));
    }
  }
}

Do not retry timeouts automatically. The API has no idempotency key, so a request that timed out may still have been sent.

Testing

Stub fetch so tests never send real mail:

import test from "node:test";
import assert from "node:assert/strict";
import { Inboxili, InboxiliError } from "./inboxili.mjs";

test("maps API errors", async (t) => {
  t.mock.method(globalThis, "fetch", async () =>
    new Response(JSON.stringify({ error: { code: "sender_not_verified", message: "nope" } }), { status: 422 })
  );
  const c = new Inboxili({ apiKey: "ik_live_test" });
  await assert.rejects(c.send({ to: "a@b.co", fromEmail: "x@y.co", subject: "s", htmlBody: "<p>x</p>" }), (e) => e instanceof InboxiliError && e.code === "sender_not_verified");
});

Run with node --test.

Production notes

  • Keep the key in a secret store, never in the repo or a client bundle.
  • Use a separate key per service so you can revoke one without touching the others.
  • Send from a queue so API latency never blocks a web request.
  • Listen for webhook events (bounced, complained) and stop mailing those addresses.
  • Verify webhook signatures: the X-Inboxili-Signature header is a hex HMAC-SHA256 of the raw request body using your webhook secret.
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyWebhook(rawBody, signatureHex, secret) {
  const expected = createHmac("sha256", secret).update(rawBody).digest();
  const given = Buffer.from(signatureHex, "hex");
  return given.length === expected.length && timingSafeEqual(given, expected);
}

Pass the raw body bytes, not a re-serialised object.

Frequently asked questions

Do I need an npm package?
No. Node 18 and later include fetch, and the API is one JSON POST.
Can I use it in the browser?
No. The API key must stay on the server. Call your own backend from the browser.

Build with Inboxili

Create a workspace, verify a domain, and make your first API call.

Related