Templates API
A template stores a subject and a body with variables such as {{name}}. Your code sends it by ID with the values for one recipient, so the copy can change without a deploy.
Each template has a draft and numbered versions. Editing changes the draft only. Publishing copies the draft into the next version, and sends use the published version, so a change reaches your users only when you publish it. You can also manage templates in the dashboard, under Transactional, then Templates.
Last updated
The template object
{
"object": "template",
"id": "tpl_123",
"name": "Welcome",
"subject": "Welcome, {{name}}",
"html": "<p>Hi {{name}}, you are on the {{plan}} plan.</p>",
"text": null,
"fallbacks": { "plan": "Free" },
"variables": [
{ "key": "name", "required": true, "fallback": null },
{ "key": "plan", "required": false, "fallback": "Free" }
],
"published_version": 1,
"has_unpublished_changes": false,
"created_at": "2026-10-27T10:00:00.000Z",
"updated_at": "2026-10-27T10:00:00.000Z"
}| Field | Type | Description |
|---|---|---|
| id | string | Template ID. Starts with tpl_. |
| name | string | Your name for the template. Max 200 characters. Recipients never see it. |
| subject | string | Draft subject. Max 998 characters. May use variables. |
| html | string or null | Draft HTML body. Max 512 KB. |
| text | string or null | Draft plain-text body. Max 256 KB. At least one of html and text is set. |
| fallbacks | object | Default value per variable key, as strings. |
| variables | object[] | Every variable the draft uses, in order of first appearance, with key, required and fallback. |
| published_version | integer or null | The version a send uses. Null until the first publish. |
| has_unpublished_changes | boolean | True when the draft differs from the published version, or nothing is published yet. |
| created_at | string | ISO 8601 timestamp. |
| updated_at | string | ISO 8601 timestamp. |
Variables and escaping
A variable is a key in double braces: {{first_name}}. Keys use letters, digits and underscores, and are case-sensitive. Spaces inside the braces are allowed, so {{ first_name }} is the same variable. The subject, HTML and text can all use variables.
A key with a fallback is optional: a send that leaves it out gets the fallback. A key without one is required, and every send must pass it. A fallback for a key that the subject, HTML and text do not use is refused with 422 and details[].code set to unknown_variable.
Values are strings, numbers or booleans, and Shipmail inserts them as text, never as markup:
- In the HTML body, values are HTML-escaped, so
<b>Ada</b>shows those characters rather than bold text. Put any markup in the template itself. - In the subject, control characters such as line breaks and tabs are removed.
- In the text body, values are inserted as given.
Fallbacks follow the same rules. If a filled-in subject, HTML or text goes over its size limit, the render or send fails with 422 and too_large. If the filled-in subject is empty, or the HTML and text are both empty, it fails with empty.
Versions and publishing
- Creating or updating a template changes the draft. Sends keep using the published version.
- Publishing copies the draft into the next version: 1, then 2, then 3. That version becomes the one sends use. A version never changes after it is published.
- Restoring a version copies it into the draft. It does not publish, so publish again to send it.
- A send can pin a version with
template.version. Pinned sends keep working after you publish newer versions.
Create a template
/v1/templatesScope: templates:write. Requires name, subject, and at least one of html and text. fallbacks is optional, with at most 200 keys and 10,000 characters per value. Pass publish: true to publish the draft as version 1 in the same call.
curl -X POST https://shipmail.to/api/v1/templates \
-H "Authorization: Bearer sm_live_..." \
-H "Content-Type: application/json" \
-d '{
"name": "Welcome",
"subject": "Welcome, {{name}}",
"html": "<p>Hi {{name}}, you are on the {{plan}} plan.</p>",
"fallbacks": { "plan": "Free" },
"publish": true
}'Returns 201 with the template. An organization can store up to 1,000 templates; past that, create returns 403 quota_exceeded.
List templates
/v1/templatesScope: templates:read. Newest first. Supports pagination with cursor and limit. Returns { data, pagination }.
Retrieve a template
/v1/templates/:idScope: templates:read. Returns the template with its draft. A template of another organization returns 404.
Update a template
/v1/templates/:idScope: templates:write. Send any of name, subject, html, text and fallbacks. Set html or text to null to remove it; one of them must remain. fallbacks replaces the whole map.
The update changes the draft only and sets has_unpublished_changes. Sends keep using the published version until you publish.
Delete a template
/v1/templates/:idScope: templates:write. Deletes the template and all its versions. Returns 204. Sends that name it fail with 404 afterwards. Messages already sent are not affected.
Publish a template
/v1/templates/:id/publishScope: templates:write. No body. Copies the draft into the next version and makes it the published version. Returns the template, with the new published_version.
Render a template
/v1/templates/:id/renderScope: templates:read. Fills in the draft, or a version, with sample values and returns the result. Nothing is stored or sent. It applies the same rules and returns the same errors as a send, so use it to check values before sending.
{
"variables": { "name": "Ada" },
"version": 1
}Omit version to render the draft; the response then has version: null. A missing required variable or a key the template does not use returns 422 with missing_variable or unknown_variable.
{
"object": "rendered_template",
"template_id": "tpl_123",
"version": null,
"subject": "Welcome, Ada",
"html": "<p>Hi Ada, you are on the Free plan.</p>",
"text": null
}List versions
/v1/templates/:id/versionsScope: templates:read. Newest first, with cursor pagination. Each item is a version object:
{
"object": "template_version",
"template_id": "tpl_123",
"version": 1,
"subject": "Welcome, {{name}}",
"html": "<p>Hi {{name}}, you are on the {{plan}} plan.</p>",
"text": null,
"fallbacks": { "plan": "Free" },
"variables": [
{ "key": "name", "required": true, "fallback": null },
{ "key": "plan", "required": false, "fallback": "Free" }
],
"published_at": "2026-10-27T10:00:00.000Z"
}Retrieve a version
/v1/templates/:id/versions/:versionScope: templates:read. version is the version number. Returns 404 when the template or the version does not exist.
Restore a version
/v1/templates/:id/versions/:version/restoreScope: templates:write. Copies the version's subject, HTML, text and fallbacks into the draft and returns the template. It does not publish. Publish to make the restored content the one sends use.
Send with a template
Pass template to POST /v1/messages in place of subject, html and text. The scope is messages:send; the key does not need a templates scope. Every other send field works as usual, including sends from a transactional sender.
curl -X POST https://shipmail.to/api/v1/messages \
-H "Authorization: Bearer sm_live_..." \
-H "Content-Type: application/json" \
-d '{
"from": "hello@example.com",
"to": ["ada@example.com"],
"template": {
"id": "tpl_123",
"variables": { "name": "Ada" }
}
}'template.id is required. template.version pins a version; without it the send uses the published version. template.variables holds the values, at most 200 keys.
Errors
Errors use the usual error format. Match on details[].code, not the message.
| Status | Type or code | When |
|---|---|---|
| 404 | not_found | The template or version does not exist, or belongs to another organization. |
| 422 | unknown_variable | A fallback, or a value passed to render or send, names a key the template does not use. |
| 422 | missing_variable | A required variable was left out of a render or send. |
| 422 | template_not_published | A send named a template that has never been published and did not pin a version. |
| 422 | template_conflict | A send passed template together with subject, html or text. |
| 422 | too_large | The filled-in subject, HTML or text is over its size limit. |
| 422 | empty | The filled-in subject is empty, or the filled-in HTML and text are both empty. |
| 403 | quota_exceeded | The organization already has 1,000 templates. |
{
"error": {
"type": "validation_error",
"message": "Missing required variables: name.",
"details": [
{
"field": "template.variables.name",
"message": "Missing required variable: name.",
"code": "missing_variable"
}
]
}
}