Every account now comes with a free mailbox — warmed automatically, no domain to buy. See how it works
Node 18 and later have fetch built in, so sending transactional email from Node.js needs no package: one POST to Warmerly's REST API. The function below adds the two things a real app needs, an idempotency key so a retry never sends the same message twice, and an error type that carries the API's error code.
Four steps, the same in every framework. Warmerly is REST only today: no SMTP relay, webhooks, templates or attachments yet.
In Warmerly open Transactional email, add the domain you send from (for example mail.example.com), publish the DNS records it shows at your DNS provider, then verify. Use a domain or subdomain you do not use for cold outreach.
Under Transactional email, API keys, create a key with the Sending permission, optionally limited to that domain. It starts with wm_tx_ and is shown once, so copy it straight into your secrets store.
Put it in your environment (a .env file loaded by your process manager, or your host's secret settings), and read it as process.env.WARMERLY_TX_KEY.
A successful request answers 202 with the email's id and a status of sent (or queued, which Warmerly retries for up to 24 hours). The free plan is 5,000 emails a month with no card.
Copy these into your project. The request is the same one the API reference documents; nothing is hidden behind a package.
// send-email.mjs (Node 18 or later: fetch is built in)
const ENDPOINT = "https://app.warmerly.com/api/v1/transactional/emails";
export class WarmerlyError extends Error {
constructor(status, code, message, details) {
super(`${status} ${code}: ${message}`);
this.status = status;
this.code = code;
this.details = details;
}
}
/**
* Send one email. `idempotencyKey` identifies the MESSAGE (for example `reset-${user.id}-${token.id}`),
* so retrying the same message never sends it twice.
*/
export async function sendEmail({ from, to, subject, html, text, idempotencyKey }) {
if (!idempotencyKey) throw new Error("idempotencyKey is required");
const res = await fetch(ENDPOINT, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.WARMERLY_TX_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify({ from, to, subject, html, text }),
});
const body = await res.json().catch(() => ({}));
if (res.status === 202) return body; // { id, status: "sent" | "queued", duplicate }
const err = body.error ?? {};
throw new WarmerlyError(res.status, err.code ?? "unknown", err.message ?? res.statusText, err.details);
}
import { sendEmail, WarmerlyError } from "./send-email.mjs";
try {
const result = await sendEmail({
from: "Acme <noreply@mail.example.com>",
to: "jane@example.org",
subject: "Reset your password",
html: '<p>Click <a href="https://example.com/reset/abc">here</a> to reset your password.</p>',
text: "Reset your password: https://example.com/reset/abc",
idempotencyKey: `reset-${user.id}-${token.id}`,
});
console.log(result.status, result.id); // "sent" or "queued", and the email id
} catch (err) {
if (err instanceof WarmerlyError) console.error(err.status, err.code, err.details);
else throw err;
}
Every refusal has a code in the response body, at error.code. These are the ones to handle, and whether to retry.
| Status | Code | Meaning | Retry? |
|---|---|---|---|
| 400 | bad_request | Invalid body, or the From address is not on a verified sending domain. | No: fix the request |
| 403 | forbidden | The key is limited to a different sending domain. | No |
| 403 | sending_paused | Sending is paused for the workspace (bounce or complaint rate, or under review). | No: contact support |
| 422 | content_rejected | The message was refused by abuse screening. | No, not unchanged |
| 429 | rate_limited | The hourly or daily sending limit was reached. | Later |
| 429 | quota_exceeded | The plan's monthly allowance, plus the free buffer, is used up. | No: upgrade or wait |
| 502 | send_failed | It could not be sent or queued. details.interrupted says whether it may have been delivered. | Same key if interrupted is false |
| 503 | service_unavailable | Transactional sending is not enabled for the workspace yet. | No |
Full details, including the sending limits, are in the transactional email API reference.
Make an HTTPS request to the provider's API. Warmerly's transactional API is REST only: POST to /api/v1/transactional/emails with a bearer key and a JSON body, as in the function above.
No. Warmerly's transactional API is one HTTP request, so there is nothing to install. The code on this page is the whole integration.
Not yet. The send request takes from, to, cc, bcc, reply_to, subject, html or text, headers and tags. Attachments, SMTP relay, delivery webhooks and templates are not available today.
If nothing was handed over, the email is queued rather than dropped. The request still answers 202 with a status of queued, and Warmerly retries it after 1, 5, 15 and 60 minutes, then hourly, for up to 24 hours.
Create a free account, verify a sending domain and send a test. 5,000 emails a month free, no card.