Sign in Create account
API reference

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

Full-access key

Query parameters

  • limitinteger
    From 1 to 100. Default: 20.
  • afterstring
    Cursor for the next page.
  • beforestring
    Cursor for the previous page.
  • searchstring
    Searches the name and the alias.
  • statusstring
    published or draft (never published).
GET /templates

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);
200Response
{
  "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

Full-access key

Creates the template as a draft. Publish it with POST /templates/{id}/publish before using it in sends.

Request body

  • namestringrequired
    Internal name, up to 255 characters. Required when creating.
  • htmlstringrequired
    Email HTML, up to 2 MB, with variables in the {{{NAME}}} format. Required when creating.
  • aliasstring | null
    A unique alias to use in place of the ID, such as order-shipped: letters, numbers, ., - and _, up to 100 characters. It must start with a letter or number and cannot look like an ID.
  • fromstring | null
    Default sender.
  • subjectstring | null
    Default subject; it accepts variables.
  • reply_tostring | string[] | null
    Default reply addresses.
  • textstring | null
    Text version. Without it, Postal generates the text from the HTML when sending.
  • variablesobject[] | null
    Up to 50 declared variables.
    • keystringrequired
      Variable name: letters, numbers and _, up to 50 characters.
    • typestring
      string (default) or number.
    • fallback_valuestring | number | null
      Value used when the send does not provide the variable. Without it, the variable is required.
  • editorobject | null
    State of the dashboard's visual editor. You can ignore it.

Errors

  • 422validation_error
    Invalid field, alias already in use or duplicate variable.
  • 403plan_limit_reached
    The plan does not allow more templates.
POST /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);
201Response
{
  "id": "0199f3a0-1b2c-7d3e-9f4a-5b6c7d8e9f0a",
  "object": "template"
}

#Retrieve a template

GET/templates/{id}

Full-access key

Returns the template with the content of the draft. has_unpublished_versions tells whether the draft differs from the published version.

Path parameters

  • idstringrequired
    ID or alias.

Errors

  • 404not_found
    The resource does not exist or belongs to another team.
GET /templates/{id}

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);
200Response
{
  "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}

Full-access key

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

  • idstringrequired
    ID or alias.

Request body (all optional)

  • namestring
    Internal name, up to 255 characters. Required when creating.
  • htmlstring
    Email HTML, up to 2 MB, with variables in the {{{NAME}}} format. Required when creating.
  • aliasstring | null
    A unique alias to use in place of the ID, such as order-shipped: letters, numbers, ., - and _, up to 100 characters. It must start with a letter or number and cannot look like an ID.
  • fromstring | null
    Default sender.
  • subjectstring | null
    Default subject; it accepts variables.
  • reply_tostring | string[] | null
    Default reply addresses.
  • textstring | null
    Text version. Without it, Postal generates the text from the HTML when sending.
  • variablesobject[] | null
    Up to 50 declared variables.
    • keystringrequired
      Variable name: letters, numbers and _, up to 50 characters.
    • typestring
      string (default) or number.
    • fallback_valuestring | number | null
      Value used when the send does not provide the variable. Without it, the variable is required.
  • editorobject | null
    State of the dashboard's visual editor. You can ignore it.

Errors

  • 422validation_error
    A field is invalid. The message says which one.
  • 403paused_by_plan
    The template is paused because it is above the plan's template limit.
  • 404not_found
    The resource does not exist or belongs to another team.
PATCH /templates/{id}

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);
200Response
{
  "id": "0199f3a0-1b2c-7d3e-9f4a-5b6c7d8e9f0a",
  "object": "template"
}

#Delete a template

DELETE/templates/{id}

Full-access key

Sends that use the template start to receive 404.

Path parameters

  • idstringrequired
    ID or alias.

Errors

  • 404not_found
    The resource does not exist or belongs to another team.
DELETE /templates/{id}

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);
200Response
{
  "object": "template",
  "id": "0199f3a0-1b2c-7d3e-9f4a-5b6c7d8e9f0a",
  "deleted": true
}

#Publish a template

POST/templates/{id}/publish

Full-access key

Creates a new version from the current draft. From then on, sends with this template use that version.

Path parameters

  • idstringrequired
    ID or alias.

Errors

  • 403paused_by_plan
    The template is paused because it is above the plan's template limit.
  • 404not_found
    The resource does not exist or belongs to another team.
POST /templates/{id}/publish

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);
200Response
{
  "id": "0199f3a0-1b2c-7d3e-9f4a-5b6c7d8e9f0a",
  "object": "template"
}

#Duplicate a template

POST/templates/{id}/duplicate

Full-access key

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

  • idstringrequired
    ID or alias.

Errors

  • 403plan_limit_reached
    The plan does not allow more templates.
  • 404not_found
    The resource does not exist or belongs to another team.
POST /templates/{id}/duplicate

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);
201Response
{
  "id": "0199f3a2-4e5f-7a6b-8c7d-9e0f1a2b3c4d",
  "object": "template"
}

#Send a test

POST/templates/{id}/test

Full-access key

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

  • idstringrequired
    ID or alias.

Request body

  • tostring[]required
    From 1 to 5 addresses.
  • fromstring
    Sender; default: the template's from.
  • variablesobject
    Variable values for the test.

Errors

  • 422validation_error
    Invalid address, missing sender or a required variable without a value.
  • 403validation_error
    The sender's domain is not verified.
  • 403sending_paused
    Sending is paused for your team.
  • 403paused_by_plan
    The template (or the sender's domain) is paused by the plan.
  • 429daily_quota_exceeded
    The plan's sending quota is used up (also monthly_quota_exceeded).
  • 404not_found
    The resource does not exist or belongs to another team.
POST /templates/{id}/test

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);
200Response
{
  "id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
  "object": "email"
}

#List versions

GET/templates/{id}/versions

Full-access key

The published versions, newest first (up to 100). current marks the one in use.

Path parameters

  • idstringrequired
    ID or alias.

Errors

  • 404not_found
    The resource does not exist or belongs to another team.
GET /templates/{id}/versions

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);
200Response
{
  "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

Full-access key

Copies a published version back to the draft. To put it in use, publish right after.

Path parameters

  • idstringrequired
    ID or alias.
  • version_idstringrequired
    Version ID.

Errors

  • 403paused_by_plan
    The template is paused because it is above the plan's template limit.
  • 404not_found
    The resource does not exist or belongs to another team.
POST /templates/{id}/versions/{version_id}/restore

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);
200Response
{
  "id": "0199f3a0-1b2c-7d3e-9f4a-5b6c7d8e9f0a",
  "object": "template"
}

#Upload an image

POST/assets

Full-access key

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)

  • filefilerequired
    PNG, JPG, GIF or WebP, up to 5 MB.

Errors

  • 422validation_error
    Missing file, a different file type or larger than 5 MB.
POST /assets

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);
201Response
{
  "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

Full-access key

Lists the team's layouts, newest first. Each item includes the HTML, because the dashboard draws each layout's card from it.

Query parameters

  • limitinteger
    From 1 to 100. Default: 20.
  • afterstring
    Cursor for the next page.
  • beforestring
    Cursor for the previous page.
  • searchstring
    Searches the name and the description.
  • categorystring
    Only layouts of this category.

Errors

  • 422validation_error
    A field is invalid. The message says which one.
GET /layouts

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);
200Response
{
  "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

Full-access key

Request body

  • namestringrequired
    Layout name, up to 255 characters. Required when creating.
  • htmlstringrequired
    Email HTML, up to 2 MB. Required when creating.
  • descriptionstring | null
    Description, up to 500 characters.
  • categorystring | null
    One of: welcome, newsletter, promotion, product, ecommerce, transactional, announcement, event, onboarding, reengagement, feedback, seasonal, content, services.
  • subjectstring | null
    Suggested subject, up to 998 characters.
  • preheaderstring | null
    Preview text, up to 500 characters.
  • textstring | null
    Text version, up to 2 MB.
  • editorobject | null
    The dashboard's visual editor document. It needs version (1 or 2) and blocks (a list of up to 300 blocks, each with a type); style is optional. You can ignore it if you only use the HTML.

Errors

  • 422validation_error
    A 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.
  • 403plan_limit_reached
    The plan does not allow more layouts.
POST /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);
201Response
{
  "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}

Full-access key

Path parameters

  • idstringrequired
    Layout ID.

Errors

  • 404not_found
    The resource does not exist or belongs to another team.
GET /layouts/{id}

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);
200Response
{
  "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}

Full-access key

All fields are optional; the ones you do not send stay as they are.

Path parameters

  • idstringrequired
    Layout ID.

Request body (all optional)

  • namestring
    Layout name, up to 255 characters. Required when creating.
  • htmlstring
    Email HTML, up to 2 MB. Required when creating.
  • descriptionstring | null
    Description, up to 500 characters.
  • categorystring | null
    One of: welcome, newsletter, promotion, product, ecommerce, transactional, announcement, event, onboarding, reengagement, feedback, seasonal, content, services.
  • subjectstring | null
    Suggested subject, up to 998 characters.
  • preheaderstring | null
    Preview text, up to 500 characters.
  • textstring | null
    Text version, up to 2 MB.
  • editorobject | null
    The dashboard's visual editor document. It needs version (1 or 2) and blocks (a list of up to 300 blocks, each with a type); style is optional. You can ignore it if you only use the HTML.

Errors

  • 422validation_error
    A field is invalid. The message says which one.
  • 403paused_by_plan
    The layout is paused because it is above the plan's layout limit.
  • 404not_found
    The resource does not exist or belongs to another team.
PATCH /layouts/{id}

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);
200Response
{
  "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}

Full-access key

Templates and broadcasts that started from the layout do not change.

Path parameters

  • idstringrequired
    Layout ID.

Errors

  • 404not_found
    The resource does not exist or belongs to another team.
DELETE /layouts/{id}

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);
200Response
{
  "object": "layout",
  "id": "019a1a01-3c4d-7e5f-9a6b-7c8d9e0f1a2b",
  "deleted": true
}

#Duplicate a layout

POST/layouts/{id}/duplicate

Full-access key

Creates a copy of the layout named "… (copy)" (or "… (cópia)" with Accept-Language: pt-BR).

Path parameters

  • idstringrequired
    Layout ID.

Errors

  • 403plan_limit_reached
    The plan does not allow more layouts.
  • 404not_found
    The resource does not exist or belongs to another team.
POST /layouts/{id}/duplicate

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);
201Response
{
  "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.