Sign in Create account
API reference

Emails

Send, list, schedule and cancel emails, with attachments, tags, headers and templates.

Send emails, follow the status of each message, schedule and cancel them, and read attachments. Emails sent through the API and through SMTP show up in the same endpoints.

Whoever receives an email sent here (in to and cc) is kept in the audience as an unconfirmed contact, who gets no broadcasts or automations until you confirm. You can turn this off under Settings › Team.

#Send an email

POST/emails

Full-access or sending-only keySupports Idempotency-Key

Queues an email for sending and returns its ID. The email goes out within seconds; follow the result with GET /emails/{id} or with webhooks. Before sending, Postal removes the recipients that are on the suppression list. To test without actually sending, use the test addresses.

Headers

  • Idempotency-Keystring
    A key of 1 to 256 characters that lets you repeat the request without sending twice. See Idempotency.

Request body

  • fromstringrequired
    Sender, as [email protected] or Name <[email protected]>. The domain must be verified and have sending enabled. Up to 998 characters.
  • tostring | string[]required
    Recipients. A string or a list of up to 50 addresses. You can leave it out when the recipient is a contact: see user_id, external_id and contact_id.
  • user_idstring
    An alternative to to: the ID of the person in your system, the external_id of a contact (user_id is an alias). The email goes to that contact's address. Ignored when to is sent.
  • external_idstring
    The same as user_id. If both are sent, user_id wins.
  • contact_idstring
    An alternative to to: the contact's ID in Postal (or its email address). It applies after user_id and external_id.
  • subjectstringrequired
    Subject, up to 998 characters.
  • htmlstring
    HTML body. Required when there is no text.
  • textstring
    Plain-text version. Recommended even with html: it improves deliverability.
  • ccstring | string[]
    Carbon copy.
  • bccstring | string[]
    Blind carbon copy.
  • reply_tostring | string[]
    Reply addresses (Reply-To).
  • headersobject
    Extra headers, such as {"List-Unsubscribe": "<https://…>"}. Values up to 998 characters with no line breaks. You cannot override From, To, Cc, Bcc, Subject, Reply-To, Sender, Date, Message-ID, Return-Path, MIME-Version, Content-Type, Content-Transfer-Encoding or DKIM-Signature. To send a one-time code that the email client can autofill, use OTP-Token: see the OTP-Token header. When the request has an OTP-Token, the API checks its format and returns 422 if the value is invalid or if the header appears more than once, in any letter case (what Postal validates).
  • tagsobject[]
    Up to 50 tags to filter and identify the email in webhooks.
    • namestringrequired
      ASCII letters, numbers, _ or -, up to 256 characters. Cannot start with postal_.
    • valuestring
      Same rules as the name. Optional.
  • attachmentsobject[]
    Attachments, up to 40 MB in total. Not supported in batch sends. Your plan may allow less than that (or no attachments): above the plan limit, the response is 403 plan_limit_reached. A path that Postal cannot download when it sends the email leaves the email with the status failed.
    • contentstring
      File content in Base64. Required without path.
    • pathstring
      Public http(s) URL that Postal downloads the file from when it sends the email. Required without content.
    • filenamestring
      File name (up to 255 characters). With path, it defaults to the name at the end of the URL.
    • content_typestring
      MIME type, such as application/pdf. Without it, the type is inferred from the extension.
    • content_idstring
      For inline images: use <img src="cid:logo"> in the HTML and "content_id": "logo" here.
  • scheduled_atstring
    Date and time to send, in ISO 8601 (for example 2026-10-25T09:00:00-03:00). Must be in the future and at most 30 days away. Not supported in batch sends. It depends on the plan's scheduling feature (403 plan_limit_reached without it).
  • templateobject
    Sends with a published template instead of html/text. See Sending with a template.
    • idstringrequired
      Template ID or alias.
    • variablesobject
      Variable values, such as {"NAME": "Jane"}.

Sending with a template

With template, Postal uses the latest published version: the template's from, subject and reply_to apply when the body does not set them, and the {{{NAME}}} variables are filled in from variables (in the HTML, the values are escaped). A variable with no value and no fallback value returns 422, and so does a value that is not a string or a number or, for a variable of the number type, not numeric. Values for variables the template does not declare are filled in too. Do not send html or text together with template. A template that is paused by the plan returns 403 paused_by_plan.

Body with a template
{
  "from": "Acme Store <[email protected]>",
  "to": "[email protected]",
  "template": {
    "id": "order-shipped",
    "variables": {
      "NAME": "Jane",
      "ORDER": "10482"
    }
  }
}

Attachments and inline images

Send the file in Base64 in content or a URL in path. For an image inside the HTML, use the same content_id in the src="cid:…" attribute.

Body with attachments
{
  "from": "Billing <[email protected]>",
  "to": ["[email protected]"],
  "subject": "Your invoice",
  "html": "<img src=\"cid:logo\" alt=\"Acme Store\"><p>Here is the invoice for order #10482.</p>",
  "attachments": [
    {
      "filename": "invoice-10482.pdf",
      "content": "JVBERi0xLjcKJcfsj6IKNSAwIG9iago8PC9MZW5ndGggNiAwIFI+PgpzdHJlYW0K…"
    },
    {
      "path": "https://cdn.acmestore.com/logo.png",
      "content_id": "logo"
    }
  ]
}

Sending to a contact

If you keep people as contacts, you do not need to repeat the address: send the user_id (the contact's external_id) instead of to and Postal uses the contact's email. It works in a batch too. A contact that does not exist returns 404. The suppression list still applies: if the address is on it, the email does not go out.

Body with a contact
{
  "from": "Acme Store <[email protected]>",
  "user_id": "user_42",
  "template": {
    "id": "order-shipped",
    "variables": {
      "NAME": "Jane",
      "ORDER": "10482"
    }
  }
}

Scheduling

With scheduled_at, the email has the status scheduled until the set time. You can reschedule or cancel it until it goes out. The quota is counted when the email is created, and an email.scheduled event is added to the history.

Errors

  • 422validation_error
    A field is invalid. The message says which one.
  • 403validation_error
    The domain in from is not verified or does not belong to your team.
  • 403restricted_api_key
    The key can only send from another domain.
  • 403sending_paused
    Sending is paused for your team because of a high bounce or complaint rate.
  • 403paused_by_plan
    The domain in from, the template or the key itself is paused because it is above the plan limit. See Plan limits.
  • 403plan_limit_reached
    The plan does not include what the send asks for: scheduled_at, test addresses, or attachments (or the total goes over the plan limit).
  • 404not_found
    The template does not exist or has not been published.
  • 404not_found
    No contact has the user_id, external_id or contact_id you sent.
  • 400invalid_idempotency_key
    The Idempotency-Key is empty or longer than 256 characters.
  • 409concurrent_idempotent_requests
    Another request with the same Idempotency-Key is still in progress.
  • 409invalid_idempotent_request
    The same Idempotency-Key was used with a different body.
  • 429daily_quota_exceeded
    The daily quota is used up: the plan's daily limit or the account reputation ceiling, whichever is lower. It resets at 00:00 UTC. When the reputation is what limits, the message gives the level and the ceiling.
  • 429monthly_quota_exceeded
    The plan's monthly quota is used up, and so is the Sends add-on reserve, if the team has one. The message says what to do, including where to buy the add-on when the plan offers it.
POST /emails

cURL

curl -X POST https://api.postal.so/emails \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Idempotency-Key: order-10482-shipped" \
  -H "Content-Type: application/json" \
  -d '{
    "from": "Acme Store <[email protected]>",
    "to": ["[email protected]"],
    "subject": "Your order #10482 is out for delivery",
    "html": "<p>Order <b>#10482</b> is on its way.</p>",
    "text": "Order #10482 is on its way.",
    "tags": [
      {
        "name": "category",
        "value": "shipping"
      }
    ]
  }'

Node.js

const response = await fetch('https://api.postal.so/emails', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
    'Content-Type': 'application/json',
    'Idempotency-Key': 'order-10482-shipped',
  },
  body: JSON.stringify({
    from: 'Acme Store <[email protected]>',
    to: ['[email protected]'],
    subject: 'Your order #10482 is out for delivery',
    html: '<p>Order <b>#10482</b> is on its way.</p>',
    text: 'Order #10482 is on its way.',
    tags: [
      {
        name: 'category',
        value: 'shipping',
      },
    ],
  }),
});

const data = await response.json();

Python

import os
import requests

response = requests.post(
    "https://api.postal.so/emails",
    headers={
        "Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}",
        "Idempotency-Key": "order-10482-shipped",
    },
    json={
        "from": "Acme Store <[email protected]>",
        "to": ["[email protected]"],
        "subject": "Your order #10482 is out for delivery",
        "html": "<p>Order <b>#10482</b> is on its way.</p>",
        "text": "Order #10482 is on its way.",
        "tags": [
            {
                "name": "category",
                "value": "shipping",
            },
        ],
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->post('emails', [
    'headers' => [
        'Authorization' => 'Bearer '.getenv('POSTAL_API_KEY'),
        'Idempotency-Key' => 'order-10482-shipped',
    ],
    'json' => [
        'from' => 'Acme Store <[email protected]>',
        'to' => ['[email protected]'],
        'subject' => 'Your order #10482 is out for delivery',
        'html' => '<p>Order <b>#10482</b> is on its way.</p>',
        'text' => 'Order #10482 is on its way.',
        'tags' => [
            [
                'name' => 'category',
                'value' => 'shipping',
            ],
        ],
    ],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b"
}

#Send a batch

POST/emails/batch

Full-access or sending-only keySupports Idempotency-Key

Sends up to 100 emails in one request. The body is a list of objects with the same fields as POST /emails, except attachments and scheduled_at. Everything is validated first: either all the emails are queued or none are. Validation errors give the position, like [3] Missing `subject` field., and the quota is checked for the whole batch (items sent only to test addresses do not count). Batch sending is a plan feature.

Request body (list)

  • [ ]object[]required
    From 1 to 100 emails, each with from, to, subject, html or text and, optionally, cc, bcc, reply_to, headers, tags and template. Instead of to, user_id, external_id or contact_id work, as in POST /emails.

Errors

  • 422validation_error
    The body is not a list, has more than 100 items, or an item is invalid (the message gives its position).
  • 403validation_error
    A from uses a domain that is not verified.
  • 403plan_limit_reached
    The plan does not include batch sending (or test addresses, if an item uses them).
  • 403sending_paused
    Sending is paused for your team because of a high bounce or complaint rate.
  • 403paused_by_plan
    A from uses a domain paused by the plan, or an item uses a paused template.
  • 404not_found
    An item uses a template that does not exist or is not published, or a contact that does not exist (the message gives the position).
  • 429daily_quota_exceeded
    The batch would go over the daily or monthly quota.
POST /emails/batch

cURL

curl -X POST https://api.postal.so/emails/batch \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '[
    {
      "from": "Acme Store <[email protected]>",
      "to": ["[email protected]"],
      "subject": "Your coupon is here",
      "html": "<p>Hi, Jane! Use the coupon <b>WELCOME</b>.</p>"
    },
    {
      "from": "Acme Store <[email protected]>",
      "to": ["[email protected]"],
      "subject": "Your coupon is here",
      "html": "<p>Hi, Mark! Use the coupon <b>WELCOME</b>.</p>"
    }
  ]'

Node.js

const response = await fetch('https://api.postal.so/emails/batch', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify([
    {
      from: 'Acme Store <[email protected]>',
      to: ['[email protected]'],
      subject: 'Your coupon is here',
      html: '<p>Hi, Jane! Use the coupon <b>WELCOME</b>.</p>',
    },
    {
      from: 'Acme Store <[email protected]>',
      to: ['[email protected]'],
      subject: 'Your coupon is here',
      html: '<p>Hi, Mark! Use the coupon <b>WELCOME</b>.</p>',
    },
  ]),
});

const data = await response.json();

Python

import os
import requests

response = requests.post(
    "https://api.postal.so/emails/batch",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    json=[
        {
            "from": "Acme Store <[email protected]>",
            "to": ["[email protected]"],
            "subject": "Your coupon is here",
            "html": "<p>Hi, Jane! Use the coupon <b>WELCOME</b>.</p>",
        },
        {
            "from": "Acme Store <[email protected]>",
            "to": ["[email protected]"],
            "subject": "Your coupon is here",
            "html": "<p>Hi, Mark! Use the coupon <b>WELCOME</b>.</p>",
        },
    ],
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->post('emails/batch', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'json' => [
        [
            'from' => 'Acme Store <[email protected]>',
            'to' => ['[email protected]'],
            'subject' => 'Your coupon is here',
            'html' => '<p>Hi, Jane! Use the coupon <b>WELCOME</b>.</p>',
        ],
        [
            'from' => 'Acme Store <[email protected]>',
            'to' => ['[email protected]'],
            'subject' => 'Your coupon is here',
            'html' => '<p>Hi, Mark! Use the coupon <b>WELCOME</b>.</p>',
        ],
    ],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "data": [
    {
      "id": "019a1b2c-51aa-7c21-9f02-6b7c8d9e0f1a"
    },
    {
      "id": "019a1b2c-51ab-7d30-8e11-2a3b4c5d6e7f"
    }
  ]
}

#List emails

GET/emails

Full-access key

Lists the emails you sent, newest first, with cursor pagination. The list does not include events; use GET /emails/{id} to see the history.

Query parameters

  • limitinteger
    Items per page, from 1 to 100. Default: 20.
  • afterstring
    ID of the last item you received, for the next page.
  • beforestring
    ID of the first item you received, for the previous page.
  • searchstring
    Searches sender, subject and recipients. A full ID finds that exact email. Recipients include cc and bcc; the search is up to 200 characters.
  • statusstring
    One or more statuses separated by commas, such as bounced,complained. See the statuses. A value that is not a status returns 422.
  • domain_idstring
    Only emails from one domain.
  • api_key_idstring
    Only emails sent with one key.
  • start_datestring
    Created on or after this date (ISO 8601). A date without a time, such as 2026-10-01, starts at midnight UTC.
  • end_datestring
    Created up to this date (ISO 8601). A date without a time, such as 2026-10-24, covers that whole day (UTC). If start_date is after end_date, the response is 422.

Errors

  • 422validation_error
    Invalid filter or cursor, or after and before used together. Also when start_date is after end_date.
GET /emails

cURL

curl 'https://api.postal.so/emails?limit=2&status=delivered,opened' \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/emails?limit=2&status=delivered,opened', {
  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/emails",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    params={
        "limit": 2,
        "status": "delivered,opened",
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->get('emails', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'query' => [
        'limit' => 2,
        'status' => 'delivered,opened',
    ],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "list",
  "has_more": true,
  "data": [
    {
      "object": "email",
      "id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
      "message_id": "<0103019a1b2c4d5e-6f7a8b9c-1d2e-4f3a-9b8c-7d6e5f4a3b2c-000000@sa-east-1.amazonses.com>",
      "to": ["[email protected]"],
      "from": "Acme Store <[email protected]>",
      "created_at": "2026-10-24T13:02:11.000000Z",
      "subject": "Your order #10482 is out for delivery",
      "html": "<p>Order <b>#10482</b> is on its way.</p>",
      "text": "Order #10482 is on its way.",
      "bcc": [],
      "cc": [],
      "reply_to": ["[email protected]"],
      "last_event": "delivered",
      "scheduled_at": null,
      "simulated": false,
      "tags": [
        {
          "name": "category",
          "value": "shipping"
        }
      ]
    }
  ]
}

#Retrieve an email

GET/emails/{id}

Full-access or sending-only key

Returns the email with its content, its current status (last_event) and the list of events. For emails sent to the test addresses, simulated is true. It also says where the email came from: source is api or smtp, and log_id points to the request log. The fields are in The email object and the event types in Email events.

Which keys can call it: a full-access key (or the dashboard) reads any email of the team. A sending_access key reads only the emails it sent itself, through the API or SMTP, so it can follow delivery status without webhooks. Any other email of the team (from another key, from the dashboard, from broadcasts or from automations) returns 404 not_found, the same as an email that does not exist. The other /emails routes still require full access.

Path parameters

  • idstringrequired
    Email ID.

Errors

  • 404not_found
    The email does not exist, belongs to another team or, with a sending_access key, was not sent by that key.
GET /emails/{id}

cURL

curl https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b', {
  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/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
    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('emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "email",
  "id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
  "message_id": "<0103019a1b2c4d5e-6f7a8b9c-1d2e-4f3a-9b8c-7d6e5f4a3b2c-000000@sa-east-1.amazonses.com>",
  "to": ["[email protected]"],
  "from": "Acme Store <[email protected]>",
  "created_at": "2026-10-24T13:02:11.000000Z",
  "subject": "Your order #10482 is out for delivery",
  "html": "<p>Order <b>#10482</b> is on its way.</p>",
  "text": "Order #10482 is on its way.",
  "bcc": [],
  "cc": [],
  "reply_to": ["[email protected]"],
  "last_event": "delivered",
  "scheduled_at": null,
  "simulated": false,
  "tags": [
    {
      "name": "category",
      "value": "shipping"
    }
  ],
  "events": [
    {
      "type": "email.sent",
      "occurred_at": "2026-10-24T13:02:12.000000Z",
      "data": null
    },
    {
      "type": "email.delivered",
      "occurred_at": "2026-10-24T13:02:14.000000Z",
      "data": {
        "recipients": ["[email protected]"]
      }
    }
  ],
  "domain_id": "0199f0e1-2a3b-7c4d-8e5f-6a7b8c9d0e1f",
  "api_key_id": "0199f0e2-7b8c-7d9e-8f0a-1b2c3d4e5f6a",
  "template_id": null,
  "source": "api",
  "log_id": "019a1b2c-4d60-7a11-b2c3-d4e5f6a7b8c9"
}

#Reschedule an email

PATCH/emails/{id}

Full-access key

Changes the send date of a scheduled email. It only works while the status is scheduled.

Path parameters

  • idstringrequired
    Email ID.

Request body

  • scheduled_atstringrequired
    New date in ISO 8601, in the future and at most 30 days away.

Errors

  • 422validation_error
    The email is not scheduled or the date is invalid.
  • 404not_found
    The resource does not exist or belongs to another team.
PATCH /emails/{id}

cURL

curl -X PATCH https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "scheduled_at": "2026-10-26T09:00:00-03:00"
  }'

Node.js

const response = await fetch('https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b', {
  method: 'PATCH',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    scheduled_at: '2026-10-26T09:00:00-03:00',
  }),
});

const data = await response.json();

Python

import os
import requests

response = requests.patch(
    "https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    json={
        "scheduled_at": "2026-10-26T09:00:00-03:00",
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->patch('emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'json' => [
        'scheduled_at' => '2026-10-26T09:00:00-03:00',
    ],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "email",
  "id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b"
}

#Cancel a scheduled email

POST/emails/{id}/cancel

Full-access key

Cancels a scheduled email. The status becomes canceled and an email.canceled event is added to the history.

Path parameters

  • idstringrequired
    Email ID.

Errors

  • 422validation_error
    The email is not scheduled (it was already sent or already canceled).
  • 404not_found
    The resource does not exist or belongs to another team.
POST /emails/{id}/cancel

cURL

curl -X POST https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/cancel \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/cancel', {
  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/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/cancel",
    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('emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/cancel', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "email",
  "id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b"
}

#Raw message

GET/emails/{id}/raw

Full-access key

Returns the message as it was sent, in RFC 5322 format (text/plain): the original for emails sent over SMTP, or a reconstruction for emails sent through the API.

Path parameters

  • idstringrequired
    Email ID.

Errors

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

cURL

curl https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/raw \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/raw', {
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
  },
});

const message = await response.text();

Python

import os
import requests

response = requests.get(
    "https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/raw",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    timeout=10,
)

message = response.text

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->get('emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/raw', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$message = (string) $response->getBody();
200Response (text/plain)
From: Acme Store <[email protected]>
To: [email protected]
Subject: Your order #10482 is out for delivery
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="b1_Xk2p"

--b1_Xk2p
Content-Type: text/plain; charset=utf-8

Order #10482 is on its way.
--b1_Xk2p--

#Deliverability insights

GET/emails/{id}/insights

Full-access key

Runs best-practice checks on the email and says whether each one passed. The codes are: has_text_version, no_link_shorteners, https_links, no_ip_links, images_have_alt, html_under_gmail_limit (HTML up to 102 KB), enough_text, reply_friendly_sender, subject_not_all_caps, unsubscribe_option and dmarc_configured (only when DNS responds). AI explains how to fix the ones that failed.

Path parameters

  • idstringrequired
    Email ID.

Errors

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

cURL

curl https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/insights \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/insights', {
  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/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/insights",
    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('emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/insights', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "list",
  "data": [
    {
      "code": "has_text_version",
      "passed": true,
      "data": {}
    },
    {
      "code": "https_links",
      "passed": false,
      "data": {
        "count": 1
      }
    },
    {
      "code": "dmarc_configured",
      "passed": true,
      "data": {
        "domain": "acmestore.com"
      }
    }
  ]
}

#Create a view link

POST/emails/{id}/share

Full-access key

Creates a public, temporary link to view the email in a browser, useful for showing it to someone without dashboard access. The HTML opens isolated, with no scripts.

Path parameters

  • idstringrequired
    Email ID.

Request body

  • expires_instring
    Lifetime, from 1 minute to 48 hours: 10m, 2 hours, 1 day… Default: 48h.

Errors

  • 422validation_error
    expires_in is in the wrong format or out of range.
  • 403plan_limit_reached
    The plan does not include shared email links.
  • 404not_found
    The resource does not exist or belongs to another team.
POST /emails/{id}/share

cURL

curl -X POST https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/share \
  -H "Authorization: Bearer $POSTAL_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "expires_in": "2h"
  }'

Node.js

const response = await fetch('https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/share', {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    expires_in: '2h',
  }),
});

const data = await response.json();

Python

import os
import requests

response = requests.post(
    "https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/share",
    headers={"Authorization": f"Bearer {os.environ['POSTAL_API_KEY']}"},
    json={
        "expires_in": "2h",
    },
    timeout=10,
)

data = response.json()

PHP

$client = new \GuzzleHttp\Client(['base_uri' => 'https://api.postal.so']);

$response = $client->post('emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/share', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
    'json' => [
        'expires_in' => '2h',
    ],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "email",
  "id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
  "url": "https://api.postal.so/shared/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b?expires=1761321731&signature=4f1c…"
}

#List attachments

GET/emails/{id}/attachments

Full-access key

Lists the attachments of a sent email. Each one has a signed download_url, valid for 1 hour. For attachments sent by path, the link is the original URL and expires_at is null. It accepts limit, after and before, like the other lists.

Path parameters

  • idstringrequired
    Email ID.

Errors

  • 422validation_error
    limit out of range, or after and before used together.
  • 404not_found
    The resource does not exist or belongs to another team.
GET /emails/{id}/attachments

cURL

curl https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/attachments \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/attachments', {
  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/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/attachments",
    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('emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/attachments', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "list",
  "has_more": false,
  "data": [
    {
      "id": "019a1b2c-4d5f-7e70-9b8a-7c6d5e4f3a2b",
      "filename": "invoice-10482.pdf",
      "content_type": "application/pdf",
      "content_id": null,
      "content_disposition": "attachment",
      "download_url": "https://api.postal.so/attachments/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/019a1b2c-4d5f-7e70-9b8a-7c6d5e4f3a2b?expires=1761314531&signature=9a7e…",
      "expires_at": "2026-10-24T14:02:11.000Z",
      "size": 48213
    }
  ]
}

#Retrieve an attachment

GET/emails/{id}/attachments/{attachment_id}

Full-access key

Returns an attachment with its download link.

Path parameters

  • idstringrequired
    Email ID.
  • attachment_idstringrequired
    Attachment ID.

Errors

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

cURL

curl https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/attachments/019a1b2c-4d5f-7e70-9b8a-7c6d5e4f3a2b \
  -H "Authorization: Bearer $POSTAL_API_KEY"

Node.js

const response = await fetch('https://api.postal.so/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/attachments/019a1b2c-4d5f-7e70-9b8a-7c6d5e4f3a2b', {
  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/emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/attachments/019a1b2c-4d5f-7e70-9b8a-7c6d5e4f3a2b",
    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('emails/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/attachments/019a1b2c-4d5f-7e70-9b8a-7c6d5e4f3a2b', [
    'headers' => ['Authorization' => 'Bearer '.getenv('POSTAL_API_KEY')],
]);

$data = json_decode($response->getBody(), true);
200Response
{
  "object": "attachment",
  "id": "019a1b2c-4d5f-7e70-9b8a-7c6d5e4f3a2b",
  "filename": "invoice-10482.pdf",
  "content_type": "application/pdf",
  "content_id": null,
  "content_disposition": "attachment",
  "download_url": "https://api.postal.so/attachments/019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b/019a1b2c-4d5f-7e70-9b8a-7c6d5e4f3a2b?expires=1761314531&signature=9a7e…",
  "expires_at": "2026-10-24T14:02:11.000Z",
  "size": 48213
}

#Email status

The last_event field shows the furthest state the email has reached. Because events can arrive out of order, the status only moves forward: a clicked email does not go back to delivered.

StatusMeaning
queuedIn the queue, going out shortly.
scheduledScheduled for scheduled_at.
sentAccepted by the sending provider.
delivery_delayedThe recipient's server delayed delivery; retries happen automatically.
deliveredDelivered to the recipient's server.
openedOpened (with open tracking turned on for the domain).
clickedA link was clicked (with click tracking turned on).
bouncedBounced. Permanent bounces go to the suppression list.
complainedThe recipient marked it as spam. The address goes to the suppression list.
failedIt could not be sent (domain not verified, sending paused, attachment unreachable…).
suppressedAll or some of the recipients were on the suppression list.
canceledScheduling canceled.

#The email object

The list (GET /emails) and the retrieval of one email (GET /emails/{id}) return the same object. Retrieving one adds the history and the origin. Sending (POST /emails) returns only the id, and batch sending returns a list of id values. Postal keeps emails, attachments and history for 30 days (and scheduled emails until they go out); after that, retrieving one returns 404. See Limits.

FieldTypeDescription
object, idstringemail and the email ID.
message_idstring | nullThe email's Message-ID. It is null until the sending provider accepts the email (queued, scheduled or canceled emails).
from, to, cc, bcc, reply_tostring | string[]The addresses, as they were sent. cc, bcc and reply_to are empty lists when there are none.
subject, html, textstring | nullThe content that was sent. For a send with a template, it is the content with the variables already filled in.
created_atstringWhen the email was created (ISO 8601, UTC).
last_eventstringThe current status: queued, scheduled, sent, delivery_delayed, delivered, opened, clicked, bounced, failed, suppressed, canceled or complained.
scheduled_atstring | nullThe send date set with scheduled_at, or null for an immediate send.
simulatedbooleantrue for emails to the test addresses, which are never actually sent.
tagsobject[]The tags, as [{"name", "value"}].
eventsobject[]Only in GET /emails/{id}: the history, oldest first, as [{"type", "occurred_at", "data"}]. See Email events.
domain_id, api_key_id, template_idstring | nullOnly in GET /emails/{id}: the sender's domain, the API key used to send and the template used (null when there was no key or template).
source, log_idstringOnly in GET /emails/{id}: api or smtp, and the ID of the request log (null if there is none).

#Email events

The events field of GET /emails/{id} holds everything that happened to the email, in order. Each event has a type, occurred_at and data (or null). Almost all of them can also be subscribed to as webhook events (email.canceled and email.unsubscribed exist only in the history). Events that involve several recipients list the addresses they apply to in data.recipients.

TypeWhendata
email.scheduledThe email was created with scheduled_at.null
email.canceledThe scheduling was canceled.null
email.sentThe sending provider accepted the email.null
email.deliveredThe recipient's server received the email.recipients
email.delivery_delayedDelivery was delayed; retries happen automatically.delay_type, recipients
email.bouncedThe email bounced. Permanent bounces enter the suppression list.bounce (type, subType, message, diagnosticCode), recipients
email.complainedThe recipient marked it as spam. The address enters the suppression list.recipients, feedback_type
email.failedIt could not be sent (domain not verified, sending paused, attachment unreachable…).reason
email.suppressedAll or some of the recipients were on the suppression list and were dropped when sending. The others go on normally.recipients
email.openedThe recipient opened the email (open tracking turned on for the domain).open (ipAddress, userAgent, timestamp)
email.clickedA link was clicked (click tracking turned on).click (ipAddress, link, userAgent, timestamp)
email.repliedA reply arrived to the email of a broadcast or automation that detects replies.reply (from, subject, message_id, preview, attachments), auto
email.unsubscribedThe contact unsubscribed through the link of a broadcast email.null

last_event only moves forward, but the events list keeps every event, even the ones that arrive out of order. email.replied and email.unsubscribed do not change last_event.

#Test addresses

To test your integration and your webhooks end to end, without sending real email and without risking your domain's reputation, send to Postal's test addresses, on the sandbox.postal.so domain. It is the same idea as Resend's [email protected].

The email is accepted like any other: the same validation, a from on one of your team's verified domains, attachments, templates, tags, scheduled_at, Idempotency-Key, batch sends and SMTP. It shows up in GET /emails and in the dashboard, with its event history, but it is never actually sent: instead of the provider, Postal records the events a few seconds later, in the order of a real send, and the webhooks go out as usual.

AddressEvents, in orderFinal status
[email protected]email.sent, email.delivereddelivered
[email protected]email.sent, email.bounced (permanent bounce: type Permanent, subType General, message smtp; 550 5.1.1 user unknown)bounced
[email protected]email.sent, email.delivered, email.complainedcomplained
[email protected]email.suppressed (no email.sent or email.delivered)suppressed
[email protected]email.sent, email.delivery_delayeddelivery_delayed

Rules

  • Letter case does not matter, and you can add a label after +, up to 64 characters of letters, numbers, ., _ and -: [email protected]. Use the label to recognize each test in the events.
  • Do not mix. The recipients of an email (to, cc and bcc) must be all test addresses or all real ones; mixing them returns 422 validation_error. An address on the test domain with any other name, such as [email protected], also returns 422, listing the valid names.
  • Several test addresses in the same email are allowed: each outcome becomes its own event, with a recipients list of the addresses with that outcome. The final status follows the furthest event (complained outranks bounced and suppressed, which outrank delivered).
  • They do not count toward anything that affects your real sending: they do not use the sending quota (daily, monthly or overage), they do not enter the suppression list (a bounced@ or complained@ blocks nothing), they do not enter the bounce and complaint rates that can pause sending, they do not appear in the metrics or the weekly summary, and they do not trigger the first-email notice.
  • The rest still applies: the requests-per-second limit, API keys restricted to a domain and the team's sending pause (403 sending_paused).
  • Plan feature. Test addresses depend on the plan's test_addresses feature (see Plan limits); without it, the send returns 403 plan_limit_reached.
  • The email has "simulated": true in GET /emails/{id}. You do not need to set up DNS: nothing is delivered to that domain.
Body of POST /emails
{
  "from": "Acme Store <[email protected]>",
  "to": ["[email protected]"],
  "subject": "Your order #10482 is out for delivery",
  "html": "<p>Order <b>#10482</b> is on its way.</p>",
  "tags": [
    {
      "name": "category",
      "value": "shipping"
    }
  ]
}

A few seconds later, your webhook receives the same event as a real bounce:

email.bounced webhook received
{
  "type": "email.bounced",
  "created_at": "2026-10-24T13:02:15.004Z",
  "data": {
    "email_id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b",
    "created_at": "2026-10-24T13:02:11.000Z",
    "from": "Acme Store <[email protected]>",
    "to": ["[email protected]"],
    "subject": "Your order #10482 is out for delivery",
    "tags": {
      "category": "shipping"
    },
    "bounce": {
      "diagnosticCode": ["smtp; 550 5.1.1 user unknown"],
      "message": "smtp; 550 5.1.1 user unknown",
      "subType": "General",
      "type": "Permanent"
    }
  }
}

#Plan limits

Some sending features depend on the plan. When the plan does not include one of them, the API returns 403 plan_limit_reached and the message says which feature is missing. What each plan includes is in Limits and in GET /plans.

Plan featureKeyWhere it applies
Batch sendingbatch_sendingPOST /emails/batch
Scheduled sendingscheduled_sendingThe scheduled_at field of POST /emails.
Simulated test addressestest_addressessandbox.postal.so
Attachment sizeattachments_mbThe total of the attachments: 0 allows no attachments; a number caps the total in MB; with no number, the platform limit of 40 MB applies.
Shared email linksshared_email_linksPOST /emails/{id}/share
SMTP sendingsmtp_accessSMTP

The sending quotas are a different limit: when they run out, the response is 429 daily_quota_exceeded or 429 monthly_quota_exceeded (see Quotas). The daily limit is the lower of the plan's and the account reputation ceiling, which starts at 500 emails a day for new accounts. Before refusing with monthly_quota_exceeded, Postal uses the Sends add-on reserve, if the team has one; it does not lift the daily limit. Emails to test addresses do not use the quota.

Paused sending

When the team's bounce, complaint or rejected-content rates go over the limit (see Automatic pause), Postal pauses sending to protect the reputation of your domains. While it lasts, POST /emails, POST /emails/batch and sends to test addresses return 403 sending_paused. The sending.paused and sending.resumed webhooks tell you when it starts and ends (see Webhooks).

Items paused by the plan

If the plan changes and the team ends up with more domains, templates, API keys, inboxes, webhooks or other items than the new plan allows, Postal deletes nothing: the oldest ones, up to the limit, stay active and the newest ones are paused. A paused item stays in the list and can be read, and its paused_by_plan field is true. Changing or using it returns 403 paused_by_plan. For sending, this applies to a from on a paused domain, to a paused template and to the API key itself when it is the paused one: every request made with it is refused. Items become active again as soon as the plan allows them, or when you delete other items.

403Feature outside the plan
{
  "statusCode": 403,
  "name": "plan_limit_reached",
  "message": "Your plan does not include “Scheduled sending”. Upgrade your plan to use it."
}
403Item paused by the plan
{
  "statusCode": 403,
  "name": "paused_by_plan",
  "message": "This item is paused because it is above the limit of your current plan. Upgrade your plan or delete other items to use it again."
}

The messages follow Accept-Language (without it, they are in English); the name does not change.

#With the SDKs

The official SDKs have methods to send (emails.send and emails.batch), retrieve and list (emails.get, emails.list and emails.iterate), reschedule and cancel (emails.update and emails.cancel) and read the raw message (emails.raw). The fields have the same names as the API.

Schedule, retrieve and cancel

Node.js

import { Postal } from 'postal-so'

const postal = new Postal() // reads POSTAL_API_KEY

const { id } = await postal.emails.send({
  from: 'Acme Store <[email protected]>',
  to: '[email protected]',
  subject: 'Your order #10482 is out for delivery',
  html: '<p>Order <b>#10482</b> is on its way.</p>',
  scheduled_at: new Date(Date.now() + 3600_000),
})

const email = await postal.emails.get(id) // email.last_event and email.events
await postal.emails.update(id, { scheduled_at: '2026-10-26T09:00:00-03:00' })
await postal.emails.cancel(id)

PHP

use Postal\Client;

$postal = new Client(); // reads POSTAL_API_KEY

$result = $postal->emails->send([
    'from' => 'Acme Store <[email protected]>',
    'to' => '[email protected]',
    'subject' => 'Your order #10482 is out for delivery',
    'html' => '<p>Order <b>#10482</b> is on its way.</p>',
    'scheduled_at' => new DateTimeImmutable('+1 hour'),
]);

$email = $postal->emails->get($result['id']); // last_event and events
$postal->emails->update($result['id'], '2026-10-26T09:00:00-03:00');
$postal->emails->cancel($result['id']);