Send from a template
Create and publish reusable email templates, manage revisions and send with variables.
Create and publish a Mail template to reuse a subject and body with values supplied at send time. Your application then sends the template ID and variables instead of keeping the wording in its code.
Before you start
You need | Why | Where it comes from |
|---|---|---|
A product with Mail available | A template belongs to exactly one product | |
A credential for the Mail API | Managing templates is an authenticated call to https://api.lepsto.com | |
Only needed for the last step, the send itself |
Use different credentials for template management and public sending:
Managing templates | Sending from one | |
|---|---|---|
Host | https://api.lepsto.com | https://public.lepsto.com/{product_id}/mail |
Credential | The product public key ( lpk_) — see authenticate the public API | |
Covers | Creating, editing, publishing, rollback, archiving | POST /emails and GET /emails/{id} |
Authenticate the management examples below on https://api.lepsto.com. The final send uses the public host and a public key. Neither credential replaces the other.
Management calls select a product through the credential. The examples omit product selection and use the credential’s default product:
Credential | The product it acts on | Selecting another |
|---|---|---|
A static API key ( lsk_…) | The product the key belongs to | Not possible. Naming a different product with X-Product-Id returns 403, even if the key’s creator belongs to that product. |
A session token, including one exchanged from an API key | The session’s default product | X-Product-Id may select another product the caller belongs to. Without the header the default stays in effect. |
If your session can access several products, set X-Product-Id explicitly. Start with a read-only template listing to confirm which product you are working in:
curl "https://api.lepsto.com/mail/templates" \
-H "Authorization: Bearer $LEPSTO_API_CREDENTIAL" \
-H "X-Product-Id: $LEPSTO_PRODUCT_ID"You can add X-Product-Id to every management example below. Without it, each call uses the credential’s default product.
This optional header applies to management calls. Public sends select their product with {product_id} in the URL; that value does not select the product for a management call. See API and keys for both contracts.
Create, publish and send: the short path
Create a draft, publish it, then send from its live revision. Edit and publish again whenever you need different wording:
- Create a draft. Call
mail_template_create, orPOST https://api.lepsto.com/mail/templates, with a name, subject, HTML and variable declarations. Checkstatus: "draft",currentRevision: nullanddraftRevision: 1. Save the permanent templateid. - Publish the draft. Call
mail_template_publish, orPOST https://api.lepsto.com/mail/templates/{id}/publish. Checkstatus: "published"and the newcurrentRevision. Only published templates can send. - Send a message. Use the public-key request below, or
mail_email_sendthrough MCP, with atemplateobject. The public request needs a public key and a verified sender domain. Check the returned message ID. - Update the wording when needed.
PATCHcreates or edits a draft; publishing makes it live. Sends between those operations still use the previous live revision.
Where templates are edited
Open Mail → Templates to list templates and inspect one. Its detail page shows status, variables, a body preview and HTML source. Revisions lists history newest first, with revision number, state, author, creation time and publication time. Live identifies the revision used for sends, Draft the open draft and Superseded the other revisions.
The workspace offers deletion, the Archived switch and rollback. Use MCP or REST to create or edit content, publish a draft or discard it. The available operations are:
REST (on https://api.lepsto.com) | MCP tool | What it does |
|---|---|---|
POST /mail/templates | Creates a template. Its first revision is an open draft. | |
GET /mail/templates | Lists templates. Archived ones are hidden unless include_archived=true. | |
GET /mail/templates/{id} | Reads one template, with its usage rollup. | |
PATCH /mail/templates/{id} | Edits it. Content lands in the open draft. | |
POST /mail/templates/{id}/publish | Makes the open draft live. | |
GET /mail/templates/{id}/revisions | The revision history, oldest first. | |
GET /mail/templates/{id}/revisions/{n} | One revision’s content. | |
POST /mail/templates/{id}/revisions/{n}/restore | Makes an earlier revision live again. | |
DELETE /mail/templates/{id}/draft | Throws the open draft away. | |
POST /mail/templates/{id}/archive | Hides it from listings and pickers. | |
POST /mail/templates/{id}/unarchive | Puts it back. | |
DELETE /mail/templates/{id} | Removes the template and its history. |
Create a template
Use the Acme receipt example below. Replace the sender domain, recipient, product ID, credentials, template ID and order values with your own. Management calls need the credential described above.
curl -X POST "https://api.lepsto.com/mail/templates" \
-H "Authorization: Bearer $LEPSTO_API_CREDENTIAL" \
-H "Content-Type: application/json" \
-d '{
"name": "Order receipt",
"subject": "Receipt for order {{order_id}}",
"html": "<p>Hi {{FIRST_NAME}}, thanks for order {{order_id}}.</p>",
"variables": [
{ "name": "order_id", "type": "string" }
]
}'{
"data": {
"id": "3c0f1b7e-6c2a-4f6d-b0a1-9d1e2f3a4b5c",
"name": "Order receipt",
"subject": "Receipt for order {{order_id}}",
"html": "<p>Hi {{FIRST_NAME}}, thanks for order {{order_id}}.</p>",
"text": null,
"variables": [
{ "name": "order_id", "type": "string", "optional": false }
],
"status": "draft",
"archivedAt": null,
"currentRevision": null,
"draftRevision": 1,
"createdAt": "2026-08-02T09:00:00.000Z",
"updatedAt": "2026-08-02T09:00:00.000Z"
},
"success": true
}Supply nonempty name, subject and html. Include text to control the plain-text body; if omitted, Mail derives it from the rendered HTML. Creation always produces a draft.
Check the result: a new template has status: "draft", currentRevision: null and draftRevision: 1. Publish before using it in a send.
Save the template id. Edits, publications, rollback and archiving keep this identifier.
Declare the variables
Declare each variable’s name and type, plus optional and fallback when needed:
{ "name": "plan", "type": "string", "optional": true, "fallback": "Free" }Declare at most 20 variables, with unique names matching [A-Za-z_][A-Za-z0-9_]*. Start a name with a letter or underscore; subsequent characters may also be digits.
Choose string, number or boolean for type. Send values as strings, numbers or booleans. Rendering inserts their text representation, so 42 and true render as 42 and true regardless of the declaration’s descriptive type.
If you omit a value, its declaration determines the result:
Declaration | With no value supplied |
|---|---|
Has a fallback | The fallback is used, whatever optional says. |
optional: true, no fallback | Renders as an empty string. |
Neither | The send is rejected. |
A missing-value error reports all absent required variables together:
{ "name": "invalid_template_variables",
"message": "invalid_template_variables: missing order_id, total",
"missing": ["order_id", "total"] }Reserved names
Do not declare these four reserved variables:
Name | Filled with |
|---|---|
FIRST_NAME | the contact’s first_name property |
LAST_NAME | the contact’s last_name property |
EMAIL | the contact’s email address |
UNSUBSCRIBE_URL | that contact’s personal unsubscribe link |
Broadcasts fill these names for each audience contact; see send one message to a list of people. A direct send has no contact context, so pass their values yourself if you need them. An omitted reserved value renders empty and never fails the send. Declaring a reserved name returns an error such as 'FIRST_NAME' is a reserved field at creation or update.
Write the placeholders
Use double braces around a plain variable name in the subject, HTML or plain text. {{order_id}} and {{ order_id }} both insert the same value.
Braces containing conditions, loops or expressions remain literal text; Mail recognizes only plain names. An undeclared, non-reserved placeholder renders as an empty string.
Mail handles inserted values according to their destination:
Where | Escaping |
|---|---|
HTML body | &, <, >, " and ' are replaced by their HTML entities, so a value can never introduce markup. Ben & Jerry's renders as Ben & Jerry's in the source and reads as Ben & Jerry's in the mail client. |
Subject | Not HTML-escaped — a subject line is not markup. Line breaks inside a value are collapsed to a single space, so a value can never split the subject or add a header. |
Plain-text body | Values are inserted literally, whether you wrote the text part yourself or it was derived from the HTML. |
Publish a draft
Content is stored in numbered revisions, beginning with revision 1. Editing a published template opens the next number only when no draft exists. Further edits change that draft; they do not create more revisions. Publishing closes it, so the next edit starts a new number.
The template exposes both revision pointers:
currentRevisionpoints to the live revision used for sends; it isnullbefore first publication.draftRevisionpoints to the unpublished draft; it isnullwhen no draft is open.
There is at most one open draft. Before first publication, status is draft; after any revision goes live, status remains published. Sends require a published template.
The template’s top-level subject, html, text and variables show the live revision. Before first publication, they show the only draft, so reading a newly created template returns the content you wrote.
Publish the open draft:
curl -X POST "https://api.lepsto.com/mail/templates/3c0f1b7e-6c2a-4f6d-b0a1-9d1e2f3a4b5c/publish" \
-H "Authorization: Bearer $LEPSTO_API_CREDENTIAL"Check the result: publication returns status: "published", sets currentRevision to the newly live revision and clears draftRevision to null. Repeating publish without an open draft changes nothing and returns the existing template.
Edit a published template
Send content changes with PATCH. Mail leaves the live revision alone and updates the open draft, copying the live revision into a new draft if necessary:
curl -X PATCH "https://api.lepsto.com/mail/templates/3c0f1b7e-6c2a-4f6d-b0a1-9d1e2f3a4b5c" \
-H "Authorization: Bearer $LEPSTO_API_CREDENTIAL" \
-H "Content-Type: application/json" \
-d '{ "subject": "Your receipt for order {{order_id}}" }'{
"data": {
"id": "3c0f1b7e-6c2a-4f6d-b0a1-9d1e2f3a4b5c",
"name": "Order receipt",
"subject": "Receipt for order {{order_id}}",
"status": "published",
"archivedAt": null,
"currentRevision": 1,
"draftRevision": 2
},
"success": true
}Check the result: draftRevision becomes 2, while the returned subject still describes live revision 1. The edited subject is stored in revision 2. A send before publication uses revision 1; publish revision 2 to use the new wording.
Additional PATCH calls edit revision 2. Revision 3 starts only after publication closes that draft and a later edit needs a new one.
Renaming is immediate: name is not versioned and changing it opens no draft. A PATCH with both name and content renames immediately while keeping the content changes unpublished.
The workspace marks templates with pending edits using an Unpublished draft badge and identifies both the open revision and the revision used for sending.
Read the revision history
curl "https://api.lepsto.com/mail/templates/3c0f1b7e-6c2a-4f6d-b0a1-9d1e2f3a4b5c/revisions" \
-H "Authorization: Bearer $LEPSTO_API_CREDENTIAL"{
"data": [
{
"revision": 1,
"subject": "Receipt for order {{order_id}}",
"html": "<p>Hi {{FIRST_NAME}}, thanks for order {{order_id}}.</p>",
"text": null,
"variables": [{ "name": "order_id", "type": "string", "optional": false }],
"live": true,
"createdBy": "9b2c4d6e-0a1b-4c3d-8e5f-6a7b8c9d0e1f",
"createdAt": "2026-08-02T09:00:00.000Z",
"publishedAt": "2026-08-02T09:04:00.000Z"
},
{
"revision": 2,
"subject": "Your receipt for order {{order_id}}",
"html": "<p>Hi {{FIRST_NAME}}, thanks for order {{order_id}}.</p>",
"text": null,
"variables": [{ "name": "order_id", "type": "string", "optional": false }],
"live": false,
"createdBy": "9b2c4d6e-0a1b-4c3d-8e5f-6a7b8c9d0e1f",
"createdAt": "2026-09-19T11:00:00.000Z",
"publishedAt": null
}
],
"success": true
}The API returns revisions oldest first, with their authored content. createdBy is the author’s identity or null if unknown. publishedAt records first publication; null identifies the open draft.
Use live to find the revision currently rendered by sends. It follows the template’s live pointer, so rollback can make an older revision live without changing publication timestamps.
Read one revision with GET /mail/templates/{id}/revisions/{n}. It uses the same shape, and a missing number returns 404. Preview in the workspace’s revision table shows that revision’s stored body with placeholders unrendered.
Roll back to an earlier revision
Restore the revision number you want sends to use:
curl -X POST "https://api.lepsto.com/mail/templates/3c0f1b7e-6c2a-4f6d-b0a1-9d1e2f3a4b5c/revisions/1/restore" \
-H "Authorization: Bearer $LEPSTO_API_CREDENTIAL"Check the result: currentRevision returns to 1, and subsequent sends render revision 1.
Restore changes only the live pointer. It creates no revision, alters no authored content and leaves history numbering unchanged. Revision 2 remains readable and can be restored later.
Restore returns these failures:
Case | Answer |
|---|---|
The template has never been published — there is no live revision to roll back from | 409 template_never_published |
The template has no revision with that number | 404 |
The revision named is the open draft. It has never been live, so making it live is publishing, not restoring | 409 revision_is_open_draft — use publish instead |
Restoring the already-live revision succeeds without changing anything.
In the workspace, select Make live and confirm to restore a previously published revision. The action is unavailable for the open draft.
Discard a draft
Delete an open draft to abandon unpublished changes:
curl -X DELETE "https://api.lepsto.com/mail/templates/3c0f1b7e-6c2a-4f6d-b0a1-9d1e2f3a4b5c/draft" \
-H "Authorization: Bearer $LEPSTO_API_CREDENTIAL"{ "data": { "discarded": true, "revision": 2 }, "success": true }Check the result: the response confirms discarded: true and the removed revision number. The template has no open draft, and its live content remains unchanged.
Discard returns 404 when there is no draft. If it is the only revision because the template was never published, it returns 409 draft_is_only_revision; delete that template instead.
Archive a template
Archive a template to hide it from normal lists and pickers while keeping it available by ID:
curl -X POST "https://api.lepsto.com/mail/templates/3c0f1b7e-6c2a-4f6d-b0a1-9d1e2f3a4b5c/archive" \
-H "Authorization: Bearer $LEPSTO_API_CREDENTIAL"Archiving sets archivedAt to an ISO 8601 UTC timestamp. The template disappears from default GET /mail/templates results and pickers. Use include_archived=true to list it, or select Show archived in Mail → Templates. The workspace stores that choice in ?archived=1 so a shared link reproduces the list.
Unarchive clears archivedAt and restores normal listing. Repeating archive or unarchive in its existing state changes nothing. Reading, editing, publishing, revision history and rollback all remain available while archived.
An archived template still sends by ID. Its live revision continues to render. IDs stored in application code, queues or scheduled-send requests keep working.
Archiving changes visibility only. It does not retire a sending integration. Mail → Logs keeps archived templates in its template filter so their historical sends remain searchable.
To retire a template, stop new sends against its ID first. Check usage.email_count and usage.last_sent_at on GET /mail/templates/{id}. Deleting with DELETE /mail/templates/{id} removes the template and all revisions; new sends against that ID return 404. Already-created messages keep their content, as described below.
Send from a template
Send a template object containing the published template ID and variable values:
curl -X POST "https://public.lepsto.com/$PRODUCT_ID/mail/emails" \
-H "Authorization: Bearer $LEPSTO_PUBLIC_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "Acme <hello@mail.acme.com>",
"to": "customer@example.com",
"template": {
"id": "3c0f1b7e-6c2a-4f6d-b0a1-9d1e2f3a4b5c",
"variables": { "order_id": "4711", "FIRST_NAME": "Dana" }
}
}'{ "data": { "id": "9f1f0d9c-1c74-4a2e-9f9f-2b6d8a0b3c11" }, "success": true }The template supplies its subject. Combining template with subject, html or text returns template is mutually exclusive with subject/html/text. Omit variables if none are needed. Recipients, reply_to, headers, tags and scheduled_at work as for inline messages, including scheduling.
In Mail → Emails → Send test, choose inline content or a published template. The template form creates fields for declared variables. It lists published, non-archived templates using the default template listing.
A missing template or one never published returns 404. Archiving does not prevent a send by ID.
Check the result: the send returns a message ID and uses the template’s live revision. Unpublished edits do not reach recipients.
What a template send freezes
Mail renders the template when it accepts the send request and stores the resulting subject and bodies on the message.
Already-created messages are not rendered again. Later edits, publication, rollback, archive or deletion leave their content unchanged. Scheduled messages follow the same rule: wording is fixed when you create them, even if delivery is weeks later.
Use the message record to check the exact wording sent. It preserves the rendered content independently of later changes to the template.
Next steps
- Send a message: Set recipients, headers and send times.
- Send from TypeScript: Use hosted templates from the SDK.
- Send one message to a list of people: Use audience contact values in broadcasts.
- Receive delivery events: Check outcomes through signed events.