Sending Templated Emails With Variables via API
A templated email API separates the email design from the code that triggers it: the template lives on the server (layout, subject line, preview text), and your API call supplies only the data. You POST a template ID plus a variables object, and the provider renders and sends the message. This guide walks through a full implementation — authentication, a runnable send request, a stable variables contract, fallbacks, localization, idempotency and retries.
What a templated email API actually does
The core idea is a clean split of responsibilities. The template owns presentation: headings, hero image, product blocks, footer, subject line, preview text. Your application owns data: the customer's name, order number, line items, tracking URL. The API call is the seam between them.
This beats building HTML strings in application code for three practical reasons. First, a designer or marketer can edit the layout without a deploy — you change the template in the dashboard, and the next send picks it up. Second, one template can serve many locales and many use cases, because the variable payload changes per request. Third, variables give you a contract: you can validate what you send before it leaves your process, instead of discovering a broken greeting in a customer's inbox.
Mailerflow's transactional email API runs on a high-priority lane and supports templates with variables. Templates can be assembled in the visual block editor (headings, heroes, products, columns, social links) or generated with the AI email builder — describe the email you want and get a complete responsive design plus subject lines and preview text, which you then review and adjust. For framework-specific wiring, the same pattern applies whether you are calling from a queue worker or a route handler; if you are on Next.js, the route handler and server action walkthrough covers the integration points.
Prerequisites
Before writing code, get these in place:
- A Mailerflow account with a verified sending domain. Domain setup is guided through DKIM, SPF and DMARC with automatic verification. Do not send from an unverified domain — sending from an unverified domain is a common cause of deliverability problems.
- A scoped API key with send permission. Scoped keys limit the blast radius if a key leaks. Use a send-only key for production senders and a separate key for template management.
- Node.js 18 or later, or any HTTP client you are comfortable with. The examples below use the built-in
fetch, so there is no dependency to install for the first call. - A template created in the dashboard with named variables, and its template ID. Build it in the visual block editor or generate a starting point with the AI builder, then note the variable names you used.
- The OpenAPI spec, which is the source of truth for request shapes and field names. A JS SDK is also available if you prefer not to hand-roll HTTP calls.
Store keys in environment variables. Never commit them, and never ship a send-capable key to a browser bundle.
Setup and authentication
Authentication is a bearer token in the Authorization header. Set it once in your environment and read it at call time.
# .env — never commit this file
MAILERFLOW_API_KEY=mf_live_xxxxxxxxxxxxxxxxxxxx
MAILERFLOW_TEMPLATE_ORDER=order_confirmation_v3
// config.js
const API_BASE = process.env.MAILERFLOW_API_BASE ?? 'https://api.mailerflow.ai';
const API_KEY = process.env.MAILERFLOW_API_KEY;
if (!API_KEY) {
throw new Error('MAILERFLOW_API_KEY is not set');
}
function authHeaders(extra = {}) {
return {
Authorization: `Bearer ${API_KEY}`,
'Content-Type': 'application/json',
...extra,
};
}
module.exports = { API_BASE, authHeaders };
If you prefer the SDK, install it and initialize the client with the same key. The raw-fetch path is worth knowing regardless, because it makes the wire format obvious when something goes wrong. Check the developer docs for the current SDK package name and installation instructions.
Sending the first request (full code)
Here is a complete, copy-paste-ready send using fetch. Every field is commented so you can map it to your own data.
// send-order-confirmation.js
const { API_BASE, authHeaders } = require('./config');
async function sendOrderConfirmation({ to, order }) {
const body = {
// Template ID from the dashboard. One template, many sends.
template_id: process.env.MAILERFLOW_TEMPLATE_ORDER,
// Recipient. Some APIs accept an array here for multiple recipients;
// check the OpenAPI spec for the current shape before relying on it.
to: [{ email: to.email, name: to.name }],
// Everything the template needs to render. Names must match the
// variable names defined in the template.
variables: {
first_name: order.customerFirstName,
order_number: order.number,
order_total: order.formattedTotal,
currency: order.currency,
tracking_url: order.trackingUrl,
items: order.items.map((i) => ({
name: i.name,
quantity: i.quantity,
price: i.formattedPrice,
})),
},
};
const res = await fetch(`${API_BASE}/v1/transactional/send`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify(body),
});
if (!res.ok) {
const detail = await res.text();
throw new Error(`Send failed (${res.status}): ${detail}`);
}
// The response includes an identifier you can store for reconciliation.
const result = await res.json();
return result.message_id;
}
module.exports = { sendOrderConfirmation };
The SDK version of the same call is shorter, and it handles serialization and error shapes for you. The following snippet is pseudocode — confirm the exact constructor and method names against the SDK documentation:
// send-with-sdk.js (pseudocode)
const { Mailerflow } = require('@mailerflow/sdk');
const client = new Mailerflow({ apiKey: process.env.MAILERFLOW_API_KEY });
async function sendOrderConfirmation({ to, order }) {
const result = await client.transactional.send({
templateId: process.env.MAILERFLOW_TEMPLATE_ORDER,
to: [{ email: to.email, name: to.name }],
variables: {
first_name: order.customerFirstName,
order_number: order.number,
order_total: order.formattedTotal,
tracking_url: order.trackingUrl,
},
});
return result.messageId;
}
Confirm the exact endpoint path and field names against the current OpenAPI spec before shipping — APIs evolve, and the spec is the contract you should trust over any tutorial, including this one.
Designing the variables contract
Treat the variables object as an interface between your application and the template, and version it like one. A few conventions that hold up in production:
- Use
snake_casefor variable names and keep them consistent across templates. - Prefer flat over deeply nested. One level of arrays (like
items) is fine; three levels of nesting is a debugging session waiting to happen. - Put anything the template can derive in the template, not the payload. Pre-formatted prices and dates are the exception — format them in your app so the template does not need locale logic.
A TypeScript interface plus a validation step catches most mistakes before the request leaves your process:
// variables.ts
export interface OrderConfirmationVars {
first_name: string;
order_number: string;
order_total: string;
currency: string;
tracking_url?: string;
items: Array<{ name: string; quantity: number; price: string }>;
}
const REQUIRED: Array<keyof OrderConfirmationVars> = [
'first_name',
'order_number',
'order_total',
'items',
];
export function assertRequired(vars: Partial<OrderConfirmationVars>) {
const missing = REQUIRED.filter((k) => vars[k] === undefined || vars[k] === null);
if (missing.length) {
throw new Error(`Missing required template variables: ${missing.join(', ')}`);
}
}
Undefined variables typically render as empty or fall back to a default, depending on how the template is configured. That is exactly why the validation step matters: fail loudly in your app rather than silently in an inbox.
Fallbacks: what happens when a variable is missing
There are three layers of fallback, and you should use all three deliberately.
1. Defaults defined in the template. Many template engines support default filters, so a missing value renders something sensible instead of nothing. The syntax below is illustrative of a typical templating engine; check your provider's documentation for the exact syntax it supports.
<p>Hi {{ first_name | default: "there" }},</p>
<p>Your order {{ order_number }} is confirmed.</p>
{{#if tracking_url}}
<a href="{{ tracking_url }}">Track your package</a>
{{/if}}
2. Defaults supplied per request. When your application knows a better default than the template does — a display name, a locale-specific greeting — pass it explicitly.
variables: {
first_name: order.customerFirstName ?? 'there',
order_number: order.number,
}
3. Conditional blocks. Sections that depend on optional data should disappear entirely rather than render an empty shell. The {{#if tracking_url}} block above is the pattern: no tracking URL, no broken link. Again, confirm that your template engine supports this syntax.
The rule that keeps this manageable: required variables should fail loudly in your application before the API call, and optional variables should degrade gracefully. Silent empty strings are the worst outcome — they produce greetings like "Hi ," and buttons with no destination.
Localization: one template, many languages
There are two workable approaches, and the right one depends on your translation workflow.
Separate templates per locale, selected by a locale field on the contact or passed per request. This gives translators a full document to work with, lets subject lines and preview text be translated independently, and handles right-to-left layouts cleanly. The cost is more templates to keep in sync.
One template with conditional blocks per language. Fewer assets to maintain, but the template becomes harder to read, and every layout change has to be verified in every language. It works well for short, highly structured emails.
Separate templates are usually easier to maintain when you support several locales. Select one with a locale map and a fallback:
const TEMPLATES = {
'order_confirmation': {
en: 'order_confirmation_en_v3',
de: 'order_confirmation_de_v3',
fr: 'order_confirmation_fr_v3',
},
};
function resolveTemplate(kind, locale) {
const byLocale = TEMPLATES[kind];
return byLocale[locale] ?? byLocale.en; // fall back to a default locale
}
const templateId = resolveTemplate('order_confirmation', contact.locale);
Remember that subject lines and preview text need translating too — they are the first thing a recipient sees, and an English subject on a German email undoes the work. If you use the AI email builder to draft a localized variant, treat the output as a first draft and have a native speaker review it before it goes live. Date and currency formatting should happen in your application, not in the template, so each locale gets the right conventions.
Handling errors, retries and idempotency
This is the part that separates a demo from a system that runs unattended.
Classify the failure. A 4xx response means the request is wrong — a bad template ID, a missing required field, an invalid recipient. Retrying will not help; fix the request. A 5xx response or a network timeout means the send may or may not have happened, and retrying is appropriate.
Use an idempotency key. Generate a key that is deterministic per logical event — for example, the order ID combined with the template ID. If you retry after a timeout, the same key tells the API this is the same logical send, so you do not deliver two copies. Where you generate the key matters: it must be stable across retries, so derive it from your domain data, not from a random value created inside the retry loop. Check the OpenAPI spec for the exact header name the API expects (for example, Idempotency-Key or X-Idempotency-Key).
// retry-send.js
const { API_BASE, authHeaders } = require('./config');
const RETRYABLE = new Set([408, 429, 500, 502, 503, 504]);
async function sendWithRetry(payload, idempotencyKey, maxAttempts = 4) {
let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const res = await fetch(`${API_BASE}/v1/transactional/send`, {
method: 'POST',
headers: authHeaders({ 'Idempotency-Key': idempotencyKey }),
body: JSON.stringify(payload),
});
if (res.ok) {
return await res.json();
}
const detail = await res.text();
// 4xx (except 408/429) will not succeed on retry — fail fast.
if (!RETRYABLE.has(res.status)) {
throw new Error(`Permanent send failure (${res.status}): ${detail}`);
}
lastError = new Error(`Retryable failure (${res.status}): ${detail}`);
} catch (err) {
// Network errors land here and are retryable.
lastError = err;
}
if (attempt < maxAttempts) {
// Exponential backoff with jitter to avoid thundering herds.
const base = Math.min(2 ** attempt * 200, 5000);
const jitter = Math.random() * 200;
await new Promise((r) => setTimeout(r, base + jitter));
}
}
// Dead-letter path: persist for manual inspection instead of losing the send.
throw new Error(`Send failed after ${maxAttempts} attempts: ${lastError?.message}`);
}
Reconcile after the fact. The API response tells you the send was accepted; it does not tell you what happened next. Signed real-time webhooks with retries and delivery logs are how you confirm delivery, bounces and complaints. Store the identifier from the response so you can match webhook events back to your own records.
Testing before you go to production
A short checklist that catches most issues before customers do:
- Send to a seed inbox and check rendering across the major clients you care about.
- Test with missing variables, empty strings, very long values, unicode and emoji.
- Send one message per locale and read it as a native speaker would.
- Click every link and confirm tracking parameters survive.
- Deliberately omit an optional field to exercise the fallback path.
- Keep a fixture file of variable payloads and run it in CI so template changes cannot silently break callers.
After sending, Mailerflow's analytics show delivery, bounces, complaints, clicks and approximate opens per campaign, which is a quick way to confirm a batch behaved as expected.
Complete example: order confirmation with fallbacks and locale
This script ties everything together: locale selection, defaults, a deterministic idempotency key, retries and error logging.
// order-confirmation.js
const { API_BASE, authHeaders } = require('./config');
const TEMPLATES = {
en: 'order_confirmation_en_v3',
de: 'order_confirmation_de_v3',
fr: 'order_confirmation_fr_v3',
};
const RETRYABLE = new Set([408, 429, 500, 502, 503, 504]);
function resolveTemplate(locale) {
return TEMPLATES[locale] ?? TEMPLATES.en;
}
function buildVariables(order) {
return {
first_name: order.customerFirstName?.trim() || 'there',
order_number: order.number,
order_total: order.formattedTotal,
currency: order.currency,
tracking_url: order.trackingUrl ?? null, // conditional block hides if null
items: order.items.map((i) => ({
name: i.name,
quantity: i.quantity,
price: i.formattedPrice,
})),
};
}
async function sendOrderConfirmation(order, maxAttempts = 4) {
const payload = {
template_id: resolveTemplate(order.locale),
to: [{ email: order.customerEmail, name: order.customerName }],
variables: buildVariables(order),
};
// Deterministic per logical event: retries reuse the same key.
const idempotencyKey = `order-confirmation:${order.number}`;
let lastError;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const res = await fetch(`${API_BASE}/v1/transactional/send`, {
method: 'POST',
headers: authHeaders({ 'Idempotency-Key': idempotencyKey }),
body: JSON.stringify(payload),
});
if (res.ok) {
const { message_id } = await res.json();
console.log(`Sent order ${order.number} (message ${message_id})`);
return message_id;
}
const detail = await res.text();
if (!RETRYABLE.has(res.status)) {
console.error(`Permanent failure for ${order.number}: ${res.status} ${detail}`);
throw new Error(`Permanent send failure (${res.status})`);
}
lastError = new Error(`${res.status}: ${detail}`);
} catch (err) {
lastError = err;
}
if (attempt < maxAttempts) {
const base = Math.min(2 ** attempt * 200, 5000);
await new Promise((r) => setTimeout(r, base + Math.random() * 200));
}
}
console.error(`Giving up on ${order.number}: ${lastError?.message}`);
throw lastError;
}
module.exports = { sendOrderConfirmation };
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
401 Unauthorized |
Key revoked, wrong environment, or missing send scope | Issue a new scoped key and confirm the scope includes sending |
404 on a template ID |
Template belongs to a different environment or was deleted | Copy the ID from the dashboard you are actually sending from |
| Variables render empty | Name mismatch between payload and template | Compare keys character by character; add the validation step from earlier |
| Duplicate emails after a timeout | Idempotency key changed between attempts | Derive the key from domain data, not from inside the retry loop |
| Emails land in spam | Sending domain not verified, or DKIM/SPF/DMARC incomplete | Finish domain verification; new domains are subject to warm-up limits |
429 under load |
Sending faster than the current limit allows | Queue sends and let the retry wrapper back off |
FAQ
Can I send the same template to multiple recipients in one call? Some APIs accept an array of recipients, but the behavior — shared variables versus per-recipient variables — varies. Check the OpenAPI spec for the current request shape, and if you need per-recipient data, send individual requests with an idempotency key per recipient.
What happens if I pass a variable the template does not use? It is typically ignored. The reverse is the problem: a variable the template expects but your payload omits renders as empty or falls back to a default, which is why validating required variables in your app is worth the few lines of code.
Do idempotency keys expire? Retention windows vary by provider, and the spec is the place to confirm the current behavior. In practice, keys only need to survive your retry window, so a deterministic key derived from the event is safe.
How do I preview a template with sample data before sending? Build the template in the visual block editor, then render it in a staging environment with fixture data to check how variables appear. For automated checks, keep a fixture file of payloads and render against a test template in CI.
Can I use the API for marketing campaigns or only transactional mail? Mailerflow's transactional API is built for event-driven mail on a high-priority lane. For campaigns and automations, use the campaign tools, which include scheduling, A/B testing and send-time optimization.
Send your first transactional email with the Mailerflow REST API — scoped keys, idempotency and an OpenAPI spec. Start free.


