Developers

Email Templates API: Create, Publish and Send Templates

By Lalit Kumar Jangid14 min

On this page+

An email templates API lets you design an email once, store it with the sending service, and send it from your code by passing only a template alias and a few values. In Cresca you create a template draft with POST /templates, mark dynamic values as {{{VARIABLE}}}, publish the draft as a numbered version, then send it with POST /emails and a template object.

This guide covers every endpoint of the Cresca Templates API with working cURL, Node.js and Python code, the real response shapes and the errors the API returns.

In this guide

  • How drafts, published versions and aliases work
  • Authentication and rate limits
  • Template variable syntax and fallbacks
  • Create, publish and send with a template (cURL, Node.js, Python)
  • Retrieve, list, update and delete templates
  • The in-app template editor
  • Error reference and FAQ

How the Cresca email templates API works

Transactional email templates in Cresca, such as a password reset or an order receipt, have two layers:

  • The draft. What you create and edit. Changing it never affects live email.
  • The published version. A snapshot of the draft taken when you publish. Sends always use this. Each publish creates the next version number: 1, 2, 3 and so on.

You address a template by its id (for example 6713b1f4c2a94e0012ab34cd) or by its alias, a readable name such as password-reset. Aliases are the better choice in application code: they read clearly in a pull request and stay the same even if you recreate a template. Every endpoint below that takes :id accepts either.

Templates you create through the API also appear under Transactional → Templates in the Cresca app, so a designer can refine the layout while your code keeps sending the same alias. If you are still deciding what belongs in a transactional template, start with transactional vs marketing email.

Authentication, base URL and rate limits

The base URL is https://api.cresca.xyz/api/v1. Send your API key as a bearer token:

Authorization: Bearer cr_live_xxxxxxxxx

The X-API-Key header also works. Keys come in two permissions (authentication docs):

EndpointKey needed
POST /templates, GET /templates, PATCH, DELETE, publishFull access
POST /emails (sending with a template)Sending access or full access

A sending-access key that calls a template endpoint gets 401 restricted_api_key. A common setup is a full-access key in your deploy or admin scripts that manage templates, and a sending-access key on the production servers that only send.

Template requests count toward the same per-second limit as every other API call, shared by all keys on the account (rate limits and quotas):

PlanRequests per second
Free2
Professional10
Premium20
Ultra50
Enterprise100

Over the limit you get 429 rate_limit_exceeded with a retry-after header. Emails sent with a template use your plan's monthly email allowance, the same allowance as your campaigns. See pricing for each plan.

Email template variables: syntax and fallbacks

Mark each value your app fills in with triple braces: {{{RESET_LINK}}}. Variables work in the HTML, the plain-text version, the subject and the preview text. The rules, from the templates and variables guide:

  • Names use letters, numbers and underscores, up to 50 characters. Upper case is the convention.
  • Each variable has a type: string (the default, up to 2,000 characters) or number.
  • A variable can have a fallback_value, used when a send leaves the variable out.
  • A template can have up to 50 variables.
  • FIRST_NAME, LAST_NAME, EMAIL and UNSUBSCRIBE_URL are reserved and cannot be used.

You declare variables as an array of definitions:

[ { "key": "NAME", "type": "string", "fallback_value": "there" }, { "key": "RESET_LINK", "type": "string" } ]

Here NAME is optional: a send without it renders "Hi there". RESET_LINK has no fallback, so every send must include it. You do not have to declare everything up front: when you publish, any placeholder in the draft that is not declared is added as a string variable without a fallback.

Escaping. Values are HTML-escaped in the HTML body, so a name like <b>Ana</b> shows as text and can never change your markup. In the plain-text version values are inserted as-is, and line breaks are removed from the subject.

Create a template

POST /templates creates a draft. The template cannot be sent until you publish it. Reference: Create template.

FieldTypeRequiredNotes
namestringYesUp to 120 characters, unique in your account.
htmlstringYesHTML body with {{{VARIABLE}}} placeholders.
aliasstringNoLowercase letters, numbers, dashes and underscores, up to 64 characters, unique in your account.
subjectstringNoDefault subject. Can contain variables.
textstringNoPlain-text version. If omitted, generated from the HTML at send time.
fromstringNoDefault sender, e.g. Acme <hello@acme.com>.
reply_tostringNoDefault Reply-To address.
preview_textstringNoInbox snippet, up to 500 characters. Can contain variables.
variablesobject[]No{ key, type, fallback_value } definitions, up to 50.

cURL

curl -X POST 'https://api.cresca.xyz/api/v1/templates' \ -H 'Authorization: Bearer cr_live_xxxxxxxxx' \ -H 'Content-Type: application/json' \ -d '{ "name": "Password reset", "alias": "password-reset", "subject": "Reset your password", "from": "Acme <hello@acme.com>", "preview_text": "The link expires in 1 hour.", "html": "<p>Hi {{{NAME}}},</p><p><a href=\"{{{RESET_LINK}}}\">Reset your password</a>. The link expires in 1 hour.</p>", "variables": [ { "key": "NAME", "type": "string", "fallback_value": "there" }, { "key": "RESET_LINK", "type": "string" } ] }'

Node.js (fetch, Node 18+)

const response = await fetch('https://api.cresca.xyz/api/v1/templates', { method: 'POST', headers: { Authorization: `Bearer ${process.env.CRESCA_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ name: 'Password reset', alias: 'password-reset', subject: 'Reset your password', from: 'Acme <hello@acme.com>', preview_text: 'The link expires in 1 hour.', html: '<p>Hi {{{NAME}}},</p><p><a href="{{{RESET_LINK}}}">Reset your password</a>. The link expires in 1 hour.</p>', variables: [ { key: 'NAME', type: 'string', fallback_value: 'there' }, { key: 'RESET_LINK', type: 'string' }, ], }), });

const result = await response.json(); if (!response.ok) throw new Error(,[object Object],); console.log(result.id); // template id

Python (requests)

import os import requests

response = requests.post( ",[object Object],", headers={"Authorization": f"Bearer {os.environ['CRESCA_API_KEY']}"}, json={ "name": "Password reset", "alias": "password-reset", "subject": "Reset your password", "from": "Acme <,[object Object],>", "preview_text": "The link expires in 1 hour.", "html": '<p>Hi {{{NAME}}},</p><p><a href="{{{RESET_LINK}}}">Reset your password</a>. The link expires in 1 hour.</p>', "variables": [ {"key": "NAME", "type": "string", "fallback_value": "there"}, {"key": "RESET_LINK", "type": "string"}, ], }, timeout=10, ) response.raise_for_status() print(response.json()["id"])

Response (201 Created):

{ "object": "template", "id": "6713b1f4c2a94e0012ab34cd" }

Common errors

StatusnameWhen
422missing_required_fieldname or html is missing: Missing `html` field.
422validation_errorThe alias breaks the format rules, or another template already uses the alias or name: Another template already uses this alias.
422validation_errorA variable key is invalid, duplicated, reserved (`EMAIL` is a reserved variable name.) or its fallback does not match its type.
401restricted_api_keyThe key has sending access only.

Publish a template

POST /templates/:id/publish copies the current draft (HTML, text, subject, default sender, Reply-To, preview text and variables) into a new published version. Every email sent with the template from then on uses it. There is no request body. Reference: Publish template.

cURL

curl -X POST 'https://api.cresca.xyz/api/v1/templates/password-reset/publish' \ -H 'Authorization: Bearer cr_live_xxxxxxxxx'

Node.js

const response = await fetch('https://api.cresca.xyz/api/v1/templates/password-reset/publish', { method: 'POST', headers: { Authorization: `Bearer ${process.env.CRESCA_API_KEY}` }, });

const result = await response.json(); if (!response.ok) throw new Error(,[object Object],); console.log(,[object Object],);

Python

response = requests.post( "https://api.cresca.xyz/api/v1/templates/password-reset/publish", headers={"Authorization": f"Bearer {os.environ['CRESCA_API_KEY']}"}, timeout=10, ) response.raise_for_status() print("Published version", response.json()["version"])

Response:

{ "object": "template", "id": "6713b1f4c2a94e0012ab34cd", "version": 1 }

Publishing again after an edit returns "version": 2, and so on. Errors: 404 not_found (Template not found) for an unknown id or alias, and 422 validation_error if the draft uses a reserved name such as {{{EMAIL}}}.

Send an email with a published template

To send, call POST /emails with a template object instead of html and text. template.id is the id or alias; template.variables holds the values as strings or numbers. This is the only part of POST /emails covered here; the send email API guide covers recipients, scheduling, tags and attachments.

Add an Idempotency-Key header so a retry after a timeout never sends the reset email twice (idempotency docs).

cURL

curl -X POST 'https://api.cresca.xyz/api/v1/emails' \ -H 'Authorization: Bearer cr_live_xxxxxxxxx' \ -H 'Content-Type: application/json' \ -H 'Idempotency-Key: password-reset/reset-request-9f2c' \ -d '{ "to": "user@example.com", "template": { "id": "password-reset", "variables": { "NAME": "Ana", "RESET_LINK": "https://acme.com/reset?token=abc" } } }'

Node.js

async function sendPasswordReset({ email, name, resetLink, requestId }) { const response = await fetch('https://api.cresca.xyz/api/v1/emails', { method: 'POST', headers: { Authorization: `Bearer ${process.env.CRESCA_API_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': `password-reset/${requestId}`, }, body: JSON.stringify({ to: email, template: { id: 'password-reset', variables: { NAME: name, RESET_LINK: resetLink }, }, }), });

const result = await response.json(); if (!response.ok) throw new Error(,[object Object],); return result.id; }

Python

def send_password_reset(email, name, reset_link, request_id): response = requests.post( "https://api.cresca.xyz/api/v1/emails", headers={ "Authorization": f"Bearer {os.environ['CRESCA_API_KEY']}", "Idempotency-Key": f"password-reset/{request_id}", }, json={ "to": email, "template": { "id": "password-reset", "variables": {"NAME": name, "RESET_LINK": reset_link}, }, }, timeout=10, ) response.raise_for_status() return response.json()["id"]

Response (200 OK), the id of the queued email:

{ "id": "4ef9a417-02e9-4d39-ad75-9611e0fcc33c" }

No from or subject in the request: they come from the published template. Fields resolve like this:

FieldComes from
htmlThe published template, with variables filled in.
textThe template's text version, or generated from the HTML.
subjectYour request if given, otherwise the template's default subject.
fromYour request if given, otherwise the template's default sender.
reply_toYour request, else the template, else the sender's Reply-To.

The sender, whether from the request or the template, must be an active sender on a verified domain. Set that up under sending domains; SPF, DKIM and DMARC done right and the Gmail and Yahoo sender requirements explain why authentication matters for password resets landing in the inbox.

Common errors

Statusnamemessage (example)
422validation_errorThis template has not been published yet. Publish it before sending.
422validation_errorMissing template variable: `RESET_LINK`. Nothing is sent.
422validation_error`template` cannot be combined with `html` or `text`.
422missing_required_fieldMissing `subject` field. The template has no default subject and the request has none.
404not_foundTemplate not found
403invalid_from_addressThe sender is not an active sender on a verified domain.

The full shape of an error body is the same for every endpoint:

{ "statusCode": 422, "name": "validation_error", "message": "Missing template variable: `RESET_LINK`." }

Retrieve a template

GET /templates/:id returns one template by id or alias. Reference: Retrieve template.

curl 'https://api.cresca.xyz/api/v1/templates/password-reset' \ -H 'Authorization: Bearer cr_live_xxxxxxxxx'
const response = await fetch('https://api.cresca.xyz/api/v1/templates/password-reset', { headers: { Authorization: `Bearer ${process.env.CRESCA_API_KEY}` }, }); const template = await response.json(); console.log(template.status, template.version);
{ "object": "template", "id": "6713b1f4c2a94e0012ab34cd", "name": "Password reset", "alias": "password-reset", "subject": "Reset your password", "from": "Acme <hello@acme.com>", "reply_to": null, "preview_text": "The link expires in 1 hour.", "html": "<p>Hi {{{NAME}}},</p><p><a href=\"{{{RESET_LINK}}}\">Reset your password</a>. The link expires in 1 hour.</p>", "text": null, "variables": [ { "key": "NAME", "type": "string", "fallback_value": "there" }, { "key": "RESET_LINK", "type": "string", "fallback_value": null } ], "status": "published", "version": 1, "published_at": "2026-10-20T08:30:00.000Z", "created_at": "2026-10-20T08:12:41.000Z", "updated_at": "2026-10-20T08:30:00.000Z" }

Read this carefully: html, subject and the other content fields show the current draft. status is draft until the first publish, then published, and version is the published version that sends use. If you edit the draft, the response shows your edits while sends keep using version 1. An unknown id or alias returns 404 not_found.

List templates

GET /templates lists your templates, newest first, with cursor pagination. Reference: List templates.

Query parameterNotes
limit1 to 100. Default 20.
afterA template id. Returns templates older than it (the next page).
curl 'https://api.cresca.xyz/api/v1/templates?limit=1' \ -H 'Authorization: Bearer cr_live_xxxxxxxxx'
def all_templates(): headers = {"Authorization": f"Bearer {os.environ['CRESCA_API_KEY']}"} params = {"limit": 100} while True: response = requests.get( "https://api.cresca.xyz/api/v1/templates", headers=headers, params=params, timeout=10 ) response.raise_for_status() page = response.json() yield from page["data"] if not page["has_more"]: break params["after"] = page["data"][-1]["id"]
{ "object": "list", "has_more": true, "data": [ { "object": "template", "id": "6713b1f4c2a94e0012ab34cd", "name": "Password reset", "alias": "password-reset", "subject": "Reset your password", "from": "Acme <hello@acme.com>", "reply_to": null, "preview_text": "The link expires in 1 hour.", "html": "<p>Hi {{{NAME}}},</p><p><a href=\"{{{RESET_LINK}}}\">Reset your password</a>. The link expires in 1 hour.</p>", "text": null, "variables": [ { "key": "NAME", "type": "string", "fallback_value": "there" }, { "key": "RESET_LINK", "type": "string", "fallback_value": null } ], "status": "published", "version": 1, "published_at": "2026-10-20T08:30:00.000Z", "created_at": "2026-10-20T08:12:41.000Z", "updated_at": "2026-10-20T08:30:00.000Z" } ] }

Each item has the same fields as Retrieve template. The list includes every template you created in Cresca, including ones not set up for the API: their status is draft and alias is null.

Update a template

PATCH /templates/:id changes the draft. Send only the fields you want to change; they take the same values as in Create template. Send null for alias to remove it. Reference: Update template.

curl -X PATCH 'https://api.cresca.xyz/api/v1/templates/password-reset' \ -H 'Authorization: Bearer cr_live_xxxxxxxxx' \ -H 'Content-Type: application/json' \ -d '{"subject": "Reset your Acme password"}'
const response = await fetch('https://api.cresca.xyz/api/v1/templates/password-reset', { method: 'PATCH', headers: { Authorization: `Bearer ${process.env.CRESCA_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ subject: 'Reset your Acme password' }), }); console.log(await response.json());
{ "object": "template", "id": "6713b1f4c2a94e0012ab34cd" }

Emails keep using the published version until you call publish again. That gives you a safe release flow: update the draft, send yourself a test from the editor, then publish. Errors match Create template, plus 404 not_found for an unknown id or alias.

Delete a template

DELETE /templates/:id deletes the template, and it disappears from the Cresca app as well. Reference: Delete template.

curl -X DELETE 'https://api.cresca.xyz/api/v1/templates/password-reset' \ -H 'Authorization: Bearer cr_live_xxxxxxxxx'
response = requests.delete( "https://api.cresca.xyz/api/v1/templates/password-reset", headers={"Authorization": f"Bearer {os.environ['CRESCA_API_KEY']}"}, timeout=10, ) print(response.json())
{ "object": "template", "id": "6713b1f4c2a94e0012ab34cd", "deleted": true }

After deletion, sends that reference the template fail with 404 not_found. Update your app before deleting a template it still uses.

Build templates in the Cresca editor

You do not have to write template HTML by hand. In Transactional → Templates, click Create template and start blank or choose Import HTML. Set the sender, subject, alias and optional preview text, type / for blocks and {{ to insert a variable. The Variables panel is where you set fallbacks.

Test email sends the draft to up to 5 addresses, with variables typed in or pasted as the same JSON your app sends in template.variables. Test emails count toward your monthly allowance. Publish checks the sender, subject, empty body and variables first, and every publish is kept in Version history, where you can restore an older version into the draft and publish it again.

Error reference for the templates API

Branch on name, not on message: messages may be reworded. The full list is in the errors docs.

StatusnameRetry?
401missing_api_key, restricted_api_keyNo
403invalid_api_key, invalid_from_addressNo
404not_foundNo
422missing_required_field, validation_errorNo
429rate_limit_exceededYes, after retry-after
500 / 503application_error, service_unavailableYes, with backoff

Frequently asked questions

What is the variable syntax for Cresca email templates?

Use triple braces: {{{NAME}}}. Names use letters, numbers and underscores, up to 50 characters, and upper case is the convention. Variables work in the HTML, plain text, subject and preview text. Values are HTML-escaped in the HTML body, inserted as-is in the text version, and line breaks are removed in the subject.

What happens if I send a template without a required variable?

Nothing is sent. If a variable has no value in template.variables and no fallback_value, the API returns 422 validation_error with a message naming every missing variable, such as Missing template variable: `RESET_LINK`. Give optional variables a fallback so a send can leave them out.

Can I send a template that is still a draft?

No. Sends always use the published version. A template that has never been published returns 422 validation_error with the message that it has not been published yet. Call POST /templates/:id/publish first. After later edits, sends keep using the last published version until you publish again.

Should I use the template id or the alias?

Both work on every endpoint and in template.id when sending. Aliases such as password-reset are easier to read in code and reviews. They use lowercase letters, numbers, dashes and underscores, up to 64 characters, and must be unique in your account.

Can I override the subject or sender for one send?

Yes. If the POST /emails request includes subject or from, it wins over the template's default. reply_to comes from the request, else the template, else the sender's own Reply-To. The HTML and text always come from the published template when you send with one.

Is there an SDK for the Templates API?

There is no official SDK package. The API is plain HTTPS and JSON, so the built-in fetch in Node.js 18+ and Python requests are enough, as in the examples above. Field names follow common email API conventions, and every response from this template API is JSON with the same error shape, so one small helper function can handle all six endpoints.

Ready to move your email HTML out of your codebase? Try Cresca free for 30 days, then follow the quickstart to send your first email in a few minutes.

Continue learning