Sending

Templates

Store your email once, and supply the variable values at send time. Keep copy out of your codebase and out of your deploys.

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.

create-template.sh
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:

namestringRequired
Human-readable name for the template.
channelstringRequired
The delivery channel. Use email. It is the only channel you can send today.
subjectstringOptional
The subject line. Email requires it at render time. Supports {{ variables }}.
body_htmlstringOptional
The HTML body. Sendara HTML-escapes every variable value on render to prevent injection.
body_textstringOptional
The plain-text body. Sendara substitutes every variable value verbatim.
body_jsonobjectOptional
Opaque design-editor document, such as a block layout. Sendara stores it verbatim and returns it verbatim. Sendara does not render it server-side.
variablesarrayOptional
Declared {{ variables }} with sample / default / required metadata. See Variables below.

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"
}
Set 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:

namestringRequired
The placeholder name. You reference it in the body as {{ name }}.
samplestringOptional
The example value that a preview and the dashboard show. Sendara never substitutes it in a real send.
defaultstringOptional
The value Sendara uses when the caller omits this variable. Makes the variable effectively optional.
requiredbooleanOptional
When true and you supply no value and no default, render and send fail with missing_variable.

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.

You do not have to declare every variable. Sendara still substitutes undeclared {{ 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.
In 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.

render.sh
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>"
}
The render body uses 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": []
}
The two endpoints differ in both shape and intent. Preview takes 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.

send-template.sh
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]" }
  }'
A resolved template supplies the whole email. Sendara replaces the payload with the rendered template, so a 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).

Reading snippets (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.

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" />
filefileRequired
The image part. Allowed types: PNG, JPEG, GIF, WebP.
Sendara caps an upload at 2 MiB. Larger files return 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): a required variable had no value and no default. Pass it, or give it a default.
  • 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 that id exists on your account.
  • invalid_request (400): a malformed body, a missing name/channel on create, an unsupported upload type, or a missing file part.
  • 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.