A template is a reusable message with {{ variables }} that you supply per send. Author the subject and body once, declare the variables it expects, then send it by template_id. Sendara renders the final email for each recipient. Edit a typo without shipping code. The next send uses the new content.
Sendara versions a template automatically. Every time you change the content, Sendara keeps the previous version, so you always have a history of what you sent.
Create a template
Create a template with POST /v1/templates. Give it a name, a channel, the content fields for that channel, and the list of variables it uses.
curl https://api.sendara.dev/v1/templates \
-H "Authorization: Bearer sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"name": "Welcome email",
"channel": "email",
"subject": "Welcome, {{ first_name }}",
"body_html": "<h1>Hi {{ first_name }}</h1><p>Thanks for joining {{ company }}.</p>",
"body_text": "Hi {{ first_name }}; thanks for joining {{ company }}.",
"variables": [
{ "name": "first_name", "sample": "Ada", "required": true },
{ "name": "company", "default": "Acme" }
]
}'The full set of body fields:
The response is the stored template, including a generated id (prefixed tmpl_), its version (starts at 1), and is_active:
{
"id": "tmpl_8f3a39ab8c2d4e6f",
"name": "Welcome email",
"channel": "email",
"subject": "Welcome, {{ first_name }}",
"body_text": "Hi {{ first_name }}, thanks for joining {{ company }}.",
"body_html": "<h1>Hi {{ first_name }}</h1><p>Thanks for joining {{ company }}.</p>",
"variables": [
{ "name": "first_name", "sample": "Ada", "required": true },
{ "name": "company", "default": "Acme" }
],
"version": 1,
"is_active": true,
"created_at": "2026-06-14T10:00:00Z",
"updated_at": "2026-06-14T10:00:00Z"
}channel to email. Email is the only channel Sendara sends today. Author your templates for it.The variable model
Variables use mustache-style syntax: {{ name }}, with optional inner whitespace. Sendara renders every template field with Handlebars. The engine reads conditionals ({{#if}}), loops ({{#each}}), and partials ({{> name}}). It registers 17 helpers, among them eq, default, and formatDate. Markup that does not parse returns invalid_template with status 400.
Sendara caps each render. Nested {{#each}} blocks reach a depth of 2. A single array holds 500 elements. A template field holds 256 KiB. A render stops after 2 seconds.
Each declared variable carries metadata:
At render time each variable resolves in this order: the value you pass wins. If you pass no value, Sendara uses the default. If there is no default and the variable is required, the request fails with missing_variable. Otherwise it renders as an empty string.
{{ tokens }} when you pass a value, and renders them empty when you do not. Declare a variable to mark it required and to supply a default and a sample for previews. Declare every variable that you send to a real user.body_html, Sendara HTML-escapes every substituted value to prevent injection. So {{ name }} set to <b>x</b> renders as text, not markup. In subject and body_text, Sendara inserts a value verbatim. Keep your HTML structure in the template itself, not in variable values.Preview a render
Before you send, preview exactly what a template produces for a given set of variables with POST /v1/templates/{id}/render. Sendara sends nothing and bills nothing. You get the rendered channel payload back.
curl https://api.sendara.dev/v1/templates/tmpl_8f3a39ab/render \
-H "Authorization: Bearer sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"vars": { "first_name": "Ada", "company": "Sendara" }
}'For an email template, the response is the email payload Sendara would send:
{
"subject": "Welcome, Ada",
"body_text": "Hi Ada, thanks for joining Sendara.",
"body_html": "<h1>Hi Ada</h1><p>Thanks for joining Sendara.</p>"
}vars, while a send uses template_vars (below). They represent the same idea, but the field name differs between the two endpoints.If a required variable is missing you get 400 missing_variable. A structurally invalid template (for example one that renders an empty email body) returns 400 invalid_template.
Preview unsaved source
To preview raw source before you store it, use POST /v1/templates/preview. It renders arbitrary subject, body_html, and body_text against a variables object without creating or changing a template. This is what a live editor calls on every keystroke, distinct from /{id}/render, which renders a saved template by id.
curl https://api.sendara.dev/v1/templates/preview \
-H "Authorization: Bearer sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"channel": "email",
"subject": "Welcome, {{ first_name }}",
"body_html": "<h1>Hi {{ first_name }}</h1>{{> footer }}",
"variables": { "first_name": "Ada", "company": "Acme" }
}'The response gives you the rendered fields plus non-fatal warnings (for example a {{ token }} you referenced but did not supply) and hard errors:
{
"subject": "Welcome, Ada",
"html": "<h1>Hi Ada</h1><footer>...</footer>",
"text": "",
"warnings": ["variable \"company\" is referenced but not provided"],
"errors": []
}variables and returns html / text with warnings and errors. The saved-template render takes vars and returns body_html / body_text. Preview never reads or writes stored templates, but it does expand your account's snippets as partials, so {{> footer }} resolves in a preview exactly as it will in a real send. It takes a read scope.Send with a template
To send a stored template, call POST /v1/send with template_id and template_vars instead of an inline payload. Sendara renders the template per recipient and sends the result.
curl https://api.sendara.dev/v1/send \
-H "Authorization: Bearer sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{
"channel": "email",
"idempotency_key": "welcome_usr_4821",
"destination": { "email": "[email protected]" },
"template_id": "tmpl_8f3a39ab",
"template_vars": { "first_name": "Ada", "company": "Acme" },
"metadata": { "from_email": "[email protected]" }
}'subject, html or text that you send next to a template_id has no effect. To change the subject line, edit the template, or put the changing part in a {{ variable }}.Everything else about /v1/send still applies. Raw HTTP requests must include idempotency_key, while official SDKs generate one when you omit it. Reusing a key with a different payload returns 409 idempotency_key_reused. metadata.from_email must be on a verified domain. Sending with an unknown template_id returns template_not_found, and a template that fails to render returns template_error. See Send email for the full request contract.
List, update, and delete
The rest of the lifecycle is plain CRUD. List returns the account's templates newest-first:
curl https://api.sendara.dev/v1/templates \
-H "Authorization: Bearer sk_live_xxx"{
"templates": [
{ "id": "tmpl_8f3a39ab8c2d4e6f", "name": "Welcome email",
"channel": "email", "version": 1, "is_active": true,
"created_at": "2026-06-14T10:00:00Z", "updated_at": "2026-06-14T10:00:00Z" }
]
}Fetch one with GET /v1/templates/{id}. Update with PUT /v1/templates/{id}. Send only the fields you want to change. Editing any content field (subject, body_text, body_html, or body_json) snapshots the current version and bumps version. Metadata-only changes, like name or is_active) do not.
curl -X PUT https://api.sendara.dev/v1/templates/tmpl_8f3a39ab8c2d4e6f \
-H "Authorization: Bearer sk_live_xxx" \
-H "Content-Type: application/json" \
-d '{ "subject": "Welcome aboard, {{ first_name }}" }'Set is_active to false to retire a template without deleting it, or delete it permanently:
curl -X DELETE https://api.sendara.dev/v1/templates/tmpl_8f3a39ab8c2d4e6f \
-H "Authorization: Bearer sk_live_xxx"Snippets
A snippet is a reusable content block, such as a footer, a header, or a legal disclaimer, that you write once and insert into any template. Give it a name and a body, then reference it as a Handlebars partial with {{> name }}. Edit the snippet and every template that includes it renders the new content on the next send. A snippet name is lowercase. It holds only letters, digits, and underscores ([a-z0-9_]). Each name is unique per account.
Create one with POST /v1/templates/snippets. It carries a body_html and a body_text, both of which can use the same {{ variables }} as a template:
curl https://api.sendara.dev/v1/templates/snippets \
-H "Authorization: Bearer sk_live_admin_xxx" \
-H "Content-Type: application/json" \
-d '{
"name": "footer",
"body_html": "<footer>© {{ company }} · <a href=\"{{ unsub_url }}\">Unsubscribe</a></footer>",
"body_text": "{{ company }}. Unsubscribe: {{ unsub_url }}"
}'The response is the stored snippet, with a generated id (prefixed snip_):
{
"id": "snip_4f9a1c0b8e2d3a6f",
"account_id": "acc_8c2d4e6f",
"name": "footer",
"body_html": "<footer>© {{ company }} · <a href=\"{{ unsub_url }}\">Unsubscribe</a></footer>",
"body_text": "{{ company }}. Unsubscribe: {{ unsub_url }}",
"created_at": "2026-07-14T10:00:00Z",
"updated_at": "2026-07-14T10:00:00Z"
}Reference it from any template body with the partial syntax:
<h1>Hi {{ first_name }}</h1>
<p>Thanks for joining {{ company }}.</p>
{{> footer }}The rest is plain CRUD. List returns the account's snippets alphabetically:
curl https://api.sendara.dev/v1/templates/snippets \
-H "Authorization: Bearer sk_live_xxx"{
"snippets": [
{ "id": "snip_4f9a1c0b8e2d3a6f", "account_id": "acc_8c2d4e6f",
"name": "footer", "body_html": "<footer>...</footer>", "body_text": "...",
"created_at": "2026-07-14T10:00:00Z", "updated_at": "2026-07-14T10:00:00Z" }
]
}Fetch one with GET /v1/templates/snippets/{id}, replace its fields with PATCH /v1/templates/snippets/{id}, and remove it with DELETE /v1/templates/snippets/{id} (a 204 on success).
GET) takes a read scope. Creating, updating, and deleting them (POST, PATCH, DELETE) takes an admin scope. A duplicate name returns 409 conflict, and a name with disallowed characters returns 400 invalid_request.The starter gallery
Call GET /v1/templates/gallery to read the 20 starter templates in place of an empty editor. Each entry is a complete styled email. It carries a subject, a body_html value and a sample_variablesobject. The category field holds transactional. Copy an entry into your own template with POST /v1/templates, then edit it.
curl https://api.sendara.dev/v1/templates/gallery \
-H "Authorization: Bearer sk_live_xxx"{
"templates": [
{
"id": "welcome",
"name": "Welcome",
"category": "transactional",
"description": "A warm first-touch email for new signups.",
"subject": "Welcome to {{ company }}, {{ first_name }}",
"body_html": "<!doctype html><html>...</html>",
"sample_variables": { "company": "Acme", "first_name": "Ada" }
}
]
}The gallery is a fixed read-only catalog inside the API. It takes a read scope, and it is the same for every account.
Image uploads
An email links to an image, such as a logo or a product photograph. Upload the image with POST /v1/uploads. Sendara then hosts it at a stable URL. Put that URL in the body_html value of your template.
Uploads are multipart/form-data with a single file part:
curl https://api.sendara.dev/v1/uploads \
-H "Authorization: Bearer sk_live_xxx" \
-F "[email protected]"The response gives you the hosted URL:
{
"id": "asset_2b91c0a17e4d5f6a",
"url": "https://api.sendara.dev/v1/assets/asset_2b91c0a17e4d5f6a",
"content_type": "image/png",
"bytes": 18234
}Reference the url in your HTML. Sendara serves it publicly with a long-lived immutable cache header, so it loads fast in every inbox:
<img src="https://api.sendara.dev/v1/assets/asset_2b91c0a17e4d5f6a"
alt="Acme" width="120" />413 payload_too_large, and an unsupported type returns 400 invalid_request. Optimize an image before you upload it. Smaller assets render faster and keep your mail under inbox size limits.Errors
All errors use the standard envelope: { "error": { "code": "...", "message": "..." } }. The ones specific to templates and uploads:
missing_variable(400): arequiredvariable had no value and no default. Pass it, or give it adefault.invalid_template(400): the rendered output is invalid for the channel (e.g. an empty email body). Fix the template content.not_found(404): no template with thatidexists on your account.invalid_request(400): a malformed body, a missingname/channelon create, an unsupported upload type, or a missingfilepart.payload_too_large(413): an upload over the 2 MiB limit.
See the errors reference for the full list and the API reference for every endpoint.
Common questions
- Which template language does Sendara run?
- Handlebars. Sendara renders a body with the mailgun/raymond engine. A body that does not parse returns 400 invalid_template. A render that fails on your variables returns 400 missing_variable.
- Can a template include a partial?
- Yes. A snippet is a Handlebars partial, and you reference it as {{> name }}. A snippet name is lowercase, and it holds only letters, digits and underscores. Edit the snippet once, and every template that includes it renders the new content.
- Why did my render fail on size or on time?
- Sendara caps a render at 2 seconds. A template body is capped at 256 KiB, and the variables are capped at 256 KiB. An each block iterates at most 500 elements, and each blocks nest at most 2 deep.
- How many starter templates are there?
- 20. Call GET /v1/templates/gallery with a read key. Each entry carries a subject, a body_html value and a sample_variables object. The category field is transactional. The gallery is read-only, and it is the same for every account.
- Which scope do I need for a snippet?
- A read key lists and fetches a snippet. An admin key creates, updates and deletes one. A duplicate name returns 409 conflict, and a name with a disallowed character returns 400 invalid_request.