Email Templates
Write transactional emails once in the control panel, in code with React Email or in the visual editor, publish them as versions, and send them by alias through the API.
Overview
An email template is an email stored in your project: a subject, a body, and the variables each send fills in. Your code names the template and passes the values, and Cosmoner renders the email and sends it:
{
"credentialId": "your-credential-id",
"to": "[email protected]",
"template": {
"id": "order-receipt",
"variables": { "customer": "Ada", "orderNumber": "A-1042" }
}
}Use a template rather than sending your own HTML when:
- people other than developers change the wording. Anyone with the developer role can edit and publish a template in the control panel, with no deploy of your application.
- you want a history and a way back. Every publish is a numbered version, and rolling back takes effect on the next send.
- you want the data checked. A send whose variables do not match what the template declares is refused before anything goes out, rather than delivered with a blank where a name should be.
Sending your own subject and html still works exactly as before, and suits
one-off messages or emails you already render elsewhere. Templates are free:
emails sent from them are billed like any other, one email per recipient. See
Email Pricing.
Prerequisites
- A verified sending domain and an SMTP credential on it — see SMTP Setup. The credential decides the sender address, for templates as for any other send.
- The developer role or above to create, edit and publish templates. Viewers can open them read-only.
Create a Template
Open Email → Templates in your project and choose New template. Give it:
- a name, which is only for people;
- an alias, which is what your code sends by (see below);
- optionally a folder;
- an editor:
- Visual — you write the email like a document, inserting variables, conditions, repeated blocks, buttons and images. You can convert it to code later.
- Code — a React Email component in TSX, for full control over the layout.
A new template starts from a short example you can replace. Nothing can be sent from it until you publish it.
Aliases
The alias is the stable handle your code names in template.id, such as
order-receipt or password-reset. It must:
- use only lower-case letters, digits,
-and_; - start with a letter or a digit;
- be at most 64 characters;
- be unique in the project.
You can change an alias under the template's Settings tab, but sends that
name the old alias are refused from that moment, so deploy the new name in your
code first. Every template also has an id that never changes; template.id
accepts either, and a send by id is not affected by an alias change.
Variables
Variables are the values a send supplies. Each one has a name, a type, and whether it is required.
| Type | A send passes | Example |
|---|---|---|
string | Text, up to 10,000 characters | "Ada" |
number | A number | 2, 19.99 |
boolean | true or false | true |
url | An http, https or mailto address, up to 2,048 characters | "https://example.com/track/A-1042" |
email | An email address | "[email protected]" |
datetime | An ISO 8601 date and time with a time zone offset | "2026-11-03T14:30:00Z" |
list | An array of items, each with the same declared fields | [{ "name": "Lamp", "qty": 2 }] |
Required and optional. A required variable must be in every send. An
optional one may be left out or sent as null; it then renders as empty, or as
its default when the template declares one.
Lists. A list declares the fields every item has, each with its own type and whether it is required. Items are flat — a list cannot contain another list — and a list holds at most 500 items, or fewer if the template sets its own maximum.
Names start with a letter and use letters, digits and underscores, up to 64
characters. A few reserved words, such as true, false, null and empty,
cannot be used; the editor tells you if you pick one.
What a send is checked against. template.variables is checked against the
variables of the version being sent before anything goes out. A missing required
value, a value of the wrong type, and a name the template does not declare are
all refused — an unknown name is usually a typo, and would otherwise leave a gap
in a delivered email. See Errors.
Every value is escaped as it is put into the email, so a variable is always
text: a customer whose name contains <b> gets those characters, not bold type.
In the Visual Editor
Declare variables on the Variables & data tab: give each a name, a type, whether it is required and a sample value. For a list, add the fields its items have.
In the email, type / to insert a block. Alongside text, headings, lists,
columns, buttons and dividers, the menu has the template blocks:
- Variable inserts a chip that is replaced by the send's value. Select a chip to change its format: as sent, UPPERCASE, lowercase or Capitalized.
- Link to a variable makes a link whose address each send supplies. Only
urlvariables can be used, so the address is always a web link. A button can point to a variable the same way. - Show if… wraps a block that appears only when a variable has a value. The
block is left out when the variable is missing, empty, or
false. - Repeat for each… repeats a block for every item of a list variable.
Inside it, insert the item's fields as
item.field. - Image inserts an image already hosted on the web, by its
https://address, with alt text and an optional width and link.
The Subject field above the email takes variables too: write {{ name }}
where the value goes. Preview text, the line most inboxes show after the
subject, is optional.
In the Code Editor
A code template is a React Email component that exports three things: its
variables, its subject, and the component itself as the default export.
import { defineVariables, Each, If, v, Var, type VariablesOf } from "@cosmoner/email-kit";
import { Body, Button, Container, Html, Section, Tailwind, Text, pixelBasedPreset } from "react-email";
export const variables = defineVariables({
customer: v.string({ required: true, sample: "Ada" }),
orderNumber: v.string({ required: true, sample: "A-1042" }),
placedAt: v.datetime({ required: true }),
items: v.list(
{
name: v.string({ required: true, sample: "Desk lamp" }),
qty: v.number({ required: true, sample: 2 }),
},
{ maxItems: 100 },
),
trackingUrl: v.url(),
discountCode: v.string({ default: "none" }),
});
type Props = VariablesOf<typeof variables>;
export const subject = (props: Props) => `Order ${props.orderNumber} confirmed`;
export default function OrderReceipt(props: Props) {
return (
<Html>
<Tailwind config={{ presets: [pixelBasedPreset] }}>
<Body className="bg-white font-sans">
<Container className="mx-auto max-w-[480px] px-6">
<Text>
Thanks {props.customer}, your order was placed on{" "}
<Var value={props.placedAt} format={{ date: "%b %d, %Y" }} />.
</Text>
<Section>
<Each of={props.items}>
{(item) => (
<Text>
{item.name} × {item.qty}
</Text>
)}
</Each>
</Section>
<If when={props.trackingUrl}>
<Button href={props.trackingUrl} className="rounded bg-black px-4 py-2 text-white">
Track your parcel
</Button>
</If>
<Text>
Discount code: <Var name="discountCode" format="upcase" />
</Text>
</Container>
</Body>
</Tailwind>
</Html>
);
}defineVariablesdeclares the variables with the buildersv.string,v.number,v.boolean,v.url,v.email,v.datetimeandv.list. Each takesrequired,sample,defaultanddescription;v.listtakes its item fields first, thenrequired,maxItemsandsample.VariablesOfturns the declaration into your component's props.subjectis a string, or a function of the props.- Render a value as
{props.customer}, or with<Var>to format it:"upcase","downcase","capitalize",{ date: "%b %d, %Y" }(strftime codes; dates are formatted in UTC, in English),{ round: 2 },{ truncate: 80 }or{ default: "there" }.<Var name="…">names a variable instead of taking a prop. <If when={…}>renders its children only when the variable has a value, with an optionalfallback.<Each of={…}>repeats its children for every item of a list. Lists cannot be nested.- Tailwind classes inside
<Tailwind>are turned into inline styles, as email clients need. - More than one file. Add files under Files — up to 50 — and import them
from the entry file with a relative path such as
./components/footer. A template can importreact-email(or@react-email/components),@cosmoner/email-kitand its own files.
Values are filled in when each email is sent, not when you write it, so a
variable has to reach the email unchanged. Use <Var>, <If> and <Each>
rather than JavaScript on a variable's value: calling .toUpperCase() or
.map() on it, testing it with && or .length, putting it in a style
attribute or a <style> element, or using a variable that is not a url as a
link's href are refused when the template builds, with a message saying what
to change. A template too heavy to render quickly — a long list rendering many
fields per item, say — is refused the same way.
The Variables & data tab lists what the template declares once it builds.
Sample Data
Previews and test sends need values. On the Variables & data tab, write
sample data as JSON, exactly as you would pass it in template.variables —
Start from sample values fills it in from the samples the variables
declare. Save sample data keeps it with the template for everyone who opens
it. Left empty, previews use the declared samples.
Preview and Test Sends
The preview beside the editor updates as you type, and switches between Desktop, Mobile and Plain text. It renders the email exactly as a send does, with your sample data. Every email sent from a template has a plain-text part, generated from its HTML.
To see the email in a real inbox, choose Send test:
- send either the draft, as previewed or the published version;
- pick the sending address from your SMTP credentials;
- send to up to 5 addresses.
A test email uses the preview's sample data and has [Test] before its
subject. It is a real email: it counts toward your
send limits and is billed like any
other.
Saving and Editing Together
Your draft saves automatically as you edit, and the editor shows when it is saved. The draft is only yours to send once you publish it — sends keep using the published version in the meantime.
If someone else saves the same template after you opened it, saving stops and the editor asks what to keep: Load theirs replaces your edits with their version, Keep mine saves yours over theirs. Nobody's work is overwritten without one of you choosing.
Publish
Publish turns the current draft into the next version — version 1, 2, 3 and so on — and makes it the one sends use, from the very next request. The button is available once the draft is saved and builds without errors. The draft and the published version then match until the next edit.
Publishing is rate-limited per project: if you publish many times in a short period, publishing is refused for a while, and you can try again later.
The header shows which version is live, or Not published for a template
that has never been published. A send naming a template that has never been
published is refused with 409 EMAIL_TEMPLATE_NOT_PUBLISHED.
Versions and Rolling Back
The Versions tab lists every published version, newest first, with the live one marked. Preview shows a version as it was published, rendered with your sample data.
Roll back to this makes an earlier version the one sends use, from the next request. Your draft stays as it is, and the next publish still gets the next version number. Roll forward again the same way. Through the API, the same action is Roll back to a version.
The 50 most recent versions are kept. Publishing a 51st removes the oldest — never the live one.
A send can also name a version explicitly with template.version, which pins
it: a later publish or rollback does not change what that send renders. Any
version that is still kept can be sent this way; once it is no longer kept, the
send is refused with 404 EMAIL_TEMPLATE_VERSION_NOT_FOUND.
Converting Visual to Code
A visual template can be converted to a code template at any time with Convert to code. The code renders exactly the same email, and you edit it as code from then on. This is one way: a code template cannot be turned back into a visual one. Versions published before the conversion keep sending unchanged.
Send with a Template
Send through the API with an API key carrying
email:read, email:write and email-templates:read:
curl https://api.cosmoner.com/v1/projects/$PROJECT_ID/email/send \
-H "Authorization: Bearer $COSMONER_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"credentialId": "your-credential-id",
"to": "[email protected]",
"replyTo": "[email protected]",
"template": {
"id": "order-receipt",
"variables": {
"customer": "Ada",
"orderNumber": "A-1042",
"placedAt": "2026-11-03T14:30:00Z",
"items": [
{ "name": "Desk lamp", "qty": 2 },
{ "name": "Bulb", "qty": 4 }
],
"trackingUrl": "https://example.com/track/A-1042"
}
}
}'template.idis the alias or the template's id.template.versionis optional. Leave it out to send the published version; set it, for example"version": 3, to send that version instead.template.variablesholds the values, by name. Leave out optional ones you have no value for.- Leave out
subject,htmlandtext. The template supplies all three; a send that includes any of them is refused with422. - The sender is the credential's address, as for every send.
totakes up to 50 recipients andreplyToup to 5 addresses.
The response says which template and version went out:
{
"success": true,
"data": {
"messageId": "0100018f…",
"template": { "id": "clx2k4m6p0000abcd12345678", "alias": "order-receipt", "version": 4 }
}
}messageId is the same id that webhook events for the
email carry, so you can match deliveries, bounces and opens back to the send.
Sending a template goes through every check a send has: billing, sending reputation and your send limits. Template problems are reported by the send call itself, before the email is handed on for delivery — they never arrive later as a webhook event.
Errors
A refused send sends nothing, and a template error never uses any of your quota. The template is checked after the credential, billing and the hourly limit, so those errors come first.
| Status | error.code | What happened | What to do |
|---|---|---|---|
403 | INSUFFICIENT_SCOPE | The API key lacks email-templates:read, email:read or email:write | Add the missing scope to the key |
404 | EMAIL_TEMPLATE_NOT_FOUND | No template with that alias or id in the project — perhaps the alias was changed or the template deleted | Check the alias under the template's Settings |
404 | EMAIL_TEMPLATE_VERSION_NOT_FOUND | The project has no such version of the template, or it is older than the 50 kept | Send another version, or leave version out |
409 | EMAIL_TEMPLATE_NOT_PUBLISHED | The template has never been published, and no version was named | Publish it in the control panel |
422 | EMAIL_TEMPLATE_VARIABLES_INVALID | template.variables does not match what the version declares | Fix the values listed in error.details.issues |
422 | EMAIL_TEMPLATE_RENDER_FAILED | The rendered email grew past 256,000 bytes of HTML, or took too long to render | Send fewer list items, or simplify the template |
422 | VALIDATION_ERROR | The request itself is malformed — for example subject alongside template | Fix the request |
503 | EMAIL_TEMPLATE_UNAVAILABLE | The template could not be loaded for a moment | Retry — nothing was sent |
EMAIL_TEMPLATE_VARIABLES_INVALID lists each problem with the path of the value
it concerns:
{
"success": false,
"error": {
"code": "EMAIL_TEMPLATE_VARIABLES_INVALID",
"message": "The variables do not match what the template declares",
"details": {
"issues": [
{ "path": "items.0.qty", "message": "Invalid input" },
{ "path": "trackingUrl", "message": "must be an http, https or mailto URL" }
]
}
}
}The send endpoint is rate-limited. A 429 means you are sending too many
requests in a short time: back off and retry. The project's hourly, daily and
monthly limits are separate and answer 400; see
Send Limits. The
API reference lists every response of
Send an email.
Roles and API Key Scopes
| Action | Role | API key scopes |
|---|---|---|
| View templates, versions and folders | Viewer or above | email-templates:read |
| Create, edit, publish, roll back, test send and delete templates; manage folders | Developer or above | email-templates:read and email-templates:write |
| Send a template | Developer or above | email:read, email:write and email-templates:read |
Sending a template needs email-templates:read because the send reveals what
the template says. A key that can send mail cannot, by that alone, read or
change your templates, and a key that manages templates cannot send.
Editing a template's content, publishing and test sends happen in the control panel. Through the API you can list and read templates and their versions, create a template, change its name, alias, description, folder and sample data, roll back, delete, and manage folders — see the Email templates API reference. See Team Roles for everything each role can do.
Folders
Folders keep a long list of templates tidy. Create one with New folder, nest folders up to 5 levels deep, and use a template's Move to folder to file it. Folders that share a parent need different names.
A folder changes only where a template is listed. Moving a template never changes its alias, its versions or how it is sent. Deleting a folder deletes nothing in it: its templates and subfolders move up one level. If a subfolder would then sit next to a folder with the same name, the delete is refused until you rename or move it.
Search narrows the templates you are looking at by name or alias. Choose All templates to search every folder, or Not in a folder for the templates outside them.
Deleting a Template
Delete a template from its Settings tab, or from its menu in the list. Its
draft and every version are deleted, and sends naming it are refused with
404 EMAIL_TEMPLATE_NOT_FOUND from then on. This cannot be undone.
Limits
| Limit | |
|---|---|
| Templates per project | 200 |
| Folders per project | 100, nested up to 5 levels |
| Versions kept per template | 50 most recent |
| Template source | 256 KB; up to 50 files for a code template |
| Name / alias / description | 128 / 64 / 500 characters |
| Variables per template | 100 |
| Fields per list | 50 |
| Items per list | 500, or the list's own maximum |
string value | 10,000 characters |
url value | 2,048 characters |
template.variables in one send | 64 KB as JSON |
| Saved sample data | 64 KB as JSON |
| Rendered HTML | 256,000 bytes |
| Subject | 998 characters; line breaks become spaces |
| Recipients per send / per test send | 50 / 5 |