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
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-KeystringA key of 1 to 256 characters that lets you repeat the request without sending twice. See Idempotency.
Request body
fromstringrequiredSender, as[email protected]orName <[email protected]>. The domain must be verified and have sending enabled. Up to 998 characters.tostring | string[]requiredRecipients. A string or a list of up to 50 addresses. You can leave it out when the recipient is a contact: seeuser_id,external_idandcontact_id.user_idstringAn alternative toto: the ID of the person in your system, theexternal_idof a contact (user_idis an alias). The email goes to that contact's address. Ignored whentois sent.external_idstringThe same asuser_id. If both are sent,user_idwins.contact_idstringAn alternative toto: the contact's ID in Postal (or its email address). It applies afteruser_idandexternal_id.subjectstringrequiredSubject, up to 998 characters.htmlstringHTML body. Required when there is notext.textstringPlain-text version. Recommended even withhtml: it improves deliverability.ccstring | string[]Carbon copy.bccstring | string[]Blind carbon copy.reply_tostring | string[]Reply addresses (Reply-To).headersobjectExtra headers, such as{"List-Unsubscribe": "<https://…>"}. Values up to 998 characters with no line breaks. You cannot overrideFrom,To,Cc,Bcc,Subject,Reply-To,Sender,Date,Message-ID,Return-Path,MIME-Version,Content-Type,Content-Transfer-EncodingorDKIM-Signature. To send a one-time code that the email client can autofill, useOTP-Token: see the OTP-Token header. When the request has anOTP-Token, the API checks its format and returns422if 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.namestringrequiredASCII letters, numbers,_or-, up to 256 characters. Cannot start withpostal_.valuestringSame 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 is403 plan_limit_reached. Apaththat Postal cannot download when it sends the email leaves the email with the statusfailed.contentstringFile content in Base64. Required withoutpath.pathstringPublic http(s) URL that Postal downloads the file from when it sends the email. Required withoutcontent.filenamestringFile name (up to 255 characters). Withpath, it defaults to the name at the end of the URL.content_typestringMIME type, such asapplication/pdf. Without it, the type is inferred from the extension.content_idstringFor inline images: use<img src="cid:logo">in the HTML and"content_id": "logo"here.
scheduled_atstringDate and time to send, in ISO 8601 (for example2026-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_reachedwithout it).templateobjectidstringrequiredTemplate ID or alias.variablesobjectVariable 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.
{
"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.
{
"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.
{
"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
- 422
validation_errorA field is invalid. The message says which one. - 403
validation_errorThe domain infromis not verified or does not belong to your team. - 403
restricted_api_keyThe key can only send from another domain. - 403
sending_pausedSending is paused for your team because of a high bounce or complaint rate. - 403
paused_by_planThe domain infrom, thetemplateor the key itself is paused because it is above the plan limit. See Plan limits. - 403
plan_limit_reachedThe plan does not include what the send asks for:scheduled_at, test addresses, or attachments (or the total goes over the plan limit). - 404
not_foundThetemplatedoes not exist or has not been published. - 404
not_foundNo contact has theuser_id,external_idorcontact_idyou sent. - 400
invalid_idempotency_keyTheIdempotency-Keyis empty or longer than 256 characters. - 409
concurrent_idempotent_requestsAnother request with the sameIdempotency-Keyis still in progress. - 409
invalid_idempotent_requestThe sameIdempotency-Keywas used with a different body. - 429
daily_quota_exceededThe 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. - 429
monthly_quota_exceededThe 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.
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);
{
"id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b"
}
#Send a batch
POST/emails/batch
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[]requiredFrom 1 to 100 emails, each withfrom,to,subject,htmlortextand, optionally,cc,bcc,reply_to,headers,tagsandtemplate. Instead ofto,user_id,external_idorcontact_idwork, as inPOST /emails.
Errors
- 422
validation_errorThe body is not a list, has more than 100 items, or an item is invalid (the message gives its position). - 403
validation_errorAfromuses a domain that is not verified. - 403
plan_limit_reachedThe plan does not include batch sending (or test addresses, if an item uses them). - 403
sending_pausedSending is paused for your team because of a high bounce or complaint rate. - 403
paused_by_planAfromuses a domain paused by the plan, or an item uses a paused template. - 404
not_foundAn item uses atemplatethat does not exist or is not published, or a contact that does not exist (the message gives the position). - 429
daily_quota_exceededThe batch would go over the daily or monthly quota.
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);
{
"data": [
{
"id": "019a1b2c-51aa-7c21-9f02-6b7c8d9e0f1a"
},
{
"id": "019a1b2c-51ab-7d30-8e11-2a3b4c5d6e7f"
}
]
}
#List emails
GET/emails
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
limitintegerItems per page, from 1 to 100. Default: 20.afterstringID of the last item you received, for the next page.beforestringID of the first item you received, for the previous page.searchstringSearches sender, subject and recipients. A full ID finds that exact email. Recipients includeccandbcc; the search is up to 200 characters.statusstringOne or more statuses separated by commas, such asbounced,complained. See the statuses. A value that is not a status returns422.domain_idstringOnly emails from one domain.api_key_idstringOnly emails sent with one key.start_datestringCreated on or after this date (ISO 8601). A date without a time, such as2026-10-01, starts at midnight UTC.end_datestringCreated up to this date (ISO 8601). A date without a time, such as2026-10-24, covers that whole day (UTC). Ifstart_dateis afterend_date, the response is422.
Errors
- 422
validation_errorInvalid filter or cursor, orafterandbeforeused together. Also whenstart_dateis afterend_date.
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);
{
"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}
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
idstringrequiredEmail ID.
Errors
- 404
not_foundThe email does not exist, belongs to another team or, with asending_accesskey, was not sent by that key.
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);
{
"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}
Changes the send date of a scheduled email. It only works while the status is scheduled.
Path parameters
idstringrequiredEmail ID.
Request body
scheduled_atstringrequiredNew date in ISO 8601, in the future and at most 30 days away.
Errors
- 422
validation_errorThe email is not scheduled or the date is invalid. - 404
not_foundThe resource does not exist or belongs to another team.
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);
{
"object": "email",
"id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b"
}
#Cancel a scheduled email
POST/emails/{id}/cancel
Cancels a scheduled email. The status becomes canceled and an email.canceled event is added to the history.
Path parameters
idstringrequiredEmail ID.
Errors
- 422
validation_errorThe email is not scheduled (it was already sent or already canceled). - 404
not_foundThe resource does not exist or belongs to another team.
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);
{
"object": "email",
"id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b"
}
#Raw message
GET/emails/{id}/raw
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
idstringrequiredEmail ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
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();
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
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
idstringrequiredEmail ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
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);
{
"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
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
idstringrequiredEmail ID.
Request body
expires_instringLifetime, from 1 minute to 48 hours:10m,2 hours,1 day… Default:48h.
Errors
- 422
validation_errorexpires_inis in the wrong format or out of range. - 403
plan_limit_reachedThe plan does not include shared email links. - 404
not_foundThe resource does not exist or belongs to another team.
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);
{
"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
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
idstringrequiredEmail ID.
Errors
- 422
validation_errorlimitout of range, orafterandbeforeused together. - 404
not_foundThe resource does not exist or belongs to another team.
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);
{
"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}
Returns an attachment with its download link.
Path parameters
idstringrequiredEmail ID.attachment_idstringrequiredAttachment ID.
Errors
- 404
not_foundThe resource does not exist or belongs to another team.
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);
{
"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.
| Status | Meaning |
|---|---|
queued | In the queue, going out shortly. |
scheduled | Scheduled for scheduled_at. |
sent | Accepted by the sending provider. |
delivery_delayed | The recipient's server delayed delivery; retries happen automatically. |
delivered | Delivered to the recipient's server. |
opened | Opened (with open tracking turned on for the domain). |
clicked | A link was clicked (with click tracking turned on). |
bounced | Bounced. Permanent bounces go to the suppression list. |
complained | The recipient marked it as spam. The address goes to the suppression list. |
failed | It could not be sent (domain not verified, sending paused, attachment unreachable…). |
suppressed | All or some of the recipients were on the suppression list. |
canceled | Scheduling 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.
| Field | Type | Description |
|---|---|---|
object, id | string | email and the email ID. |
message_id | string | null | The email's Message-ID. It is null until the sending provider accepts the email (queued, scheduled or canceled emails). |
from, to, cc, bcc, reply_to | string | string[] | The addresses, as they were sent. cc, bcc and reply_to are empty lists when there are none. |
subject, html, text | string | null | The content that was sent. For a send with a template, it is the content with the variables already filled in. |
created_at | string | When the email was created (ISO 8601, UTC). |
last_event | string | The current status: queued, scheduled, sent, delivery_delayed, delivered, opened, clicked, bounced, failed, suppressed, canceled or complained. |
scheduled_at | string | null | The send date set with scheduled_at, or null for an immediate send. |
simulated | boolean | true for emails to the test addresses, which are never actually sent. |
tags | object[] | The tags, as [{"name", "value"}]. |
events | object[] | Only in GET /emails/{id}: the history, oldest first, as [{"type", "occurred_at", "data"}]. See Email events. |
domain_id, api_key_id, template_id | string | null | Only 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_id | string | Only 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.
| Type | When | data |
|---|---|---|
email.scheduled | The email was created with scheduled_at. | null |
email.canceled | The scheduling was canceled. | null |
email.sent | The sending provider accepted the email. | null |
email.delivered | The recipient's server received the email. | recipients |
email.delivery_delayed | Delivery was delayed; retries happen automatically. | delay_type, recipients |
email.bounced | The email bounced. Permanent bounces enter the suppression list. | bounce (type, subType, message, diagnosticCode), recipients |
email.complained | The recipient marked it as spam. The address enters the suppression list. | recipients, feedback_type |
email.failed | It could not be sent (domain not verified, sending paused, attachment unreachable…). | reason |
email.suppressed | All or some of the recipients were on the suppression list and were dropped when sending. The others go on normally. | recipients |
email.opened | The recipient opened the email (open tracking turned on for the domain). | open (ipAddress, userAgent, timestamp) |
email.clicked | A link was clicked (click tracking turned on). | click (ipAddress, link, userAgent, timestamp) |
email.replied | A reply arrived to the email of a broadcast or automation that detects replies. | reply (from, subject, message_id, preview, attachments), auto |
email.unsubscribed | The 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.
| Address | Events, in order | Final status |
|---|---|---|
[email protected] | email.sent, email.delivered | delivered |
[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.complained | complained |
[email protected] | email.suppressed (no email.sent or email.delivered) | suppressed |
[email protected] | email.sent, email.delivery_delayed | delivery_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,ccandbcc) must be all test addresses or all real ones; mixing them returns422 validation_error. An address on the test domain with any other name, such as[email protected], also returns422, listing the valid names. - Several test addresses in the same email are allowed: each outcome becomes its own event, with a
recipientslist of the addresses with that outcome. The final status follows the furthest event (complainedoutranksbouncedandsuppressed, which outrankdelivered). - 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@orcomplained@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_addressesfeature (see Plan limits); without it, the send returns403 plan_limit_reached. - The email has
"simulated": trueinGET /emails/{id}. You do not need to set up DNS: nothing is delivered to that domain.
{
"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:
{
"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 feature | Key | Where it applies |
|---|---|---|
| Batch sending | batch_sending | POST /emails/batch |
| Scheduled sending | scheduled_sending | The scheduled_at field of POST /emails. |
| Simulated test addresses | test_addresses | sandbox.postal.so |
| Attachment size | attachments_mb | The 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 links | shared_email_links | POST /emails/{id}/share |
| SMTP sending | smtp_access | SMTP |
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.
{
"statusCode": 403,
"name": "plan_limit_reached",
"message": "Your plan does not include “Scheduled sending”. Upgrade your plan to use it."
}
{
"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.
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']);