Templates
Templates with variables, drafts, published versions, tests and images.
Templates store the content of emails you send over and over, with variables. You edit a draft and publish it when it is ready: sends always use the latest published version, so editing the draft changes nothing in production. To send with a template, use the template field of POST /emails. In all the template endpoints below, {id} accepts the ID or the alias. Layouts store email designs to reuse, and the plan defines how many templates and layouts the team has (see Plan limits).
#List templates
GET/templates
Query parameters
limitintegerFrom 1 to 100. Default: 20.afterstringCursor for the next page.beforestringCursor for the previous page.searchstringSearches the name and the alias.statusstringpublishedordraft(never published).
cURL
curl 'https://api.postal.so/templates?status=published' \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/templates?status=published', {
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.get(
"https://api.postal.so/templates",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
params={
"status": "published",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('templates', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'query' => [
'status' => 'published',
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "list",
"has_more": false,
"data": [
{
"object": "template",
"id": "0199f3a0-1b2c-7d3e-9f4a-5b6c7d8e9f0a",
"paused_by_plan": false,
"name": "Order shipped",
"alias": "order-shipped",
"status": "published",
"published_at": "2026-10-10T15:00:00.000000Z",
"created_at": "2026-10-08T11:30:00.000000Z",
"updated_at": "2026-10-10T14:58:12.000000Z",
"has_unpublished_versions": false,
"current_version_id": "0199f3a0-8c9d-7e0f-a1b2-c3d4e5f6a7b8"
}
]
}
#Create a template
POST/templates
Creates the template as a draft. Publish it with POST /templates/{id}/publish before using it in sends.
Request body
namestringrequiredInternal name, up to 255 characters. Required when creating.htmlstringrequiredEmail HTML, up to 2 MB, with variables in the{{{NAME}}}format. Required when creating.aliasstring | nullA unique alias to use in place of the ID, such asorder-shipped: letters, numbers,.,-and_, up to 100 characters. It must start with a letter or number and cannot look like an ID.fromstring | nullDefault sender.subjectstring | nullDefault subject; it accepts variables.reply_tostring | string[] | nullDefault reply addresses.textstring | nullText version. Without it, Postal generates the text from the HTML when sending.variablesobject[] | nullUp to 50 declared variables.keystringrequiredVariable name: letters, numbers and_, up to 50 characters.typestringstring(default) ornumber.fallback_valuestring | number | nullValue used when the send does not provide the variable. Without it, the variable is required.
editorobject | nullState of the dashboard's visual editor. You can ignore it.
Errors
- 422
validation_errorInvalid field, alias already in use or duplicate variable. - 403
plan_limit_reachedThe plan does not allow more templates.
cURL
curl -X POST https://api.postal.so/templates \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Order shipped",
"alias": "order-shipped",
"from": "Acme Store <[email protected]>",
"subject": "Your order #{{{ORDER}}} is out for delivery",
"html": "<p>Hi, {{{NAME}}}! Order <b>#{{{ORDER}}}</b> is on its way.</p>",
"variables": [
{
"key": "NAME",
"fallback_value": "customer"
},
{
"key": "ORDER",
"type": "number"
}
]
}'
Node.js
const response = await fetch('https://api.postal.so/templates', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'Order shipped',
alias: 'order-shipped',
from: 'Acme Store <[email protected]>',
subject: 'Your order #{{{ORDER}}} is out for delivery',
html: '<p>Hi, {{{NAME}}}! Order <b>#{{{ORDER}}}</b> is on its way.</p>',
variables: [
{
key: 'NAME',
fallback_value: 'customer',
},
{
key: 'ORDER',
type: 'number',
},
],
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/templates",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"name": "Order shipped",
"alias": "order-shipped",
"from": "Acme Store <[email protected]>",
"subject": "Your order #{{{ORDER}}} is out for delivery",
"html": "<p>Hi, {{{NAME}}}! Order <b>#{{{ORDER}}}</b> is on its way.</p>",
"variables": [
{
"key": "NAME",
"fallback_value": "customer",
},
{
"key": "ORDER",
"type": "number",
},
],
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('templates', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'name' => 'Order shipped',
'alias' => 'order-shipped',
'from' => 'Acme Store <[email protected]>',
'subject' => 'Your order #{{{ORDER}}} is out for delivery',
'html' => '<p>Hi, {{{NAME}}}! Order <b>#{{{ORDER}}}</b> is on its way.</p>',
'variables' => [
[
'key' => 'NAME',
'fallback_value' => 'customer',
],
[
'key' => 'ORDER',
'type' => 'number',
],
],
],
]);
$data = json_decode($response->getBody(), true);
{
"id": "0199f3a0-1b2c-7d3e-9f4a-5b6c7d8e9f0a",
"object": "template"
}
#Retrieve a template
GET/templates/{id}
Returns the template with the content of the draft. has_unpublished_versions tells whether the draft differs from the published version.
Path parameters
idstringrequiredID or alias.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl https://api.postal.so/templates/order-shipped \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/templates/order-shipped', {
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.get(
"https://api.postal.so/templates/order-shipped",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('templates/order-shipped', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "template",
"id": "0199f3a0-1b2c-7d3e-9f4a-5b6c7d8e9f0a",
"paused_by_plan": false,
"name": "Order shipped",
"alias": "order-shipped",
"status": "published",
"published_at": "2026-10-10T15:00:00.000000Z",
"created_at": "2026-10-08T11:30:00.000000Z",
"updated_at": "2026-10-10T14:58:12.000000Z",
"has_unpublished_versions": false,
"current_version_id": "0199f3a0-8c9d-7e0f-a1b2-c3d4e5f6a7b8",
"from": "Acme Store <[email protected]>",
"subject": "Your order #{{{ORDER}}} is out for delivery",
"reply_to": ["[email protected]"],
"html": "<p>Hi, {{{NAME}}}! Order <b>#{{{ORDER}}}</b> is on its way.</p>",
"text": null,
"variables": [
{
"key": "NAME",
"type": "string",
"fallback_value": "customer"
},
{
"key": "ORDER",
"type": "number",
"fallback_value": null
}
],
"editor": null
}
#Update a template
PATCH/templates/{id}
Changes the draft. All fields are optional; the ones you do not send stay as they are. The published version only changes when you publish again.
Path parameters
idstringrequiredID or alias.
Request body (all optional)
namestringInternal name, up to 255 characters. Required when creating.htmlstringEmail HTML, up to 2 MB, with variables in the{{{NAME}}}format. Required when creating.aliasstring | nullA unique alias to use in place of the ID, such asorder-shipped: letters, numbers,.,-and_, up to 100 characters. It must start with a letter or number and cannot look like an ID.fromstring | nullDefault sender.subjectstring | nullDefault subject; it accepts variables.reply_tostring | string[] | nullDefault reply addresses.textstring | nullText version. Without it, Postal generates the text from the HTML when sending.variablesobject[] | nullUp to 50 declared variables.keystringrequiredVariable name: letters, numbers and_, up to 50 characters.typestringstring(default) ornumber.fallback_valuestring | number | nullValue used when the send does not provide the variable. Without it, the variable is required.
editorobject | nullState of the dashboard's visual editor. You can ignore it.
Errors
- 422
validation_errorA field is invalid. The message says which one. - 403
paused_by_planThe template is paused because it is above the plan's template limit. - 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl -X PATCH https://api.postal.so/templates/order-shipped \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"subject": "Order #{{{ORDER}}} on its way"
}'
Node.js
const response = await fetch('https://api.postal.so/templates/order-shipped', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
subject: 'Order #{{{ORDER}}} on its way',
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.patch(
"https://api.postal.so/templates/order-shipped",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"subject": "Order #{{{ORDER}}} on its way",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->patch('templates/order-shipped', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'subject' => 'Order #{{{ORDER}}} on its way',
],
]);
$data = json_decode($response->getBody(), true);
{
"id": "0199f3a0-1b2c-7d3e-9f4a-5b6c7d8e9f0a",
"object": "template"
}
#Delete a template
DELETE/templates/{id}
Sends that use the template start to receive 404.
Path parameters
idstringrequiredID or alias.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl -X DELETE https://api.postal.so/templates/0199f3a0-1b2c-7d3e-9f4a-5b6c7d8e9f0a \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/templates/0199f3a0-1b2c-7d3e-9f4a-5b6c7d8e9f0a', {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.delete(
"https://api.postal.so/templates/0199f3a0-1b2c-7d3e-9f4a-5b6c7d8e9f0a",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->delete('templates/0199f3a0-1b2c-7d3e-9f4a-5b6c7d8e9f0a', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "template",
"id": "0199f3a0-1b2c-7d3e-9f4a-5b6c7d8e9f0a",
"deleted": true
}
#Publish a template
POST/templates/{id}/publish
Creates a new version from the current draft. From then on, sends with this template use that version.
Path parameters
idstringrequiredID or alias.
Errors
- 403
paused_by_planThe template is paused because it is above the plan's template limit. - 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl -X POST https://api.postal.so/templates/order-shipped/publish \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/templates/order-shipped/publish', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/templates/order-shipped/publish",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('templates/order-shipped/publish', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"id": "0199f3a0-1b2c-7d3e-9f4a-5b6c7d8e9f0a",
"object": "template"
}
#Duplicate a template
POST/templates/{id}/duplicate
Creates a copy of the draft named "… (copy)" (or "… (cópia)" with Accept-Language: pt-BR), with no alias and no published versions.
Path parameters
idstringrequiredID or alias.
Errors
- 403
plan_limit_reachedThe plan does not allow more templates. - 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl -X POST https://api.postal.so/templates/order-shipped/duplicate \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/templates/order-shipped/duplicate', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/templates/order-shipped/duplicate",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('templates/order-shipped/duplicate', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"id": "0199f3a2-4e5f-7a6b-8c7d-9e0f1a2b3c4d",
"object": "template"
}
#Send a test
POST/templates/{id}/test
Sends the draft (published or not) to up to 5 addresses, with the subject prefixed by [Test] (or [Teste] in Portuguese, following Accept-Language). The test is a normal send: it counts toward the quota and shows up in GET /emails with the template_test tag.
Path parameters
idstringrequiredID or alias.
Request body
tostring[]requiredFrom 1 to 5 addresses.fromstringSender; default: the template'sfrom.variablesobjectVariable values for the test.
Errors
- 422
validation_errorInvalid address, missing sender or a required variable without a value. - 403
validation_errorThe sender's domain is not verified. - 403
sending_pausedSending is paused for your team. - 403
paused_by_planThe template (or the sender's domain) is paused by the plan. - 429
daily_quota_exceededThe plan's sending quota is used up (alsomonthly_quota_exceeded). - 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl -X POST https://api.postal.so/templates/order-shipped/test \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"to": ["[email protected]"],
"variables": {
"NAME": "Rachel",
"ORDER": 10482
}
}'
Node.js
const response = await fetch('https://api.postal.so/templates/order-shipped/test', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
to: ['[email protected]'],
variables: {
NAME: 'Rachel',
ORDER: 10482,
},
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/templates/order-shipped/test",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"to": ["[email protected]"],
"variables": {
"NAME": "Rachel",
"ORDER": 10482,
},
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('templates/order-shipped/test', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'to' => ['[email protected]'],
'variables' => [
'NAME' => 'Rachel',
'ORDER' => 10482,
],
],
]);
$data = json_decode($response->getBody(), true);
{
"id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
"object": "email"
}
#List versions
GET/templates/{id}/versions
The published versions, newest first (up to 100). current marks the one in use.
Path parameters
idstringrequiredID or alias.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl https://api.postal.so/templates/order-shipped/versions \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/templates/order-shipped/versions', {
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.get(
"https://api.postal.so/templates/order-shipped/versions",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('templates/order-shipped/versions', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "list",
"has_more": false,
"data": [
{
"object": "template_version",
"id": "0199f3a0-8c9d-7e0f-a1b2-c3d4e5f6a7b8",
"version": 2,
"subject": "Your order #{{{ORDER}}} is out for delivery",
"published_at": "2026-10-10T15:00:00.000000Z",
"published_by": "Rachel Lane",
"current": true
},
{
"object": "template_version",
"id": "0199f3a0-5a6b-7c7d-8e9f-0a1b2c3d4e5f",
"version": 1,
"subject": "Your order is out for delivery",
"published_at": "2026-10-08T12:10:00.000000Z",
"published_by": "Rachel Lane",
"current": false
}
]
}
#Restore a version
POST/templates/{id}/versions/{version_id}/restore
Copies a published version back to the draft. To put it in use, publish right after.
Path parameters
idstringrequiredID or alias.version_idstringrequiredVersion ID.
Errors
- 403
paused_by_planThe template is paused because it is above the plan's template limit. - 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl -X POST https://api.postal.so/templates/order-shipped/versions/0199f3a0-5a6b-7c7d-8e9f-0a1b2c3d4e5f/restore \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/templates/order-shipped/versions/0199f3a0-5a6b-7c7d-8e9f-0a1b2c3d4e5f/restore', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/templates/order-shipped/versions/0199f3a0-5a6b-7c7d-8e9f-0a1b2c3d4e5f/restore",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('templates/order-shipped/versions/0199f3a0-5a6b-7c7d-8e9f-0a1b2c3d4e5f/restore', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"id": "0199f3a0-1b2c-7d3e-9f4a-5b6c7d8e9f0a",
"object": "template"
}
#Upload an image
POST/assets
Hosts an image to use in your emails. The returned URL is public (email clients need to load it), permanent and hard to guess. Send it as multipart/form-data.
Request body (multipart/form-data)
filefilerequiredPNG, JPG, GIF or WebP, up to 5 MB.
Errors
- 422
validation_errorMissing file, a different file type or larger than 5 MB.
cURL
curl -X POST https://api.postal.so/assets \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-F "[email protected]"
Node.js
import fs from 'node:fs/promises';
const form = new FormData();
form.append('file', new Blob([await fs.readFile('banner.png')]), 'banner.png');
const response = await fetch('https://api.postal.so/assets', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
body: form,
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/assets",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
files={"file": open("banner.png", "rb")},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('assets', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'multipart' => [
['name' => 'file', 'contents' => fopen('banner.png', 'r')],
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "asset",
"id": "019a1a00-2b3c-7d4e-8f5a-6b7c8d9e0f1a",
"url": "https://media.postal.so/uploads/assets/0199f0e0-1a2b-7c3d-8e4f-5a6b7c8d9e0f/Xk2pQ9vLm4Rt7Wb1Yc5Hf3Lz8Ag6Ej0Nd2Sq4UvB/banner.png",
"filename": "banner.png"
}
#Layouts
A layout is an email design saved by the team ("My layouts" in the dashboard), to start new templates and broadcasts without designing everything again. It holds the finished HTML and, if it came from the visual editor, the editor document. A layout is not used directly when sending: sending uses templates.
Layouts live under /layouts and require a full-access key.
#List layouts
GET/layouts
Lists the team's layouts, newest first. Each item includes the HTML, because the dashboard draws each layout's card from it.
Query parameters
limitintegerFrom 1 to 100. Default: 20.afterstringCursor for the next page.beforestringCursor for the previous page.searchstringSearches the name and the description.categorystringOnly layouts of this category.
Errors
- 422
validation_errorA field is invalid. The message says which one.
cURL
curl 'https://api.postal.so/layouts?category=transactional' \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/layouts?category=transactional', {
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.get(
"https://api.postal.so/layouts",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
params={
"category": "transactional",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('layouts', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'query' => [
'category' => 'transactional',
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "list",
"has_more": false,
"data": [
{
"object": "layout",
"id": "019a1a01-3c4d-7e5f-9a6b-7c8d9e0f1a2b",
"paused_by_plan": false,
"name": "Order shipped",
"description": "Notice that the order is out for delivery.",
"category": "transactional",
"subject": "Your order #{{{ORDER}}} is out for delivery",
"preheader": "Follow the delivery.",
"editor": null,
"html": "<p>Hi, {{{NAME}}}! Order <b>#{{{ORDER}}}</b> is on its way.</p>",
"text": null,
"created_by": "Rachel Lane",
"created_at": "2026-10-08T11:30:00.000000Z",
"updated_at": "2026-10-08T11:30:00.000000Z"
}
]
}
#Create a layout
POST/layouts
Request body
namestringrequiredLayout name, up to 255 characters. Required when creating.htmlstringrequiredEmail HTML, up to 2 MB. Required when creating.descriptionstring | nullDescription, up to 500 characters.categorystring | nullOne of:welcome,newsletter,promotion,product,ecommerce,transactional,announcement,event,onboarding,reengagement,feedback,seasonal,content,services.subjectstring | nullSuggested subject, up to 998 characters.preheaderstring | nullPreview text, up to 500 characters.textstring | nullText version, up to 2 MB.editorobject | nullThe dashboard's visual editor document. It needsversion(1or2) andblocks(a list of up to 300 blocks, each with atype);styleis optional. You can ignore it if you only use the HTML.
Errors
- 422
validation_errorA field is missing or invalid: unknown category, an editor document without version 1 or 2 or without a list of blocks, or more than 300 blocks. - 403
plan_limit_reachedThe plan does not allow more layouts.
cURL
curl -X POST https://api.postal.so/layouts \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Order shipped",
"category": "transactional",
"subject": "Your order #{{{ORDER}}} is out for delivery",
"html": "<p>Hi, {{{NAME}}}! Order <b>#{{{ORDER}}}</b> is on its way.</p>"
}'
Node.js
const response = await fetch('https://api.postal.so/layouts', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: 'Order shipped',
category: 'transactional',
subject: 'Your order #{{{ORDER}}} is out for delivery',
html: '<p>Hi, {{{NAME}}}! Order <b>#{{{ORDER}}}</b> is on its way.</p>',
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/layouts",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"name": "Order shipped",
"category": "transactional",
"subject": "Your order #{{{ORDER}}} is out for delivery",
"html": "<p>Hi, {{{NAME}}}! Order <b>#{{{ORDER}}}</b> is on its way.</p>",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('layouts', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'name' => 'Order shipped',
'category' => 'transactional',
'subject' => 'Your order #{{{ORDER}}} is out for delivery',
'html' => '<p>Hi, {{{NAME}}}! Order <b>#{{{ORDER}}}</b> is on its way.</p>',
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "layout",
"id": "019a1a01-3c4d-7e5f-9a6b-7c8d9e0f1a2b",
"paused_by_plan": false,
"name": "Order shipped",
"description": "Notice that the order is out for delivery.",
"category": "transactional",
"subject": "Your order #{{{ORDER}}} is out for delivery",
"preheader": "Follow the delivery.",
"editor": null,
"html": "<p>Hi, {{{NAME}}}! Order <b>#{{{ORDER}}}</b> is on its way.</p>",
"text": null,
"created_by": "Rachel Lane",
"created_at": "2026-10-08T11:30:00.000000Z",
"updated_at": "2026-10-08T11:30:00.000000Z"
}
#Retrieve a layout
GET/layouts/{id}
Path parameters
idstringrequiredLayout ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl https://api.postal.so/layouts/019a1a01-3c4d-7e5f-9a6b-7c8d9e0f1a2b \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/layouts/019a1a01-3c4d-7e5f-9a6b-7c8d9e0f1a2b', {
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.get(
"https://api.postal.so/layouts/019a1a01-3c4d-7e5f-9a6b-7c8d9e0f1a2b",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->get('layouts/019a1a01-3c4d-7e5f-9a6b-7c8d9e0f1a2b', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "layout",
"id": "019a1a01-3c4d-7e5f-9a6b-7c8d9e0f1a2b",
"paused_by_plan": false,
"name": "Order shipped",
"description": "Notice that the order is out for delivery.",
"category": "transactional",
"subject": "Your order #{{{ORDER}}} is out for delivery",
"preheader": "Follow the delivery.",
"editor": null,
"html": "<p>Hi, {{{NAME}}}! Order <b>#{{{ORDER}}}</b> is on its way.</p>",
"text": null,
"created_by": "Rachel Lane",
"created_at": "2026-10-08T11:30:00.000000Z",
"updated_at": "2026-10-08T11:30:00.000000Z"
}
#Update a layout
PATCH/layouts/{id}
All fields are optional; the ones you do not send stay as they are.
Path parameters
idstringrequiredLayout ID.
Request body (all optional)
namestringLayout name, up to 255 characters. Required when creating.htmlstringEmail HTML, up to 2 MB. Required when creating.descriptionstring | nullDescription, up to 500 characters.categorystring | nullOne of:welcome,newsletter,promotion,product,ecommerce,transactional,announcement,event,onboarding,reengagement,feedback,seasonal,content,services.subjectstring | nullSuggested subject, up to 998 characters.preheaderstring | nullPreview text, up to 500 characters.textstring | nullText version, up to 2 MB.editorobject | nullThe dashboard's visual editor document. It needsversion(1or2) andblocks(a list of up to 300 blocks, each with atype);styleis optional. You can ignore it if you only use the HTML.
Errors
- 422
validation_errorA field is invalid. The message says which one. - 403
paused_by_planThe layout is paused because it is above the plan's layout limit. - 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl -X PATCH https://api.postal.so/layouts/019a1a01-3c4d-7e5f-9a6b-7c8d9e0f1a2b \
-H "Authorization: Bearer $POSTAL_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"description": "Order shipped notice, with the track button."
}'
Node.js
const response = await fetch('https://api.postal.so/layouts/019a1a01-3c4d-7e5f-9a6b-7c8d9e0f1a2b', {
method: 'PATCH',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
description: 'Order shipped notice, with the track button.',
}),
});
const data = await response.json();
Python
import os
import requests
response = requests.patch(
"https://api.postal.so/layouts/019a1a01-3c4d-7e5f-9a6b-7c8d9e0f1a2b",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
json={
"description": "Order shipped notice, with the track button.",
},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->patch('layouts/019a1a01-3c4d-7e5f-9a6b-7c8d9e0f1a2b', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
'json' => [
'description' => 'Order shipped notice, with the track button.',
],
]);
$data = json_decode($response->getBody(), true);
{
"object": "layout",
"id": "019a1a01-3c4d-7e5f-9a6b-7c8d9e0f1a2b",
"paused_by_plan": false,
"name": "Order shipped",
"description": "Order shipped notice, with the track button.",
"category": "transactional",
"subject": "Your order #{{{ORDER}}} is out for delivery",
"preheader": "Follow the delivery.",
"editor": null,
"html": "<p>Hi, {{{NAME}}}! Order <b>#{{{ORDER}}}</b> is on its way.</p>",
"text": null,
"created_by": "Rachel Lane",
"created_at": "2026-10-08T11:30:00.000000Z",
"updated_at": "2026-10-24T14:10:00.000000Z"
}
#Delete a layout
DELETE/layouts/{id}
Templates and broadcasts that started from the layout do not change.
Path parameters
idstringrequiredLayout ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl -X DELETE https://api.postal.so/layouts/019a1a01-3c4d-7e5f-9a6b-7c8d9e0f1a2b \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/layouts/019a1a01-3c4d-7e5f-9a6b-7c8d9e0f1a2b', {
method: 'DELETE',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.delete(
"https://api.postal.so/layouts/019a1a01-3c4d-7e5f-9a6b-7c8d9e0f1a2b",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->delete('layouts/019a1a01-3c4d-7e5f-9a6b-7c8d9e0f1a2b', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "layout",
"id": "019a1a01-3c4d-7e5f-9a6b-7c8d9e0f1a2b",
"deleted": true
}
#Duplicate a layout
POST/layouts/{id}/duplicate
Creates a copy of the layout named "… (copy)" (or "… (cópia)" with Accept-Language: pt-BR).
Path parameters
idstringrequiredLayout ID.
Errors
- 403
plan_limit_reachedThe plan does not allow more layouts. - 404
not_foundThe resource does not exist or belongs to another team.
cURL
curl -X POST https://api.postal.so/layouts/019a1a01-3c4d-7e5f-9a6b-7c8d9e0f1a2b/duplicate \
-H "Authorization: Bearer $POSTAL_API_KEY"
Node.js
const response = await fetch('https://api.postal.so/layouts/019a1a01-3c4d-7e5f-9a6b-7c8d9e0f1a2b/duplicate', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
},
});
const data = await response.json();
Python
import os
import requests
response = requests.post(
"https://api.postal.so/layouts/019a1a01-3c4d-7e5f-9a6b-7c8d9e0f1a2b/duplicate",
headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
timeout=10,
)
data = response.json()
PHP
$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);
$response = $client->post('layouts/019a1a01-3c4d-7e5f-9a6b-7c8d9e0f1a2b/duplicate', [
'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);
$data = json_decode($response->getBody(), true);
{
"object": "layout",
"id": "019a1a02-4d5e-7f60-8a7b-8c9d0e1f2a3b",
"paused_by_plan": false,
"name": "Order shipped (copy)",
"description": "Notice that the order is out for delivery.",
"category": "transactional",
"subject": "Your order #{{{ORDER}}} is out for delivery",
"preheader": "Follow the delivery.",
"editor": null,
"html": "<p>Hi, {{{NAME}}}! Order <b>#{{{ORDER}}}</b> is on its way.</p>",
"text": null,
"created_by": "Rachel Lane",
"created_at": "2026-10-08T11:30:00.000000Z",
"updated_at": "2026-10-08T11:30:00.000000Z"
}
#Plan limits
The plan defines how many templates and layouts the team can have. Going over the limit when creating or duplicating a template, or creating or duplicating a layout, returns 403 plan_limit_reached. The values of each plan are in Limits and in GET /plans.
If the plan shrinks and the team is left with more templates or layouts than the new limit, Postal deletes nothing: the oldest ones, up to the limit, stay active and the newest ones are paused (paused_by_plan: true). A paused item stays in the list and can be read, but changing it, publishing it, restoring a version or sending a test returns 403 paused_by_plan, and so does a send with template. It returns to normal when the plan allows it again or when you delete other items. See also Plan limits in Emails.