Transactional email in Node.js that survives retries, restarts and bounces.
The first send from Node takes one call. Production adds four things around it: an idempotency key so a crashed process cannot send twice, one switch on the error envelope so you retry the right failures, a verified webhook so your database learns what was delivered, and a key that can only send. This guide adds them in that order on the typed SDK for the transactional email API.
What you need
A working send, and a URL that can take a POST.
A first send that works. If you have not done that yet, start with send email from Node.js and come back.
notix-js installed, and NOTIX_API_KEY in the server environment. Create the key with Sending access; the last section says why.
A public URL on your server that Notix can POST webhook events to, and the signing secret the dashboard shows when you create the webhook, kept as NOTIX_WEBHOOK_SECRET.
Steps
Six changes between a demo and a system you can be paged for.
1. Give every logical send an idempotency key.
Pass { idempotencyKey } as the second argument and the SDK sends it as the Idempotency-Key header. Derive the key from your own record, an order id, a signup id, an invoice number, not from a random value: the point is that a retry after a crash, a timeout or a redeploy carries the same key and gets the original emailId back instead of sending a second receipt. Keys are up to 256 characters and the server remembers them for 24 hours.
send-receipt.ts
import { Notix } from "notix-js";const notix = new Notix(process.env.NOTIX_API_KEY);// One logical send, one key. The order id is the key, so a retry after a// crash returns the original emailId instead of a second receipt.export async function sendReceipt(order: { id: string; email: string; total: string }) { const { data, error } = await notix.emails.send( { from: "receipts@acme.com", to: order.email, subject: `Your receipt for order ${order.id}`, html: `<p>Thanks for your order. Total: ${order.total}.</p>`, text: `Thanks for your order. Total: ${order.total}.`, }, { idempotencyKey: `order-${order.id}-receipt` }, ); if (error) throw new Error(`${error.code}: ${error.message}`); return data.emailId;}
2. Handle the error envelope, and retry only what deserves it.
Every failure is { error: { code, message } }. The SDK unwraps it and returns { data, error } rather than throwing, and a 422 carries its extra fields under error.details. A BAD_REQUEST names the field and the limit it broke (a subject over 998 characters, an attachment over 7 MB, more than 10 attachments); fix it, do not retry it. RATE_LIMITED means the per second limit or the team's send limit; back off and retry with the same key. A request body over 20 MB is refused at the edge with a 413 and no JSON body at all.
SDK
const { data, error } = await notix.emails.send(payload, { idempotencyKey: `order-${order.id}-receipt`,});if (error) { switch (error.code) { case "BAD_REQUEST":// The message names the field and the limit. Fix the payload; do not retry. throw new Error(error.message); case "UNAUTHORIZED": case "FORBIDDEN":// Wrong key, or a key restricted to another domain. Configuration, not luck. throw new Error(error.message); case "NOT_UNIQUE":// Same idempotency key, different body. Your key scheme has a bug. throw new Error(error.message); case "RATE_LIMITED":// Back off and retry with the same key; the key makes the retry safe. return retryLater(payload, order.id); default:// A 409 while the first request is still in flight, or a 5xx:// retry with the same key after a short delay. return retryLater(payload, order.id); }}console.log("queued", data.emailId);
fetch
const response = await fetch("https://app.usenotix.dev/api/v1/emails", { method: "POST", headers: { Authorization: `Bearer ${process.env.NOTIX_API_KEY}`,"Content-Type": "application/json","Idempotency-Key": `order-${order.id}-receipt`, }, body: JSON.stringify(payload),});if (response.ok) { const { emailId } = await response.json(); console.log("queued", emailId);} else if (response.status === 429 || response.status === 409 || response.status >= 500) {// Same key, later: the server returns the original emailId if the first// request went through, or a fresh one if it did not. await retryLater(payload, order.id);} else {// 400, 401, 403: the body is { error: { code, message } }. Do not retry. const { error } = await response.json(); throw new Error(`${error.code}: ${error.message}`);}
3. Use batch for digests and bulk receipts.
notix.emails.batch takes up to 100 emails in one request and returns the created ids in order. Each email obeys the single-send limits, and the whole batch is capped at 40 MB of attachments once decoded, so 100 emails that each pass on their own cannot add up to a request no single check would refuse. One idempotency key covers the batch; include the page index if you paginate. React rendering is not available in batch mode, so render templates to HTML first or use templateId with variables.
weekly-digest.ts
// Up to 100 emails in one request; every email carries the single-send// limits, and the batch as a whole is capped at 40 MB of attachments.const { data, error } = await notix.emails.batch( users.map((user) => ({ from: "digest@acme.com", to: user.email, subject: "Your weekly summary", html: renderDigest(user), })), { idempotencyKey: `digest-${weekKey}-${pageIndex}` },);if (error) throw new Error(`${error.code}: ${error.message}`);// data is the list of created emails; store each id against its user.
4. Schedule, then move or cancel.
Pass scheduledAt as an ISO date-time on the send and the email waits. notix.emails.update(id, { scheduledAt }) moves it and notix.emails.cancel(id) stops it; a cancelled email fires email.cancelled to your webhook like any other state change. Both calls need a Full access key, so keep them in the job that owns the schedule rather than in the request path.
invoice-schedule.ts
// Send at 09:00 UTC tomorrow rather than now.const { data } = await notix.emails.send( { from: "billing@acme.com", to: user.email, subject: "Your invoice", html }, { idempotencyKey: `invoice-${invoice.id}` },);// Move it, or cancel it, while it is still scheduled.await notix.emails.update(data.emailId, { scheduledAt: "2026-09-14T09:00:00Z" });await notix.emails.cancel(data.emailId);
5. Verify the webhook and record what happened.
Each call carries X-Notix-Signature (v1= plus an HMAC-SHA256 over `${timestamp}.${rawBody}`), X-Notix-Timestamp in milliseconds, X-Notix-Event, X-Notix-Call and, on a redelivery, X-Notix-Retry: true. The SDK's constructEvent checks the signature, rejects a timestamp more than five minutes off, and returns the event as a discriminated union, so narrowing on event.type narrows event.data with it. Read the raw body: a JSON parser that re-serialises the payload changes the bytes the signature was computed over. Answer 2xx within 10 seconds and do the database work after, or in a queue.
Express
import express from "express";import { Webhooks } from "notix-js";const webhooks = new Webhooks(process.env.NOTIX_WEBHOOK_SECRET!);const app = express();// The signature is over the raw body, so this route must not go through// express.json(): a re-serialised body verifies as tampered.app.post("/webhooks/notix", express.raw({ type: "application/json" }), async (req, res) => { try { const event = webhooks.constructEvent(req.body, { headers: req.headers }); switch (event.type) { case "email.delivered": await db.email.update({ where: { providerId: event.data.id }, data: { status: "delivered" } }); break; case "email.bounced": case "email.complained": case "email.suppressed": await db.email.update({ where: { providerId: event.data.id }, data: { status: event.type } }); break; } res.status(200).send("ok"); } catch (error) { res.status(400).send((error as Error).message); }});
Next.js route handler
import { Notix } from "notix-js";const notix = new Notix(process.env.NOTIX_API_KEY);const webhooks = notix.webhooks(process.env.NOTIX_WEBHOOK_SECRET!);export async function POST(request: Request) { try {// request.text(), never request.json(): the signature covers the raw bytes. const event = webhooks.constructEvent(await request.text(), { headers: request.headers }); if (event.type === "email.delivered") { await db.email.update({ where: { providerId: event.data.id }, data: { status: "delivered" } }); } if (event.type === "email.bounced") { await db.email.update({ where: { providerId: event.data.id }, data: { status: "bounced" } }); } return new Response("ok"); } catch (error) { return new Response((error as Error).message, { status: 400 }); }}
Subscribe at least email.delivered, email.bounced, email.complained and email.suppressed. A call that does not get a 2xx is retried six times with exponential backoff (about 5, 10, 20, 40 and 80 seconds), and an endpoint that fails 30 calls in a row is disabled until you re-enable it in the dashboard, so make the handler idempotent on X-Notix-Call and keep it fast.
6. Let templates and suppression do their part.
A template created in the dashboard is sent by templateId with a variables map, so copy changes stop being deploys. Suppression needs no code: a recipient who bounced hard, complained, or was added to your list is skipped before the send, and the skip arrives as an email.suppressed event with the reason. The list is shared with campaigns, so a complaint from a receipt also stops the newsletter.
In production
Five habits that keep the pager quiet.
A key that can only send.
Create the app's key with Sending access. It reaches the send, batch, verify, SMS and deliverability endpoints and reads back only its own sends; everything else answers 403. A leaked key of that kind cannot read your contacts, delete a domain or cancel a campaign.
Log the id, not the body.
Store emailId against your own record and log that. The HTML often contains a name, an address or an order; the id lets support open the message in the dashboard without your logs ever holding it.
Alert on bounces and complaints.
Count email.bounced and email.complained per hour from the webhook and alert when either climbs. Both are delivered to you before they show in any report, and a complaint rate is what mailbox providers judge a sender by.
Check big sends before you send them.
Run a new template through the deliverability check first. It returns a verdict, a score and the findings, and the four blocking findings are the ones that would have cost you reputation to discover in the bounce log.
Two env vars, both server-side.
NOTIX_API_KEY and NOTIX_WEBHOOK_SECRET never reach a browser bundle. The SDK reads the key from the environment when you construct new Notix() without an argument, which keeps it out of source.
Know the plan you are on.
Free sends 5,000 emails a month and 200 a day with no card; Pro is $15 a month for 50,000, with usage past that metered on the same invoice. A RATE_LIMITED on a quiet day usually means the daily allowance, not the per second limit; the pricing page has both.
Using SMTP instead
When the relay is enough, and what you give up.
If the code you are hardening is a legacy app that already speaks SMTP, point its transport at the relay. The relay turns each message into the same API send, so suppression, templates by dashboard and delivery webhooks still apply. What SMTP cannot carry is the Idempotency-Key header, and the transport returns no emailId, so a retried send can duplicate and your webhook handler has to match events by recipient and subject rather than by id. For new code, use the API.
What happens if I retry a send with the same idempotency key?
The server keeps the canonical request body for 24 hours. The same key with the same body returns the original emailId with a 200 and sends nothing new. The same key with a different body answers 409 with code NOT_UNIQUE, so a bug in your key scheme surfaces as an error rather than a duplicate. A retry that arrives while the first request is still being processed also answers 409; wait a moment and try again.
Which errors should my code retry?
Retry 429 RATE_LIMITED, a 409 that arrives while an identical request is still in flight, and 5xx responses, always with the same idempotency key. Do not retry 400, 401 or 403: those name a bad field, a bad key or a key restricted to another domain, and repeating the request repeats the mistake. The SDK returns { data, error } instead of throwing, so the decision is one switch on error.code.
How do I know an email was actually delivered?
Subscribe a webhook to email.delivered, email.bounced, email.complained and email.suppressed, verify each call's signature, and update your own record by the email id. The send response only tells you the message was accepted and queued. Notix retries a webhook that does not get a 2xx up to six times with exponential backoff and disables the endpoint after 30 consecutive failures, so answer quickly and do the work afterwards.
Can I send React Email templates in a batch?
Not in a batch. A single send accepts a react element and renders it to HTML before the request goes out; the batch call takes plain html and text for each email. Render your React Email template to a string first, or use a template from the dashboard by templateId with variables, which works in both.
Should the app's API key have full access?
No. Create a Sending access key for the backend that only sends. It can call the send, batch, verify, SMS and deliverability endpoints and read back its own sends, and nothing else: a leaked key of that kind cannot list your contacts, delete a domain or cancel a campaign. Keep a Full access key for the dashboard and for scripts a person runs.
The full endpoint reference, including every limit quoted above, is in the docs. For one-time codes, see OTP email.