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:sendscope - 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-Signatureheader 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.