postmark-templates
PublicRepository: activecampaign/postmark-skills
Low Risk
No security issues found
Skill manifest does not include a 'license' field. Specifying a license helps users understand usage terms.
Remediation Add 'license' field to SKILL.md frontmatter (e.g., MIT, Apache-2.0)
Description
Use when creating, managing, or sending with Postmark server-side email templates — Handlebars syntax, layout inheritance, template validation, and cross-server pushing.
Details
- author
- postmark
- version
- 1.0.0
Skill Files
# Email Templates with Postmark
## Overview
Postmark templates are server-side email templates using Handlebars syntax. Templates are rendered on Postmark's servers — no client-side rendering library needed.
| Feature | Description |
|---------|-------------|
| **Syntax** | Handlebars (Mustache-compatible) |
| **Rendering** | Server-side — no React, no client library |
| **Types** | Standard templates and Layout templates |
| **Inheritance** | Standard templates can inherit from a Layout |
| **Validation** | API endpoint to test-render templates before sending |
| **Cross-server** | Push templates between servers (staging → production) |
| **Limit** | 100 templates per server (contact support for more) |
## Quick Start
1. **Create a template** via API or the [Postmark dashboard](https://account.postmarkapp.com)
2. **Define variables** using Handlebars syntax: `{{variable_name}}`
3. **Send with template** using `POST /email/withTemplate` or `POST /email/batchWithTemplates`
4. **Pass data** via `TemplateModel` — Postmark renders and sends
## Template Syntax (Handlebars)
```handlebars
Hello {{name}}, {{! variable }}
{{{html_content}}} {{! unescaped HTML }}
{{#if premium}}Premium member{{/if}} {{! conditional }}
{{#each items}}{{this.name}}{{/each}} {{! iteration }}
{{customer.address.city}} {{! nested object }}
```
See [references/handlebars-syntax.md](references/handlebars-syntax.md) for the full syntax reference including conditionals, iteration with index, nested objects, and common mistakes.
## Template Types
| Type | `TemplateType` | Sendable | Purpose |
|------|---------------|----------|---------|
| **Standard** | `"Standard"` | Yes | Defines subject, HTML, and text body |
| **Layout** | `"Layout"` | No | Reusable wrapper injected via `{{{@content}}}` |
Standard templates can reference a Layout via `LayoutTemplate: "layout-alias"`. The Standard template's body replaces `{{{@content}}}` in the Layout at send time.
See [references/layout-templates.md](references/layout-templates.md) for layout creation, assignment, and examples.
## API Endpoints
### Template CRUD
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/templates` | `POST` | Create a new template |
| `/templates` | `GET` | List all templates (`?count=100&offset=0&templateType=Standard`) |
| `/templates/{idOrAlias}` | `GET` | Get a single template |
| `/templates/{idOrAlias}` | `PUT` | Update a template |
| `/templates/{idOrAlias}` | `DELETE` | Delete a template |
| `/templates/validate` | `POST` | Validate template rendering |
| `/templates/push` | `PUT` | Push templates to another server |
### Sending with Templates
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/email/withTemplate` | `POST` | Send single email with template |
| `/email/batchWithTemplates` | `POST` | Send batch with templates (up to 500) |
## Send with Template
Always use `TemplateAlias` over `TemplateId` — aliases survive re-creation and work across environments:
```javascript
const postmark = require('postmark');
const client = new postmark.ServerClient(process.env.POSTMARK_SERVER_TOKEN);
await client.sendEmailWithTemplate({
From: '[email protected]',
To: '[email protected]',
TemplateAlias: 'welcome-email',
TemplateModel: {
name: 'Jane Doe',
product_name: 'Acme App'
},
MessageStream: 'outbound'
});
```
## Validate a Template
Test-render without sending — useful in CI/CD before deploying template changes:
```javascript
const validation = await client.validateTemplate({
Subject: 'Welcome {{name}}',
HtmlBody: '<h1>Hello {{name}}</h1>',
TextBody: 'Hello {{name}}',
TestRenderModel: { name: 'Test User' }
});
console.log('Valid:', validation.AllContentIsValid);
console.log('Rendered:', validation.Subject.RenderedContent);
```
See [references/template-api.md](references/template-api.md) for full CRUD operations (create, list, update, delete), batch sending, validate, and push between servers.
## Common Mistakes
| Mistake | Fix |
|---------|-----|
| Using `{{html}}` for HTML content | Use triple braces `{{{html}}}` for unescaped HTML |
| Forgetting `{{{@content}}}` in Layout | Layout templates must include `{{{@content}}}` placeholder |
| Deleting a Layout with dependents | Remove layout association from Standard templates first |
| Using Template ID across environments | Use `TemplateAlias` — it survives re-creation and works across servers |
| Not validating before deploy | Use `/templates/validate` to test-render before sending |
| Sending a Layout directly | Layouts are wrappers — you can only send Standard templates |
| Missing TemplateModel fields | Handlebars renders missing variables as empty strings — validate your data |
| Exceeding 100 templates | Contact Postmark support to increase the per-server limit |
## Notes
- Templates use Handlebars (Mustache-compatible) syntax — no React or client-side rendering needed
- Template aliases are strings; template IDs are integers — prefer aliases for portability
- `TemplateType` is either `Standard` (sendable) or `Layout` (wrapper)
- Layout inheritance: Standard template body replaces `{{{@content}}}` in the Layout
- Push templates between servers using the Account Token (not Server Token)
- Maximum 100 templates per server by default
- Template validation (`/templates/validate`) lets you test-render without sending
- Both `TemplateId` and `TemplateAlias` work for sending — use one, not both
# Handlebars Template Syntax
Postmark templates use Handlebars (Mustache-compatible) syntax. Templates are rendered server-side — no client-side library needed.
## Variables
```handlebars
Hello {{name}},
Your order {{order_id}} has been confirmed.
Thank you for shopping with {{company_name}}.
```
Variables render to an empty string if missing from `TemplateModel` — they do not throw errors.
## Unescaped HTML
Use triple braces to render raw HTML without escaping:
```handlebars
{{{html_content}}}
```
- Use `{{variable}}` for user-supplied content (HTML-escaped for security)
- Use `{{{variable}}}` only for trusted HTML you control
## Conditionals
```handlebars
{{#if premium_member}}
<p>Thank you for being a premium member!</p>
{{else}}
<p>Upgrade to premium for exclusive benefits.</p>
{{/if}}
```
Falsy values (`false`, `0`, `""`, `null`, `undefined`, `[]`) trigger the `else` branch.
### Nested Conditionals
```handlebars
{{#if order_shipped}}
{{#if tracking_number}}
<p>Track your order: {{tracking_number}}</p>
{{else}}
<p>Your order is on its way!</p>
{{/if}}
{{else}}
<p>Your order is being prepared.</p>
{{/if}}
```
## Iteration
```handlebars
<table>
{{#each items}}
<tr>
<td>{{this.name}}</td>
<td>{{this.quantity}}</td>
<td>{{this.price}}</td>
</tr>
{{/each}}
</table>
```
Inside `{{#each}}`, use `{{this.field}}` or just `{{field}}` to access item properties.
### Index and First/Last
```handlebars
{{#each items}}
<p>{{@index}}. {{this.name}}</p>
{{/each}}
```
`@index` is 0-based. `@first` and `@last` are also available as boolean helpers.
## Nested Objects
```handlebars
{{customer.name}}
{{customer.address.city}}
{{customer.address.zip}}
```
## Layout Placeholder
In Layout templates, use this special placeholder where the Standard template body is injected:
```handlebars
{{{@content}}}
```
Must use triple braces — the Standard template's rendered body is HTML and should not be escaped.
## Full TemplateModel Example
Template:
```handlebars
Hello {{name}},
{{#if premium_member}}
Thank you for being a premium member!
{{/if}}
Your order {{order_id}} contains:
{{#each items}}
- {{this.name}} x{{this.quantity}} — {{this.price}}
{{/each}}
Shipping to: {{customer.address.city}}, {{customer.address.state}}
{{{custom_message}}}
```
Corresponding `TemplateModel`:
```json
{
"name": "Jane Doe",
"premium_member": true,
"order_id": "ORD-12345",
"items": [
{ "name": "Widget", "quantity": 2, "price": "$19.99" },
{ "name": "Gadget", "quantity": 1, "price": "$29.99" }
],
"customer": {
"address": {
"city": "San Francisco",
"state": "CA"
}
},
"custom_message": "<strong>Special offer inside!</strong>"
}
```
## Common Syntax Mistakes
| Mistake | Fix |
|---------|-----|
| `{{html_body}}` renders escaped HTML | Use `{{{html_body}}}` for unescaped HTML |
| `{{#each}}` items without `this.` | Use `{{this.field}}` inside each blocks |
| Missing variable shows as blank | Handlebars renders missing vars as empty string — validate your model |
| `{{@content}}` in Layout | Must use triple braces: `{{{@content}}}` |
# Layout Templates
Layout templates provide a reusable wrapper (CSS, header, footer) that Standard templates inherit from. This enforces consistent branding without duplicating boilerplate across every template.
## How It Works
```
Layout Template
├── <html><head>styles</head><body>
├── <header>Logo, navigation</header>
├── {{{@content}}} ← Standard template body injected here
├── <footer>Links, unsubscribe</footer>
└── </body></html>
```
The Standard template only defines the content section — the Layout wraps it automatically when sending.
## Template Types
### Standard Templates
Regular sendable templates that define subject, HTML body, and text body:
```json
{
"Name": "Order Confirmation",
"Alias": "order-confirmation",
"Subject": "Order {{order_id}} confirmed",
"HtmlBody": "<h1>Order Confirmed</h1><p>Hi {{name}}, your order {{order_id}} is confirmed.</p>",
"TextBody": "Order Confirmed\nHi {{name}}, your order {{order_id}} is confirmed.",
"TemplateType": "Standard"
}
```
### Layout Templates
Reusable wrappers that inject Standard template content via `{{{@content}}}`:
```json
{
"Name": "Base Layout",
"Alias": "base-layout",
"HtmlBody": "<html><body><header>...</header><main>{{{@content}}}</main><footer>...</footer></body></html>",
"TextBody": "{{{@content}}}\n\n---\n(c) 2025 Your Company",
"TemplateType": "Layout"
}
```
**Layouts do not have a Subject** — the subject is defined on the Standard template.
## Creating a Layout
```javascript
const postmark = require('postmark');
const client = new postmark.ServerClient(process.env.POSTMARK_SERVER_TOKEN);
const layout = await client.createTemplate({
Name: 'Company Layout',
Alias: 'company-layout',
HtmlBody: `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; margin: 0; padding: 0; background: #f4f4f4; }
.wrapper { max-width: 600px; margin: 0 auto; background: #ffffff; }
.header { background: #0a2540; padding: 24px; text-align: center; }
.header img { height: 32px; }
.content { padding: 32px 40px; }
.footer { padding: 20px 40px; text-align: center; color: #666; font-size: 12px; }
.footer a { color: #666; }
</style>
</head>
<body>
<div class="wrapper">
<div class="header">
<img src="https://yourdomain.com/logo.png" alt="Your Company">
</div>
<div class="content">
{{{@content}}}
</div>
<div class="footer">
<p>© {{year}} Your Company, Inc.</p>
{{#if unsubscribe_url}}
<p><a href="{{unsubscribe_url}}">Unsubscribe</a></p>
{{/if}}
</div>
</div>
</body>
</html>
`,
TextBody: `{{{@content}}}
---
(c) {{year}} Your Company, Inc.
{{#if unsubscribe_url}}Unsubscribe: {{unsubscribe_url}}{{/if}}`,
TemplateType: 'Layout'
});
console.log('Layout ID:', layout.TemplateId);
```
## Assigning a Layout to a Standard Template
Set `LayoutTemplate` to the layout's alias (or ID):
```javascript
const template = await client.createTemplate({
Name: 'Order Confirmation',
Alias: 'order-confirmation',
LayoutTemplate: 'company-layout',
Subject: 'Your order {{order_id}} is confirmed',
HtmlBody: `
<h1>Order Confirmed!</h1>
<p>Hi {{name}}, your order <strong>{{order_id}}</strong> is confirmed.</p>
<table>
{{#each items}}
<tr>
<td>{{this.name}}</td>
<td>{{this.price}}</td>
</tr>
{{/each}}
</table>
<p>Total: {{order_total}}</p>
`,
TextBody: `Order Confirmed!\n\nHi {{name}}, your order {{order_id}} is confirmed.\n\nTotal: {{order_total}}`,
TemplateType: 'Standard'
});
```
The Standard template's `HtmlBody` replaces `{{{@content}}}` in the Layout automatically.
## Variables Flow Through Both
Variables in `TemplateModel` are accessible in both the Layout and the Standard template:
```javascript
await client.sendEmailWithTemplate({
From: '[email protected]',
To: '[email protected]',
TemplateAlias: 'order-confirmation',
TemplateModel: {
// Used by Standard template
name: 'Jane Doe',
order_id: 'ORD-12345',
items: [{ name: 'Widget', price: '$19.99' }],
order_total: '$19.99',
// Used by Layout template
year: '2025',
unsubscribe_url: null // null = condition is false, footer link hidden
},
MessageStream: 'outbound'
});
```
## Changing or Removing a Layout
```javascript
// Assign a different layout
await client.editTemplate('order-confirmation', {
LayoutTemplate: 'new-layout'
});
// Remove a layout (set to null)
await client.editTemplate('order-confirmation', {
LayoutTemplate: null
});
```
## Rules and Constraints
| Rule | Detail |
|------|--------|
| `{{{@content}}}` required | Layout must include this placeholder — triple braces |
| No Subject on Layout | Subject is defined only on Standard templates |
| Layouts are not sendable | You can only send Standard templates, not Layouts directly |
| Delete order | Cannot delete a Layout that has dependent Standard templates |
| No nested layouts | Layouts cannot inherit from other Layouts |
| Variables shared | TemplateModel variables are available to both Layout and Standard |
# Template API Reference
## Create a Template
**Endpoint:** `POST /templates`
### Node.js
```javascript
const postmark = require('postmark');
const client = new postmark.ServerClient(process.env.POSTMARK_SERVER_TOKEN);
const template = await client.createTemplate({
Name: 'Welcome Email',
Alias: 'welcome-email',
Subject: 'Welcome to {{product_name}}, {{name}}!',
HtmlBody: `
<h1>Welcome, {{name}}!</h1>
<p>Thanks for joining {{product_name}}.</p>
{{#if trial}}
<p>Your trial ends on {{trial_end_date}}.</p>
{{/if}}
<a href="{{action_url}}">Get Started</a>
`,
TextBody: 'Welcome, {{name}}!\n\nThanks for joining {{product_name}}.\n\nGet started: {{action_url}}',
TemplateType: 'Standard'
});
console.log('Template ID:', template.TemplateId);
```
### cURL
```bash
curl "https://api.postmarkapp.com/templates" \
-X POST \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-H "X-Postmark-Server-Token: $POSTMARK_SERVER_TOKEN" \
-d '{
"Name": "Welcome Email",
"Alias": "welcome-email",
"Subject": "Welcome to {{product_name}}, {{name}}!",
"HtmlBody": "<h1>Welcome, {{name}}!</h1><p>Thanks for joining {{product_name}}.</p>",
"TextBody": "Welcome, {{name}}!\n\nThanks for joining {{product_name}}.",
"TemplateType": "Standard"
}'
```
### Response
```json
{
"TemplateId": 12345,
"Name": "Welcome Email",
"Alias": "welcome-email",
"Active": true,
"TemplateType": "Standard",
"LayoutTemplate": null
}
```
---
## List Templates
```javascript
const templates = await client.getTemplates({
count: 100,
offset: 0,
templateType: 'Standard' // 'Standard', 'Layout', or 'All'
});
templates.Templates.forEach(t => {
console.log(`${t.Name} (${t.Alias || t.TemplateId}) — ${t.Active ? 'Active' : 'Inactive'}`);
});
```
---
## Get a Template
```javascript
const template = await client.getTemplate('welcome-email');
// or by ID:
const template = await client.getTemplate(12345);
```
---
## Update a Template
```javascript
await client.editTemplate('welcome-email', {
Subject: 'Welcome to {{product_name}}!',
HtmlBody: '<h1>Updated content for {{name}}</h1>'
});
// By ID:
await client.editTemplate(12345, {
Name: 'Welcome Email v2',
Subject: 'Welcome aboard, {{name}}!'
});
```
Only fields you include are updated — omitted fields are unchanged.
---
## Delete a Template
```javascript
await client.deleteTemplate('welcome-email');
// or by ID:
await client.deleteTemplate(12345);
```
**Note:** You cannot delete a Layout template that has dependent Standard templates. Remove the `LayoutTemplate` association from all dependents first.
---
## Validate a Template
Test-render without sending. Use in CI/CD to verify templates before deploying.
**Endpoint:** `POST /templates/validate`
### Node.js
```javascript
const validation = await client.validateTemplate({
Subject: 'Welcome {{name}}',
HtmlBody: '<h1>Hello {{name}}</h1>{{#if premium}}<p>Premium member</p>{{/if}}',
TextBody: 'Hello {{name}}',
TestRenderModel: {
name: 'Test User',
premium: true
}
});
if (validation.AllContentIsValid) {
console.log('Rendered subject:', validation.Subject.RenderedContent);
console.log('Rendered HTML:', validation.HtmlBody.RenderedContent);
} else {
console.error('Errors:', validation.HtmlBody.ValidationErrors);
}
```
### cURL
```bash
curl "https://api.postmarkapp.com/templates/validate" \
-X POST \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-H "X-Postmark-Server-Token: $POSTMARK_SERVER_TOKEN" \
-d '{
"Subject": "Welcome {{name}}",
"HtmlBody": "<h1>Hello {{name}}</h1>",
"TextBody": "Hello {{name}}",
"TestRenderModel": { "name": "Test User" }
}'
```
The response includes `SuggestedTemplateModel` — a list of all variables detected in the template. Useful for documentation.
---
## Push Templates Between Servers
Sync templates from staging to production. Requires an **Account Token**, not a Server Token.
**Endpoint:** `PUT /templates/push`
```bash
# Preview first (PerformChanges: false)
curl "https://api.postmarkapp.com/templates/push" \
-X PUT \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-H "X-Postmark-Account-Token: $POSTMARK_ACCOUNT_TOKEN" \
-d '{
"SourceServerID": 12345,
"DestinationServerID": 67890,
"PerformChanges": false
}'
# Apply (PerformChanges: true)
curl "https://api.postmarkapp.com/templates/push" \
-X PUT \
-H "Accept: application/json" \
-H "Content-Type: application/json" \
-H "X-Postmark-Account-Token: $POSTMARK_ACCOUNT_TOKEN" \
-d '{
"SourceServerID": 12345,
"DestinationServerID": 67890,
"PerformChanges": true
}'
```
Templates are matched by Alias — templates without an Alias cannot be pushed.
---
## Send with a Template
### Single Email (alias recommended)
```javascript
const result = await client.sendEmailWithTemplate({
From: '[email protected]',
To: '[email protected]',
TemplateAlias: 'welcome-email',
TemplateModel: {
name: 'Jane Doe',
product_name: 'Acme App',
trial: true,
trial_end_date: 'February 15, 2025',
action_url: 'https://app.yourdomain.com/start'
},
MessageStream: 'outbound'
});
```
### Batch with Templates (up to 500)
```javascript
const results = await client.sendEmailBatchWithTemplates([
{
From: '[email protected]',
To: '[email protected]',
TemplateAlias: 'welcome-email',
TemplateModel: { name: 'User 1', product_name: 'Acme App' },
MessageStream: 'outbound'
},
{
From: '[email protected]',
To: '[email protected]',
TemplateAlias: 'welcome-email',
TemplateModel: { name: 'User 2', product_name: 'Acme App' },
MessageStream: 'outbound'
}
]);
results.forEach((result, i) => {
if (result.ErrorCode === 0) {
console.log(`Email ${i + 1} sent: ${result.MessageID}`);
} else {
console.error(`Email ${i + 1} failed: ${result.Message}`);
}
});
```