Email APIs
How to Send Transactional Email in Node.js with an Email API
Build a production-safe Node.js email flow with fetch, stable idempotency keys, explicit error handling, durable retries, and honest delivery states.
Published · Updated
To send transactional email from Node.js, keep the API key on your server, verify the sender domain, call the provider's send endpoint with both HTML and plain-text content, and attach one stable idempotency key to each logical message. Store the returned message ID. If the request times out, reuse that same key instead of creating a second send. Then inspect delivery events separately from the initial API response.
This guide builds that flow with Node's built-in fetch, so there is no email package to install.
What you need before writing code
You need three things:
- A SendHQ workspace and a verified sending domain.
- A server-side API key stored as
SENDHQ_API_KEY. - A Node.js version with stable built-in
fetch. Node's official documentation marksfetchas stable from Node 21 onward and available without a flag from Node 18.
Do not put the API key in browser JavaScript, a mobile application, a public repository, or an error response. Anyone holding that key can attempt actions allowed by the workspace credential. The SendHQ API contract defines bearer authentication for product integrations and explicitly describes the key as server-side.
The sender address must use a domain verified in the same workspace. Verification establishes that the workspace is allowed to send for that domain. It does not guarantee inbox placement.
Configure the application
Set the key in the process environment used by your server or worker:
export SENDHQ_API_KEY="re_your_key_here"
For production, use the secret manager supplied by your host instead of a checked-in .env file. Validate the configuration at startup so a missing key fails before a customer action reaches the send path:
const SENDHQ_API_KEY = process.env.SENDHQ_API_KEY;
if (!SENDHQ_API_KEY) {
throw new Error("SENDHQ_API_KEY is not configured");
}Build one safe send function
SendHQ's current versioned base URL is https://sendhq.cc/api/v1. POST /emails accepts the sender, recipients, subject, HTML, and text. It returns a SendHQ message ID, a provider message ID, and a thread ID after a successful request.
const SENDHQ_API_KEY = process.env.SENDHQ_API_KEY;
if (!SENDHQ_API_KEY) {
throw new Error("SENDHQ_API_KEY is not configured");
}
export async function sendTransactionalEmail({
from,
to,
subject,
html,
text,
idempotencyKey,
}) {
if (!idempotencyKey) {
throw new Error("idempotencyKey is required");
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
try {
const response = await fetch("https://sendhq.cc/api/v1/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${SENDHQ_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({
from,
to: Array.isArray(to) ? to : [to],
subject,
html,
text,
}),
signal: controller.signal,
});
const payload = await response.json().catch(() => null);
if (!response.ok) {
const error = new Error(
payload?.error?.message || `Email API returned ${response.status}`,
);
error.status = response.status;
error.code = payload?.error?.code;
throw error;
}
return {
id: payload.id,
providerMessageId: payload.providerMessageId,
threadId: payload.threadId,
};
} finally {
clearTimeout(timeout);
}
}Call it from a trusted server-side workflow:
const order = {
id: "ord_7421",
customerEmail: "customer@example.net",
};
const message = await sendTransactionalEmail({
from: "Acme Receipts <receipts@example.com>",
to: order.customerEmail,
subject: `Receipt for order ${order.id}`,
text: `We received order ${order.id}.`,
html: `<p>We received order <strong>${order.id}</strong>.</p>`,
idempotencyKey: `order-receipt:${order.id}:v1`,
});
console.log("Queued email", message.id);The example recipient is a placeholder. In a real application, send only to the address connected to the user's action or valid consent. Validate the address and the business event before this function runs. An API call is an external side effect, not a harmless rendering operation.
Make the idempotency key represent the business event
The most important line is not fetch. It is the stable Idempotency-Key header.
Email sends use HTTP POST. RFC 9110 warns clients not to automatically retry a non-idempotent request unless they know the operation is effectively idempotent or know the original request was not applied. A timeout is ambiguous: the remote service might have accepted the email even though your process never received the response.
Build the key from a durable business event, not the current timestamp and not a new random value on every attempt. Useful examples include:
password-reset:<reset-record-id>:v1invoice-receipt:<invoice-id>:v1comment-notification:<comment-id>:<recipient-id>:v1
Store that key with your email job. If the worker retries the same logical message, reuse the key and the identical body. The live SendHQ contract permits keys up to 200 characters and states that reusing a key with a changed JSON body returns HTTP 409. That conflict is useful: it prevents one key from silently representing two different messages.
Do not catch every failure and immediately generate a new key. That defeats the duplicate-send protection.
For a deeper treatment, see Idempotency Keys for Email APIs.
Handle failures by category
Not every non-2xx response should be retried. Treat status codes as operational decisions:
400or422: The request is invalid or blocked by validation. Fix the data. Repeating the same request will not help.401: The key is missing or invalid. Stop and repair configuration.402: The workspace lacks the required entitlement. Route this to billing or account operations.403: A workspace, verified-domain, policy, or suppression rule forbids the send. Do not bypass it with another address or credential.409: The resource state or idempotency record conflicts with the request. Compare the stored business event and payload before taking action.423: The sender identity is paused by reputation policy. Stop sends from that identity and investigate.429: A usage limit was reached. Delay work according to your plan and application policy. Do not create parallel sends.500,502, or503: The failure may be temporary, but the outcome can still be ambiguous. Retry only from a durable job with the same idempotency key and bounded backoff.
Network errors and aborts are also ambiguous. Record the job as unknown or reconcile, not failed forever and not send again with a fresh key.
A small production system usually needs a durable email-jobs table or queue. The web request creates one job, a worker sends it, and the worker records the message ID or error category. This keeps a slow provider call out of the user-facing transaction and gives retries one stable home. Do not add a queue merely for architectural fashion, though. A low-volume internal tool can send inline if duplicate risk is acceptable and the idempotency key is preserved.
Treat acceptance, delivery, and inbox placement as different states
An HTTP 201 response means the send request was accepted by the API. It does not prove that the destination mail server accepted the message, that the message reached the inbox, or that a person read it.
Store the returned SendHQ id. The API exposes GET /emails/{id} for current message state and GET /emails/{id}/events for delivery events. Polling can work for a small administrative view. At higher volume, process provider events asynchronously and update your local job state idempotently.
Use a state model that preserves those distinctions:
pending -> accepted -> delivered
-> bounced
-> complained
-> delayedYour exact states can differ, but never label API acceptance as inbox delivery. Even a delivery event generally means the recipient's mail server accepted the message. Mailbox placement remains under the receiver's control.
Permanent bounces and complaints should update suppression state so later workflows do not keep sending to a harmful destination. For the operational distinction between those signals, read Bounce vs Complaint: What Actually Hurts Deliverability.
Add an approval boundary where risk increases
A password reset requested by the account owner can usually send automatically. A generated sales message, unfamiliar recipient, sensitive attachment, or large recipient set deserves a draft and approval step.
For agent-driven workflows, separate content generation from permission to send. Give the agent structured inputs, narrow recipient scope, and a server-side tool that enforces verified domains, consent, suppression checks, and one idempotency key. Require human review when the recipient or purpose is not already authorized. The agent should receive the stored message ID after sending, not unrestricted mailbox credentials.
This is slower than granting broad send access. It is also far easier to audit, revoke, and explain when something goes wrong.
API call, SDK, or SMTP?
Direct fetch is a good choice when you want the smallest dependency surface and an explicit HTTP contract. The downside is that you own request validation, error mapping, timeouts, and retry policy.
An SDK can reduce boilerplate and provide typed inputs, but it adds a dependency and can lag behind the live API. Check its version and underlying endpoint before assuming feature parity.
SMTP is useful when an existing system already speaks SMTP. For a new Node.js product, an HTTP API is usually easier to observe because requests, JSON errors, idempotency keys, and message IDs fit normal application tooling. SMTP does not remove the need for verified domains, suppression handling, durable retries, or delivery-event processing.
Production checklist
Before enabling the code for real users, verify all of the following:
- The API key is stored only in server-side secret management.
- The
fromdomain is verified and owned by the correct workspace. - Recipients are expected and tied to a user action or valid consent.
- Every logical message has one durable idempotency key.
- HTML has a useful plain-text alternative.
- Logs exclude the API key and minimize recipient and message content.
- Validation and policy failures are not retried.
- Temporary failures use bounded retries with the same key and body.
- The returned message ID is stored for later status checks.
- Acceptance, delivery, bounce, complaint, and inbox placement are not conflated.
- Bounces, complaints, and opt-outs feed suppression controls.
- Higher-risk or agent-generated sends have an approval boundary.
The shortest demo is one fetch call. The smallest production-safe system adds a stable business identifier, explicit failure categories, and honest delivery states. That is the difference between code that sends one email and a workflow you can trust.
If you want a versioned REST API with verified-domain sending, message IDs, events, and retry-safe requests, build the workflow with SendHQ.