Email template variables: building an order confirmation end to end
Published Updated 17 min readBy the Rasket team
Written for developers and AI automation builders

Email template variables are the placeholders, such as {{first_name}}, that a stored template fills from the data each send passes. Getting them right takes three habits worth keeping on any provider: declare every variable with a type and a fallback, refuse a send that is missing one, and decide where loops and plurals live, in the template engine if it has them or in code you can test.
Get them wrong and the customer sees it: an order confirmation that reads Hi {{frist_name}}, your 1 items are on the way. The rest of this post is a walkthrough on Rasket’s API, one order-confirmation template from a draft to a real send, and every response shown is one the API returned on 2026-10-17. Steps 1 and 2 are enough to ship. Step 3 is for receipts with line items, step 4 only if an AI agent sends, and step 5 before you edit live copy. The reference tables, every type and every limit, are in the templates reference.
Step 1: declare the variables
Give a variable a fallback when a generic word reads fine (“Hi there”) and none when the email is wrong without it, like an order number. Declare string or number for anything a customer reads. The list and object types print compact JSON, brackets and all, which reads badly in customer copy, so this template has none. Creating and publishing need a full_access key.
API="${RASKET_API_URL:-https://api.rasket.com}"
curl "$API/templates" \
-H "Authorization: Bearer $RASKET_DEPLOY_KEY" \
-H "User-Agent: acme-shop/1.0" \
-H "Content-Type: application/json" \
-d '{
"name": "Order confirmation",
"alias": "order-confirmation",
"from": "Acme Shop <orders@acme.example>",
"subject": "Order {{order_number}} is confirmed",
"html": "<p>Hi {{first_name}},</p><p>Thanks for order {{order_number}}: {{items_summary}}.</p><p>Total: {{total}}</p><p><a href=\"https://acme.example/orders/{{order_number}}\">View your order</a></p>",
"text": "Hi {{first_name}},\n\nThanks for order {{order_number}}: {{items_summary}}.\n\nTotal: {{total}}\n\nhttps://acme.example/orders/{{order_number}}",
"variables": [
{ "key": "first_name", "type": "string", "fallback_value": "there" },
{ "key": "order_number", "type": "string" },
{ "key": "items_summary", "type": "string" },
{ "key": "total", "type": "string" }
]
}'
# 201 {"object":"template","id":"01a148fc-1498-7c8a-afd7-3b4df1221075"}
# Version 1 is a draft. Publish it so sends can use it:
curl -X POST "$API/templates/order-confirmation/publish" \
-H "Authorization: Bearer $RASKET_DEPLOY_KEY" \
-H "User-Agent: acme-shop/1.0"Every {{key}} the subject and bodies use must be declared, and every declared variable must be used. A draft whose HTML says {{frist_name}} is refused with 422 validation_error and one entry per key, so the typo dies when you save it.
Pitfall: the campaign merge-tag syntax
Rasket has a second grammar, {{{FIRST_NAME|there}}}, for campaigns and automations, which fill it from the contact record. POST /emails does not fill it, and nothing warns: a template holding it is sent with the braces showing. In a template your code sends, use {{key}} only. The pre-publish check in step 5 fails the build on a triple brace. (A third form, {{brand.company_name}}, comes from your brand kit and is filled everywhere; the free email templates post covers it.)
Step 2: send it and handle the refusals
Send every value as a JSON string or a JSON number, whatever its declared type, and keep each string to at most 2,000 characters, as the emails reference states. The template supplies from and subject, and a request that sets them wins. The Idempotency-Key header makes a retry safe for 24 hours (the idempotency post has the details). The API refuses a request with no User-Agent with a 403; curl and Node send one, and naming your app makes the request log readable. A sending_access key is enough here.
API="${RASKET_API_URL:-https://api.rasket.com}"
curl "$API/emails" \
-H "Authorization: Bearer $RASKET_SEND_KEY" \
-H "User-Agent: acme-shop/1.0" \
-H "Idempotency-Key: order-1001-confirmation" \
-H "Content-Type: application/json" \
-d '{
"to": ["ronald@example.com"],
"template": {
"id": "order-confirmation",
"variables": {
"first_name": "Ronald",
"order_number": "1001",
"items_summary": "2 × Blue mug, 1 × Tea towel & cloth",
"total": "$42.00"
}
}
}'
# 200 {"id":"01a148fc-1755-77ab-8571-446c3ce07fc5"}A bad value is refused before anything renders. These four are the ones you will meet first; the second row comes from a template that declares item_count as a number:
"total": true 400 validation_error template.variables.total: Invalid input
"item_count": "three" 422 invalid_parameter Template variable item_count must be a number.
order_number left out 422 missing_required_field Template variable order_number is required.
"coupon": "SAVE10" 422 invalid_parameter Template variable coupon is not declared on this template.A 400 means the JSON has the wrong shape: a real boolean, array or object where only a string or number may go. Every rule about the value itself is a 422: a missing, undeclared or wrongly typed variable, and a string over 2,000 characters. Neither sends, and the same value gets the same answer, so fix the value and never retry it as it is. Retry a 429, a 5xx or a dropped connection with backoff and the same Idempotency-Key.
A value prints as sent. A number has no currency, separator or fixed decimals: 1234.5 prints 1234.5, so format money in your code and send a string, as total does. A variable declared boolean takes "true", "false", 1 or 0, and prints true or false.
Pitfall: values in links and subjects
Rasket HTML-escapes every value in the html body, the encoding OWASP’s XSS prevention cheat sheet prescribes, and leaves the subject and text part as written. That makes a value safe as text. It does not make it safe as a whole URL: a send accepted javascript:alert(1) in href="{{tracking_url}}" and stored it as written. Keep the scheme and host in the template, as step 1 does with https://acme.example/orders/{{order_number}}, and run a value that lands in a URL through encodeURIComponent before you send it, so it cannot change the path or the query.
A filled-in subject with a line break is refused with 422 invalid_parameter, because a header field ends at CRLF (carriage return, line feed) under RFC 5322 and a value could otherwise start a new header. Rasket also caps the filled-in subject at 998 bytes, its own limit. RFC 5322’s 998 applies per line, header name included, and long headers are folded. A value is never scanned again, so {{total}} typed into a form lands as that literal text.
Step 3: add the line items
A receipt with an item table needs your code, or a one-line summary passed as a string. The {{key}} grammar has no loop and no condition. Handlebars has #each and #if and Liquid has a for tag; Rasket’s API path has neither, so the decision stays in code you can test. Rasket’s one loop is a Repeat block in an automation email, and automation emails skip customers who unsubscribed, so it is the wrong place for receipts (the suppression post explains why).
Option A: render the rows and send html
Use this for a table, links or images. Without a template, escaping is your job, so the sample escapes every value it writes into the HTML and keeps money in whole cents.
// Option A: render the line items in your own code and send finished html + text.
// Node 22 or later, as an .mjs file. Needs RASKET_SEND_KEY.
const API = process.env.RASKET_API_URL ?? "https://api.rasket.com";
const order = {
id: "1002",
email: "ronald@example.com",
firstName: "Ronald",
items: [
{ title: "Blue mug", quantity: 2, unitCents: 1200 },
{ title: "Tea towel & cloth", quantity: 1, unitCents: 1800 },
],
};
// You are the template engine now, so escaping every value is your job.
const escapeHtml = (s) =>
String(s).replace(/[&<>"']/g, (c) =>
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c],
);
const usd = (cents) =>
new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }).format(cents / 100);
const lineCents = (item) => item.quantity * item.unitCents;
const totalCents = order.items.reduce((sum, item) => sum + lineCents(item), 0);
const rows = order.items
.map(
(item) =>
`<tr><td>${escapeHtml(item.title)}</td><td>${item.quantity}</td>` +
`<td align="right">${usd(lineCents(item))}</td></tr>`,
)
.join("");
const lines = order.items
.map((item) => `${item.quantity} × ${item.title} ${usd(lineCents(item))}`)
.join("\n");
const res = await fetch(`${API}/emails`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.RASKET_SEND_KEY}`,
"User-Agent": "acme-shop/1.0",
"Content-Type": "application/json",
"Idempotency-Key": `order-${order.id}-confirmation`,
},
body: JSON.stringify({
from: "Acme Shop <orders@acme.example>",
to: [order.email],
subject: `Order ${order.id} is confirmed`,
html:
`<p>Hi ${escapeHtml(order.firstName)},</p><table>${rows}` +
`<tr><td colspan="2"><b>Total</b></td><td align="right"><b>${usd(totalCents)}</b></td></tr>` +
`</table>`,
text: `Hi ${order.firstName},\n\n${lines}\n\nTotal: ${usd(totalCents)}`,
}),
});
// A proxy can answer a 502 with HTML, so read the body without trusting it to be JSON.
const sent = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(`${res.status} ${sent.name ?? res.statusText}: ${sent.message ?? ""}`);
console.log(res.status, sent.id);It prints 200 and the email id, and the stored HTML holds Tea towel & cloth. A refusal throws with its status and name, so a failed send cannot pass as a sent one.
Option B: pass one plain-text summary
Use this when a sentence of items is enough and the copy should stay in the versioned template. The template escapes the value, so markup would show as visible tags. Plurals work the same way: pass “1 item” or “3 items” as a finished string.
// Option B: keep the template and pass the items as one plain-text string.
// Node 22 or later, as an .mjs file. Needs RASKET_SEND_KEY.
const API = process.env.RASKET_API_URL ?? "https://api.rasket.com";
const MAX_VALUE = 2000; // characters per variable value
const items = [
{ title: "Blue mug", quantity: 2 },
{ title: "Tea towel & cloth", quantity: 1 },
];
let summary = items.map((item) => `${item.quantity} × ${item.title}`).join(", ");
if (summary.length > MAX_VALUE) {
summary = items.length === 1 ? "1 item" : `${items.length} items`;
}
const res = await fetch(`${API}/emails`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.RASKET_SEND_KEY}`,
"User-Agent": "acme-shop/1.0",
"Content-Type": "application/json",
"Idempotency-Key": "order-1003-confirmation",
},
body: JSON.stringify({
to: ["ronald@example.com"],
template: {
id: "order-confirmation",
variables: { order_number: "1003", items_summary: summary, total: "$42.00" },
},
}),
});
// A proxy can answer a 502 with HTML, so read the body without trusting it to be JSON.
const sent = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(`${res.status} ${sent.name ?? res.statusText}: ${sent.message ?? ""}`);
console.log(res.status, sent.id);The sent HTML reads Thanks for order 1003: 2 × Blue mug, 1 × Tea towel & cloth.
Step 4: give an agent a tool that sends only this email
Give the agent a tool that can send this one template, not a general send tool. No API key permission does that for you: a sending_access key cannot read, edit or publish templates, but it can still send any HTML. Our hosted MCP server is closer: a connection granted only the emails:send scope lists just send_email and describe_setup, and send_email takes a template and its variables. It also takes html and any recipient, so nothing pins it to one email.
The decision rule: if a scoped grant is enough, connect the agent to the hosted server as the MCP agent post shows. If the agent may only ever send this email, run your own MCP server with one tool. It takes two short files: a deploy step that reads the schema with the full_access key, and a server that holds only a sending_access key.
Part 1: save the schema in your deploy
The published version is the one a send uses, so its declared variables are the tool’s contract. Reading them needs the full_access key, so this runs in the pipeline and the running server never holds that key.
// Deploy step: save the published version's variables. Runs with the full_access key,
// which stays in the pipeline. Node 22 or later, as an .mjs file. Needs RASKET_DEPLOY_KEY.
import { writeFile } from "node:fs/promises";
const API = process.env.RASKET_API_URL ?? "https://api.rasket.com";
const ALIAS = "order-confirmation";
const headers = {
Authorization: `Bearer ${process.env.RASKET_DEPLOY_KEY}`,
"User-Agent": "acme-deploy/1.0",
};
// Versions come newest first, 100 to a page; the published one can be several pages back.
// A variable lists fallback_value only when it has one: {"key":"first_name","type":"string",
// "fallback_value":"there"} next to {"key":"order_number","type":"string"}.
let published;
let cursor = "";
while (!published) {
const res = await fetch(`${API}/templates/${ALIAS}/versions?limit=100${cursor}`, { headers });
const page = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(`${res.status} ${page.name ?? res.statusText}: ${page.message ?? ""}`);
published = page.data.find((version) => version.published);
if (!published && !page.has_more) throw new Error(`${ALIAS} has no published version`);
cursor = `&after=${page.data.at(-1)?.id}`;
}
const variables = published.variables.map((v) => ({
key: v.key,
type: v.type,
required: !("fallback_value" in v),
}));
// Written next to this script; keep it beside server.mjs, which reads it from there.
await writeFile(
new URL(`./${ALIAS}.tool.json`, import.meta.url),
JSON.stringify({ alias: ALIAS, version: published.version_number, variables }, null, 2),
);
console.log(`${ALIAS} version ${published.version_number}: ${variables.map((v) => v.key).join(", ")}`);
// order-confirmation version 1: first_name, order_number, items_summary, total
// With a sending_access key: Error: 401 restricted_api_key: This key has sending access only, …
// With nothing published: Error: order-confirmation has no published versionA sending_access key gets 401 restricted_api_key here, as on every template route. It means the key works but is the wrong kind, and a revoked key gets 403 restricted_api_key. That is the reverse of the usual HTTP reading, where a 401 asks for new credentials, so branch on the error name as well as the status: this 401 is a setup mistake to fix, and only the 403 calls for a new key.
Part 2: serve one tool over MCP
// An MCP server with one tool: send the order confirmation. It holds only a sending_access
// key and the schema the deploy saved. Node 22 or later, as an .mjs file. Needs RASKET_SEND_KEY.
// npm i @modelcontextprotocol/sdk@1.31.0 zod@4.6.7
// This SDK takes a whole zod object as inputSchema. An older 1.x release that wants a shape
// takes input.shape instead, which drops additionalProperties: false.
import { createHash } from "node:crypto";
import { readFile } from "node:fs/promises";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const API = process.env.RASKET_API_URL ?? "https://api.rasket.com";
const saved = JSON.parse(
await readFile(new URL("./order-confirmation.tool.json", import.meta.url), "utf8"),
);
const TYPES = {
string: () => z.string(),
number: () => z.number(),
boolean: () => z.boolean(),
list: () => z.array(z.unknown()),
object: () => z.record(z.string(), z.unknown()),
};
const input = z.strictObject({
to: z.email(),
...Object.fromEntries(
saved.variables.map((v) => [v.key, v.required ? TYPES[v.type]() : TYPES[v.type]().optional()]),
),
});
// The model sends natural JSON; the API takes strings or numbers, so convert here.
const toSendValue = (value) =>
typeof value === "string" || typeof value === "number"
? value
: typeof value === "boolean"
? String(value)
: JSON.stringify(value);
const server = new McpServer({ name: "acme-email", version: "1.0.0" });
server.registerTool(
"send_order_confirmation",
{
description: "Send the order confirmation to one customer. Fill only the listed fields.",
inputSchema: input,
},
async ({ to, ...variables }) => {
const body = JSON.stringify({
to: [to],
template: {
id: saved.alias,
variables: Object.fromEntries(
Object.entries(variables).map(([key, value]) => [key, toSendValue(value)]),
),
},
});
const res = await fetch(`${API}/emails`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.RASKET_SEND_KEY}`,
"User-Agent": "acme-agent-tools/1.0",
"Content-Type": "application/json",
// The same call made twice sends one email.
"Idempotency-Key": createHash("sha256").update(body).digest("hex"),
},
body,
});
// A refusal names the field, so the model can fix the value and call again.
return { content: [{ type: "text", text: `${res.status} ${await res.text()}` }], isError: !res.ok };
},
);
// stdout carries the protocol, so the server prints nothing of its own.
await server.connect(new StdioServerTransport());- The schema comes from the template. Every variable without a fallback is required, and
z.strictObjectpublishesadditionalProperties: false, so the model cannot invent a variable. - The model sends natural JSON. A model asked for a list sends an array, which the API refuses with a
400. The tool turns booleans into"true"or"false"and lists and objects into JSON strings. - A retry sends nothing new. The idempotency key is a hash of the request, and zod returns the fields in schema order, so a model that repeats a call, in any argument order, gets the first answer back for 24 hours. That is what an order confirmation wants. If the same customer may need the same email twice, add something that differs, such as a message id, to the hashed body.
- The key never reaches the model. It sits in the server’s environment, and the model sees only the tool’s name and schema.
Any MCP client that starts a stdio server can run it. For Claude Code, put this in the project’s .mcp.json; Claude Desktop reads the same mcpServers block from its own config file, with the key written in instead of read from your shell and an absolute path to server.mjs, because it does not start servers from your project:
{
"mcpServers": {
"acme-email": {
"command": "node",
"args": ["./tools/server.mjs"],
"env": { "RASKET_SEND_KEY": "${RASKET_SEND_KEY}" }
}
}
}Called through the SDK’s own client, the server gave these answers:
tools/list required ["to","order_number","items_summary","total"], additionalProperties false
call 200 {"id":"01a14b7f-2c35-7cae-beaa-5fecc697a04d"}
the same call again 200 {"id":"01a14b7f-2c35-7cae-beaa-5fecc697a04d"} (one email, not two)
plus "gift_note" refused by the server: Unrecognized key: "gift_note"
total of 2001 chars 422 invalid_parameter: Template variable total may not be longer than 2000 characters.The model still chooses the recipient. If the server can reach your order data, take to out of the schema and look the address up from order_number, so the agent can only confirm a real order to the person who placed it.
Publish only from the deploy step that writes the snapshot. A publish from the dashboard that adds a required variable makes the tool’s sends fail with 422 missing_required_field, and one that removes a variable fails them with 422 invalid_parameter, until the next deploy.
A 200 means accepted, not delivered. Delivery, bounce and complaint arrive later as webhook events (the webhooks post). GET /emails/{id} shows the latest as last_event, but it needs a full_access key, so let your app read it rather than giving the agent’s server that key. The MCP agent post covers the rest of letting an agent send: confirmation before a send, and treating inbound mail as untrusted.
Step 5: change the copy without breaking production
Edit a draft, run the checks, send a test, then publish. Sends only ever use the published version, and a send cannot name a version, so to tie copy to a release, make the publish a step in your deploy. An email already accepted keeps the bodies it was rendered with. A live template keeps every version for as long as it exists, so any of them can be restored.
API="${RASKET_API_URL:-https://api.rasket.com}"
AUTH=(-H "Authorization: Bearer $RASKET_DEPLOY_KEY" -H "User-Agent: acme-shop/1.0")
# Edit the published template. This opens draft version 2; sends keep using version 1.
curl -X PATCH "$API/templates/order-confirmation" "${AUTH[@]}" \
-H "Content-Type: application/json" \
-d '{ "subject": "Thanks, {{first_name}}: order {{order_number}} is in" }'
# After the checks and a test send: make version 2 the one sends use.
curl -X POST "$API/templates/order-confirmation/publish" "${AUTH[@]}"
# Roll back: copy version 1 into a new draft (version 3), then publish it.
curl -X POST "$API/templates/order-confirmation/versions/1/restore" "${AUTH[@]}"
curl -X POST "$API/templates/order-confirmation/publish" "${AUTH[@]}"A canary needs a second alias, because versions are for restoring, not routing. Publish the new copy as order-confirmation-next, have your code send it for a share of orders, and copy it onto the main alias once it holds.
Before the publish, run a check in CI. POST /templates/{id}/checks renders the draft with the values you give it and fails a link that runs code, so give it the worst values a customer could send. It does not flag {{{FIRST_NAME|there}}}, so the script looks for {{{ itself:
// Run before publishing: fail the build on merge tags the API will not fill, and on any
// check that fails with the worst values a customer could send.
// Node 22 or later, as an .mjs file. Needs RASKET_DEPLOY_KEY (full_access).
const API = process.env.RASKET_API_URL ?? "https://api.rasket.com";
const ALIAS = "order-confirmation";
const headers = {
Authorization: `Bearer ${process.env.RASKET_DEPLOY_KEY}`,
"User-Agent": "acme-ci/1.0",
"Content-Type": "application/json",
};
const call = async (path, init) => {
const res = await fetch(`${API}${path}`, { headers, ...init });
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(`${res.status} ${body.name ?? res.statusText}: ${body.message ?? ""}`);
return body;
};
const template = await call(`/templates/${ALIAS}`);
const problems = [];
for (const field of ["subject", "html", "text"]) {
if (template[field]?.includes("{{{")) problems.push(`${field} has a {{{merge tag}}} the API will not fill`);
}
const hostile = { order_number: "javascript:alert(1)", items_summary: "<b>x</b>", total: "$0" };
const { checks } = await call(`/templates/${ALIAS}/checks`, {
method: "POST",
body: JSON.stringify({ variables: hostile }),
});
for (const check of checks) {
if (check.status === "fail") problems.push(`${check.code}: ${check.message}`);
}
console.log(problems.length === 0 ? "ok" : problems.join("\n"));
process.exitCode = problems.length === 0 ? 0 : 1;
// On order-confirmation: ok
// On a copy with {{{FIRST_NAME|there}}} and href="{{order_number}}", exit code 1:
// html has a {{{merge tag}}} the API will not fill
// link_scheme: 1 link runs code instead of opening a page: “javascript:alert(1)”. Mail clients remove these.Template routes draw on the same per-team rate limit as your sends, so a CI job that checks many templates at once can make production sends answer 429 rate_limit_exceeded. Check them one at a time and wait out retry-after on a 429; the rate limits post has the numbers and a backoff loop.
Then send a test. It renders the current version, unpublished draft included, to one to five addresses with [Test] before the subject, and counts as a send. Publish once someone has read it in a real mail client; the welcome email guide lists what to look for in a first email.
API="${RASKET_API_URL:-https://api.rasket.com}"
curl "$API/templates/order-confirmation/test" \
-H "Authorization: Bearer $RASKET_DEPLOY_KEY" \
-H "User-Agent: acme-shop/1.0" \
-H "Idempotency-Key: order-confirmation-test-v3" \
-H "Content-Type: application/json" \
-d '{
"to": ["you@example.com"],
"variables": { "order_number": "TEST-1", "items_summary": "2 × Blue mug", "total": "$24.00" }
}'
# 202 {"object":"template_test","template_id":"01a148fc-1498-7c8a-afd7-3b4df1221075","version_number":3,
# "data":[{"id":"01a148fc-2745-7486-8156-767b2d74e808","to":"you@example.com"}]}Skip stored templates when an email is mostly logic or is sent once: render it in code with the engine you already use and send html and text, as in option A.
Frequently asked questions
How do I send one template in several languages when there are no conditionals?
Keep one template per language, each with its own alias such as order-confirmation-fr, and let your code pick the alias from the customer's locale. Each language then has its own versions, fallbacks and test sends, and a change to one cannot break another.
Does a template need a plain-text version if I only write HTML?
No. A published version with no text, or an empty one, gets the same automatically generated plain-text part that a send with only html gets, so every email still goes out with both parts. Write your own text when you want to control its wording.
What do campaign merge tags print when a contact's field is empty?
The inline default after the pipe, then the contact property's fallback_value, then nothing. A campaign's checks stop the send only for a tag that names no contact field or property and has no default, so a real field that is empty for some contacts is not flagged: give it an inline default.
What else does the checks endpoint look at?
Besides variables and link schemes, POST /templates/{id}/checks reports on well-formed links, the subject, image alt text, image weight, the balance of text and images, and the unsubscribe link. On a template, only a link that goes nowhere or runs code is a failure; everything else is a warning at most.
How do I preview a template as one particular contact?
In the dashboard editor, Preview as picks one of ten recent contacts and greys out the blocks that contact's show-when rules would hide. The API preview, POST /templates/{id}/preview, takes sample values only and has no contact to decide those rules for.
What does the API answer when an Idempotency-Key comes back with a different body?
409 invalid_idempotent_request: the key was already used with a different payload, and nothing is sent. A second request with the same key while the first is still running gets 409 concurrent_idempotent_requests. Build the key from what makes the email unique, such as the order id, so a changed body means a new key.
Can someone on my team who does not code edit the copy?
Yes, in the dashboard editor. Their edit is a draft until someone publishes it, and sends keep using the published version, so a developer can run the checks and a test send before it goes live.
Sources
- Emails API reference: POST /emails — Rasket, read 2026-10-17
- Templates API reference: GET /templates/{id}/versions — Rasket, read 2026-10-17
- Cross Site Scripting Prevention Cheat Sheet — OWASP Cheat Sheet Series, read 2026-10-17
- RFC 5322: Internet Message Format (October 2008) — RFC Editor, read 2026-10-17
- Built-in Helpers — Handlebars, read 2026-10-17
- Iteration — Liquid template language, read 2026-10-17
Related
- Templates — Versioned email content with typed variables, addressed by ID or alias.
- Emails — Send, batch, retrieve, list, reschedule, cancel, attachments.
- Let an AI agent send email with MCP — Connect an agent to an MCP email server: the OAuth sign-in for your own loop, the send_email call, prompt injection, and a person approving every send.
- Email suppression list: what it is, how to use it — What goes on an email suppression list, which entries block receipts and password resets, what a suppressed send returns, and how to manage it by API.
- Free email templates: wording for 8 everyday emails — How to pick and adapt a free email template for welcome, launch, newsletter, order, shipping, cart, gift card and reset emails, with wording for any tool.
- How to write a welcome email that gets read — Welcome email best practices: what to write, how to protect the signup form from bots, example emails and sequences, and how to measure without opens.