Emails
Send one email or a hundred, schedule, cancel and reschedule them, and read back every status change and open — the full /emails API.
Before you send#
Every request goes to https://api.poststack.dev and authenticates with an API key in the Authorization: Bearer header (see Authentication). The address in from must be on a domain you have added and verified under Domains; everything else on this page works the same with a live (sk_live_) or a test (sk_test_) key.
Use this API when your code sends mail and you want the email id back, delivery events and safe retries. If your framework already speaks SMTP, the SMTP relay needs no code changes and goes through the same checks.
import { PostStack } from '@poststack.dev/sdk';
const poststack = new PostStack(process.env.POSTSTACK_API_KEY!);
const { id } = await poststack.emails.send({
from: 'Acme <hello@acme.com>',
to: ['user@example.com'],
subject: 'Welcome to Acme',
html: '<p>Thanks for signing up.</p>',
text: 'Thanks for signing up.',
});curl -X POST https://api.poststack.dev/emails \
-H "Authorization: Bearer $POSTSTACK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "Acme <hello@acme.com>",
"to": ["user@example.com"],
"subject": "Welcome to Acme",
"html": "<p>Thanks for signing up.</p>",
"text": "Thanks for signing up."
}'import os
from poststack import PostStack
ps = PostStack(api_key=os.environ["POSTSTACK_API_KEY"])
result = ps.emails.send({
"from": "Acme <hello@acme.com>",
"to": ["user@example.com"],
"subject": "Welcome to Acme",
"html": "<p>Thanks for signing up.</p>",
"text": "Thanks for signing up.",
})
print(result["id"])client := poststack.NewClient(os.Getenv("POSTSTACK_API_KEY"))
res, err := client.Emails.Send(ctx, &poststack.SendEmailInput{
From: "Acme <hello@acme.com>",
To: []string{"user@example.com"},
Subject: "Welcome to Acme",
Html: "<p>Thanks for signing up.</p>",
Text: "Thanks for signing up.",
})
if err != nil {
return err
}
fmt.Println(res.ID)Sending#
A send is accepted, not delivered: the API validates the request, filters suppressed recipients, scores the content for spam, stores the email and queues it, then answers 202 Accepted with the email's id. Delivery happens a moment later; follow it with the events endpoint or a webhook.
Send an email#
Validate, store and queue one email. Returns 202 with the new email's id. POST /emails/send is an alias with identical behaviour.
https://api.poststack.dev/emailsimport { readFileSync } from 'node:fs';
const res = await poststack.emails.send({
from: 'Acme <hello@acme.com>',
to: ['user@example.com'],
subject: 'Your invoice for September',
html: '<p>Your invoice is attached.</p>',
text: 'Your invoice is attached.',
attachments: [
{
filename: 'invoice-1042.pdf',
content: readFileSync('invoice-1042.pdf').toString('base64'),
content_type: 'application/pdf',
},
],
idempotency_key: 'invoice-1042',
});
if (res.replayed) console.log('Already sent as', res.id);
if (res.test_mode) console.log('Simulated — nothing was delivered');curl -X POST https://api.poststack.dev/emails \
-H "Authorization: Bearer $POSTSTACK_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: invoice-1042" \
-d '{
"from": "Acme <hello@acme.com>",
"to": ["user@example.com"],
"subject": "Your invoice for September",
"html": "<p>Your invoice is attached.</p>"
}'{
"from": "Acme <hello@acme.com>",
"to": ["user@example.com"],
"cc": ["account-manager@acme.com"],
"reply_to": "support@acme.com",
"subject": "Your invoice for September",
"html": "<p>Your invoice is attached.</p>",
"text": "Your invoice is attached.",
"tags": ["billing", "invoice"],
"attachments": [
{
"filename": "invoice-1042.pdf",
"content": "JVBERi0xLjcKJeLjz9MK...",
"content_type": "application/pdf"
}
],
"idempotency_key": "invoice-1042",
"tracking": { "opens": false, "clicks": false }
}HTTP/1.1 202 Accepted
{
"id": "em_7f3kq9x2m4p8r1v6t0w5y2za",
"spam_score": 1.2
}Headers
Idempotency-Keystring- Used as
idempotency_keywhen the body has none. Printable ASCII, up to 64 characters; any other value is ignored rather than rejected. The TypeScript, Python and Go SDKs send a fresh UUID on every call, so their own retries never double-send.
Body parameters
fromstringrequired- Sender address, either
you@yourdomain.comorYour Name <you@yourdomain.com>. The domain must be added to your team and verified, otherwise the send is refused with422. tostring[]required- 1–50 recipient addresses. Bare addresses only —
Name <a@b.com>fails validation here.to,ccandbcctogether may hold at most 50 addresses. ccstring[]- Up to 50 carbon-copy addresses. Bare addresses only.
bccstring[]- Up to 50 blind-copy addresses. They receive the message but never appear in its headers.
reply_tostring- One address replies should go to. A single bare address, not an array.
subjectstring- 1–998 characters after trimming. Required unless you send a
template_id. htmlstring- HTML body. You need
html,textor both unless you send atemplate_id. textstring- Plain-text body. Sending both html and text gives the best inbox placement.
template_idstring- A published template's id (
tpl_…). Its subject and bodies replacesubject,htmlandtextfrom the request. variablesobject- Values for the template's
{{name}}tags. Every value must be a string — a number or boolean fails validation with400. headersobject- Extra MIME headers as name → value strings. Each value is at most 1,024 characters.
tagsstring[]- Up to 10 labels of at most 64 characters each, for filtering the email list.
attachmentsobject[]- Up to 10 files:
filename(required),content(base64, required),content_typeandcontent_id(for an inlinecid:image). At most 10 MB per file and 25 MB in total, measured after base64 decoding. scheduled_atstring (ISO 8601)- When to send, in UTC with a
Zsuffix, e.g.2026-10-01T09:00:00Z. An offset such as+02:00fails validation. A time in the past sends at once. idempotency_keystring- Up to 64 characters. A retry with the same key and the same payload returns the original email instead of sending again. See Idempotency.
trackingobject{ opens?: boolean, clicks?: boolean }— overrides the sending domain's open/click tracking for this message only.unsubscribebooleandefaultfalse- Adds RFC 8058 one-click
List-Unsubscribeheaders. See Unsubscribe headers and links. signature"auto" | "none"default"auto"autoappends the stored signature of the mailbox thatfrombelongs to, if it has one; a{{signature}}tag in the body places it there instead.nonenever adds it. Sends from an address that is not a mailbox are unaffected.idempotency_window_hoursinteger- Deprecated. Accepted (1–72) for compatibility and ignored.
Response fields
idstring- The email's
em_…id. Keep it: every other email endpoint takes it. spam_scorenumber- The rspamd score. Omitted when the spam scanner was unreachable (scoring fails open) and on test-mode sends.
spam_warningsobject[]{ name, score, description? }per rule that fired. Present only when the score reached the warn threshold (default 5.0).replayedtrue- Present when the idempotency key matched an earlier send:
idis that email and nothing was sent this time. The response also carriesX-Idempotent-Replay: true. test_modetrue- Present when you sent with an
sk_test_key. The email is recorded as delivered and never leaves PostStack.
Status codes
| Status | Meaning |
|---|---|
| 202 | Accepted and queued (or scheduled). Also returned for an idempotent replay. |
| 400 | Body is not JSON, or failed validation — the error names each field, e.g. to.0: must be a valid email address. A body with neither recipients nor content also returns missingFields. |
| 401 | The API key or access token is invalid, revoked or expired, or a GET had no Authorization header. |
| 403 | insufficient_scope (an OAuth token without the scope this endpoint needs), Team is suspended, or Invalid request origin (a POST or PATCH sent without an Authorization: Bearer header). |
| 403 | Also: the team owner has not verified their email address; sending is paused for the team; the API key is restricted to a different domain; or a safety hold (e.g. a bank or authority name in the sender name). |
| 404 | Template not found — template_id is not one of your templates. |
| 413 | The request body is over 36 MB. The proxy answers with an HTML page, not JSON. |
| 422 | The request is valid but cannot be sent: the from domain is not added or not verified; the template is not published; more than 50 recipients across to/cc/bcc; an attachment over its size limit; every recipient is suppressed; the spam score reached the block threshold; or the idempotency key was already used for a different payload. |
| 429 | Branch on code: rate_limit_exceeded is the rate limit (60 requests/min per key; wait Retry-After). Quotas that a retry will not clear have their own code: monthly_limit_exceeded (your plan's monthly email limit), daily_limit_exceeded (the daily cap on sends to external addresses for new and Free accounts) and send_limit_exceeded (sending from new accounts paused platform-wide). |
| 500 | Unexpected server error. Safe to retry; the SDKs retry it for you. |
Send a batch#
Send up to 100 emails in one request. Each email is processed independently; the response is 202 even when some were refused, so check every element.
https://api.poststack.dev/emails/batchconst { data } = await poststack.emails.batch({
emails: orders.map((order) => ({
from: 'Acme <hello@acme.com>',
to: [order.email],
subject: 'Your order has shipped',
html: `<p>Hi ${order.name}, your order is on its way.</p>`,
idempotency_key: `shipped-${order.id}`,
})),
});
// Entries are { id } | { error } — narrow before reading id.
data.forEach((result, i) => {
if ('error' in result) console.error(orders[i].id, result.error);
});curl -X POST https://api.poststack.dev/emails/batch \
-H "Authorization: Bearer $POSTSTACK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"emails": [
{
"from": "Acme <hello@acme.com>",
"to": ["alice@example.com"],
"subject": "Your order has shipped",
"html": "<p>Your order is on its way.</p>"
}
]
}'{
"emails": [
{
"from": "Acme <hello@acme.com>",
"to": ["alice@example.com"],
"subject": "Your order has shipped",
"html": "<p>Hi Alice, your order is on its way.</p>"
},
{
"from": "Acme <hello@acme.com>",
"to": ["bob@example.com"],
"subject": "Your order has shipped",
"html": "<p>Hi Bob, your order is on its way.</p>"
}
]
}HTTP/1.1 202 Accepted
{
"data": [
{ "id": "em_2b8xw4n0c7q5k1m9d3r6t8ya" },
{ "error": "All recipients are suppressed" }
]
}Headers
Idempotency-Keystring- One key for the whole batch: element
nwithout its ownidempotency_keygets<key>:n. The key plus that suffix must fit in 64 characters, otherwise the header is ignored.
Body parameters
emailsobject[]required- 1–100 email objects, each with exactly the body parameters of Send an email. The whole request is validated first: one malformed element fails the entire batch with
400and nothing is sent.
Response fields
dataobject[]- One entry per input email, in the same order. Each entry has either id or error.
data[].idstring- The em_… id of an accepted email.
data[].replayedtrue- Present when this element matched an earlier idempotency key.
data[].test_modetrue- Present on every accepted element when you sent with an
sk_test_key — the same flag a single send returns. data[].errorstring- Why this element was refused — the same message a single send would return with its
403,404,422or429. data[].codestring- Present on a refused element when the refusal has a specific code — the quota codes
monthly_limit_exceeded,daily_limit_exceededandsend_limit_exceeded. Retrying that element will not help.
Status codes
| Status | Meaning |
|---|---|
| 202 | The batch was processed. Some elements may still carry error. |
| 400 | Validation failed — no emails, more than 100, or an invalid element (emails.3.to.0: must be a valid email address). Nothing was sent. |
| 401 | The API key or access token is invalid, revoked or expired, or a GET had no Authorization header. |
| 403 | insufficient_scope (an OAuth token without the scope this endpoint needs), Team is suspended, or Invalid request origin (a POST or PATCH sent without an Authorization: Bearer header). |
| 413 | The request body is over 36 MB. |
| 429 | 10 batch requests per minute per key exceeded. Check Retry-After. |
| 500 | Unexpected server error. Safe to retry; the SDKs retry it for you. |
Retrieving emails#
Emails are kept for your plan's log retention — 14 days on Free and Starter, 30 on Pro, 90 on Scale, unlimited on Enterprise — and then deleted along with their events.
List emails#
Newest first, paginated. Bodies and attachments are left out of list rows — fetch one email to get them.
https://api.poststack.dev/emailsconst { data, meta } = await poststack.emails.list({
status: 'bounced',
date_from: '2026-09-01',
per_page: 100,
});
// Address emails by publicId ("em_…"), never by the numeric id.
for (const email of data) console.log(email.publicId, email.toAddresses);curl "https://api.poststack.dev/emails?status=bounced&date_from=2026-09-01&per_page=100" \
-H "Authorization: Bearer $POSTSTACK_API_KEY"{
"data": [
{
"id": 48211,
"publicId": "em_7f3kq9x2m4p8r1v6t0w5y2za",
"teamId": 312,
"apiKeyId": 97,
"domainId": 41,
"fromAddress": "hello@acme.com",
"fromName": "Acme",
"toAddresses": ["user@example.com"],
"ccAddresses": null,
"bccAddresses": null,
"replyTo": ["support@acme.com"],
"subject": "Your invoice for September",
"headers": null,
"tags": ["billing", "invoice"],
"status": "delivered",
"testMode": false,
"recipientProvider": "gmail",
"unsubscribeEnabled": false,
"spamScore": "1.20",
"spamWarnings": null,
"scheduledAt": null,
"sentAt": "2026-09-29T10:00:01.412Z",
"createdAt": "2026-09-29T10:00:00.871Z"
}
],
"meta": { "page": 1, "perPage": 20, "total": 142, "totalPages": 8 }
}Query parameters
pageintegerdefault1- Page number, 1 or higher.
per_pageintegerdefault20- 1–100. A higher value is rejected with 400.
statusstringqueued,sending,deferred,delivered,bounced,complainedorfailed. Anything else is a400.tostring- Case-insensitive substring of any recipient address.
fromstring- Case-insensitive substring of the sender address.
subjectstring- Case-insensitive substring of the subject.
tagstring- Emails carrying exactly this tag.
domain_idinteger- Numeric id of the sending domain. A
dom_…id is rejected with400. date_fromstring (ISO 8601)- Created at or after this moment. A date (
2026-09-01) or a full timestamp; anything unparseable is a400. date_tostring (ISO 8601)- Created at or before this moment. A bare date means midnight UTC at the start of that day.
providerstring- Recipient mailbox provider:
gmail,outlook,yahoo,icloud,proton,aol,gmx,zoho,fastmailorother. countrystring- Emails with at least one open or click from this two-letter country code (
DK). devicestring- Emails opened or clicked on a
desktop,mobileortablet.
Response fields
data[]object[]- Email objects with every field listed under Retrieve an email except
htmlBody,textBody,attachmentsandevents. meta.pageinteger- The page returned.
meta.perPageinteger- Page size used.
meta.totalinteger- Emails matching the filters.
meta.totalPagesinteger- Pages at this page size.
Status codes
| Status | Meaning |
|---|---|
| 200 | The page of emails (possibly empty). |
| 400 | An invalid filter: unknown status, unparseable date, per_page over 100, non-numeric domain_id. |
| 401 | The API key or access token is invalid, revoked or expired, or a GET had no Authorization header. |
| 403 | insufficient_scope (an OAuth token without the scope this endpoint needs), Team is suspended, or Invalid request origin (a POST or PATCH sent without an Authorization: Bearer header). |
| 500 | Unexpected server error. Safe to retry; the SDKs retry it for you. |
Retrieve an email#
One email with its bodies, attachment metadata and events.
https://api.poststack.dev/emails/:idconst email = await poststack.emails.get('em_7f3kq9x2m4p8r1v6t0w5y2za');
console.log(email.status, email.testMode);curl https://api.poststack.dev/emails/em_7f3kq9x2m4p8r1v6t0w5y2za \
-H "Authorization: Bearer $POSTSTACK_API_KEY"{
"email": {
"id": 48211,
"publicId": "em_7f3kq9x2m4p8r1v6t0w5y2za",
"fromAddress": "hello@acme.com",
"fromName": "Acme",
"toAddresses": ["user@example.com"],
"subject": "Your invoice for September",
"htmlBody": "<p>Your invoice is attached.</p>",
"textBody": "Your invoice is attached.",
"attachments": [
{ "filename": "invoice-1042.pdf", "content_type": "application/pdf", "size": 48213 }
],
"status": "delivered",
"testMode": false,
"spamScore": "1.20",
"createdAt": "2026-09-29T10:00:00.871Z",
"events": [
{ "id": 902, "emailId": 48211, "eventType": "queued", "isPrefetch": false, "createdAt": "2026-09-29T10:00:00.874Z" },
{ "id": 903, "emailId": 48211, "eventType": "sent", "isPrefetch": false, "createdAt": "2026-09-29T10:00:01.412Z" },
{ "id": 904, "emailId": 48211, "eventType": "delivered", "isPrefetch": false, "createdAt": "2026-09-29T10:00:02.050Z" }
]
}
}
// Shortened: the email carries every field listed below.Path parameters
idstringrequired- The
em_…id from the send response orpublicIdin the list.
Response fields
emailobject- The email. The SDKs unwrap this envelope:
emails.get()returns the object itself. email.idinteger- Internal row id. No endpoint accepts it — address an email by
publicId. email.publicIdstring- The
em_…id every email endpoint takes (em_+ 24 lowercase letters and digits). email.teamIdinteger- Your team.
email.apiKeyIdinteger | null- The API key that sent it; null for dashboard, workflow and broadcast sends.
email.domainIdinteger | null- The sending domain.
email.fromAddressstring- Sender address without the name.
email.fromNamestring | null- Display name from `from`, if any.
email.toAddressesstring[]- Recipients after suppression filtering.
email.ccAddressesstring[] | null- Cc recipients.
email.bccAddressesstring[] | null- Bcc recipients.
email.replyTostring[] | null- The
reply_toaddress, stored as a one-element array. email.subjectstring- Subject after template rendering.
email.headersobject | null- Your custom headers.
email.tagsstring[] | null- Tags from the request.
email.statusstring- Current status. See Statuses and events.
email.testModeboolean- True when sent with an
sk_test_key: recorded as delivered, never handed to a mail server. email.recipientProviderstring | null- Mailbox provider of the first recipient:
gmail,outlook,yahoo,icloud,proton,aol,gmx,zoho,fastmailorother. email.unsubscribeEnabledboolean- Whether
unsubscribe: truewas set. email.spamScorestring | null- rspamd score as a decimal string (
"1.20"); null when scoring was skipped (the scanner was unreachable). email.spamWarningsobject[] | null{ name, score, description? }for each rule that fired, stored only when the score reached the warn threshold.email.scheduledAtstring | null- Scheduled send time, if any.
email.sentAtstring | null- When PostStack handed the message to its mail server.
email.createdAtstring- When the API accepted the request.
email.htmlBodystring | null- HTML as stored: after template rendering and signature, with the open pixel and rewritten click links when tracking is on.
email.textBodystring | null- Plain-text body.
email.attachmentsobject[] | null- One
{ filename, content_type, size }per attachment —sizeis the decoded size in bytes andcontent_typeis null when you did not send one. The content is not included here: download one file withGET /emails/:id/attachments/:index, whereindexis its position in this array. email.eventsobject[]- The email's events, not guaranteed to be in order. Use the events endpoint for a chronological list.
Status codes
| Status | Meaning |
|---|---|
| 200 | The email. |
| 401 | The API key or access token is invalid, revoked or expired, or a GET had no Authorization header. |
| 403 | insufficient_scope (an OAuth token without the scope this endpoint needs), Team is suspended, or Invalid request origin (a POST or PATCH sent without an Authorization: Bearer header). |
| 404 | Email not found — wrong id, another team’s email, or deleted by log retention. |
| 500 | Unexpected server error. Safe to retry; the SDKs retry it for you. |
List an email's events#
Every status change, open and click for one email, oldest first.
https://api.poststack.dev/emails/:id/events// Returns the array directly.
const events = await poststack.emails.getEvents('em_7f3kq9x2m4p8r1v6t0w5y2za');
// Exclude privacy-proxy prefetches when counting real opens.
const humanOpens = events.filter((e) => e.eventType === 'opened' && !e.isPrefetch);curl https://api.poststack.dev/emails/em_7f3kq9x2m4p8r1v6t0w5y2za/events \
-H "Authorization: Bearer $POSTSTACK_API_KEY"{
"events": [
{ "id": 902, "emailId": 48211, "eventType": "queued", "metadata": null, "isPrefetch": false, "createdAt": "2026-09-29T10:00:00.874Z" },
{ "id": 903, "emailId": 48211, "eventType": "sent", "metadata": null, "isPrefetch": false, "createdAt": "2026-09-29T10:00:01.412Z" },
{ "id": 904, "emailId": 48211, "eventType": "delivered", "metadata": null, "isPrefetch": false, "createdAt": "2026-09-29T10:00:02.050Z" },
{
"id": 911,
"emailId": 48211,
"eventType": "opened",
"metadata": null,
"ipAddress": "203.0.113.24",
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ...",
"geoCountry": "DK",
"geoRegion": "Capital Region",
"geoCity": "Copenhagen",
"uaClientName": "Chrome",
"uaClientType": "browser",
"uaOs": "macOS",
"uaDeviceType": "desktop",
"isPrefetch": false,
"createdAt": "2026-09-29T10:04:37.220Z"
}
]
}Path parameters
idstringrequired- The email's
em_…id.
Response fields
eventsobject[]- Events sorted by createdAt, oldest first.
events[].idinteger- Event id.
events[].emailIdinteger- The email’s internal id.
events[].eventTypestring- One of
queued,sent,delivered,deferred,bounced,soft_bounced,opened,clicked,complained,unsubscribed,failed. events[].metadataobject | null- Event details — e.g. the clicked URL, a bounce diagnostic,
testMode, orreason: "cancelled". events[].ipAddressstring | null- IP that opened or clicked.
events[].userAgentstring | null- Raw User-Agent of an open or click.
events[].geoCountrystring | null- Two-letter country code resolved from the IP.
events[].geoRegionstring | null- Region resolved from the IP.
events[].geoCitystring | null- City resolved from the IP.
events[].uaClientNamestring | null- Mail client or browser name.
events[].uaClientTypestring | null- Client category.
events[].uaOsstring | null- Operating system.
events[].uaDeviceTypestring | nulldesktop,mobileortablet.events[].isPrefetchboolean- True for a machine fetch — Apple Mail Privacy Protection, a security-scanner IP, or a hit within 15 seconds of hand-off. Gmail image-proxy opens are real opens and stay false.
events[].createdAtstring- When the event happened.
Status codes
| Status | Meaning |
|---|---|
| 200 | The events (at least queued for any accepted email). |
| 401 | The API key or access token is invalid, revoked or expired, or a GET had no Authorization header. |
| 403 | insufficient_scope (an OAuth token without the scope this endpoint needs), Team is suspended, or Invalid request origin (a POST or PATCH sent without an Authorization: Bearer header). |
| 404 | Email not found. |
| 500 | Unexpected server error. Safe to retry; the SDKs retry it for you. |
Get deliverability insights#
Checks a sent email for common deliverability problems and returns a warning for each one found.
https://api.poststack.dev/emails/:id/insightsconst warnings = await poststack.emails.getInsights('em_7f3kq9x2m4p8r1v6t0w5y2za');
for (const w of warnings) console.log(w.severity, w.code, w.message);curl https://api.poststack.dev/emails/em_7f3kq9x2m4p8r1v6t0w5y2za/insights \
-H "Authorization: Bearer $POSTSTACK_API_KEY"{
"warnings": [
{
"severity": "warning",
"code": "missing_text_body",
"message": "No plain text version included. Some email clients and spam filters prefer a text alternative."
},
{
"severity": "warning",
"code": "missing_dmarc",
"message": "DMARC record is not verified. Set up DMARC to improve deliverability."
}
]
}Path parameters
idstringrequired- The email's
em_…id.
Response fields
warningsobject[]- Empty when nothing was found.
warnings[].severity"warning" | "info"- How much it is likely to matter.
warnings[].codestringmissing_text_body(HTML without a text part),html_too_large(HTML over 102 KB — Gmail clips it),noreply_sender(a no-reply from address),link_domain_mismatch(links to domains other than the sending domain and its subdomains, judged on the links you wrote — click-tracking rewrites are ignored),missing_dmarc(the sending domain's DMARC record is not verified).warnings[].messagestring- Readable explanation.
Status codes
| Status | Meaning |
|---|---|
| 200 | The warnings (possibly none). |
| 401 | The API key or access token is invalid, revoked or expired, or a GET had no Authorization header. |
| 403 | insufficient_scope (an OAuth token without the scope this endpoint needs), Team is suspended, or Invalid request origin (a POST or PATCH sent without an Authorization: Bearer header). |
| 404 | Email not found. |
| 500 | Unexpected server error. Safe to retry; the SDKs retry it for you. |
Scheduling#
Add scheduled_at to a send and the email waits in queued until then. The time must be UTC with a Z suffix (2026-10-01T09:00:00Z); a time already in the past sends immediately. An email.scheduled webhook fires when a future-dated send is accepted. The monthly quota is counted when you schedule, not when the email goes out.
Until the scheduled time you can move or cancel it. Both only work on an email that is still queued and has a scheduled_at in the future — an immediate send is already on its way and cannot be recalled.
Reschedule an email#
Move a scheduled email to a new time. The old delivery time is removed, so it cannot fire twice.
https://api.poststack.dev/emails/:idawait poststack.emails.reschedule('em_7f3kq9x2m4p8r1v6t0w5y2za', '2026-10-02T14:00:00Z');curl -X PATCH https://api.poststack.dev/emails/em_7f3kq9x2m4p8r1v6t0w5y2za \
-H "Authorization: Bearer $POSTSTACK_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "scheduled_at": "2026-10-02T14:00:00Z" }'{
"scheduled_at": "2026-10-02T14:00:00Z"
}{
"success": true
}Path parameters
idstringrequired- The email's
em_…id.
Body parameters
scheduled_atstring (ISO 8601)required- New send time in UTC with a
Zsuffix. Must be in the future.
Response fields
successtrue- The email is scheduled for the new time.
Status codes
| Status | Meaning |
|---|---|
| 200 | Rescheduled. |
| 400 | scheduled_at is missing or not an ISO 8601 UTC timestamp. |
| 401 | The API key or access token is invalid, revoked or expired, or a GET had no Authorization header. |
| 403 | insufficient_scope (an OAuth token without the scope this endpoint needs), Team is suspended, or Invalid request origin (a POST or PATCH sent without an Authorization: Bearer header). |
| 404 | Email not found. |
| 422 | Only queued emails can be rescheduled, Only future-scheduled emails can be rescheduled, or New scheduled time must be in the future. |
| 500 | Unexpected server error. Safe to retry; the SDKs retry it for you. |
Cancel an email#
Stop a scheduled email from being sent. Its status becomes failed and a failed event with reason 'cancelled' is recorded; no webhook fires.
https://api.poststack.dev/emails/:id/cancelawait poststack.emails.cancel('em_7f3kq9x2m4p8r1v6t0w5y2za');curl -X POST https://api.poststack.dev/emails/em_7f3kq9x2m4p8r1v6t0w5y2za/cancel \
-H "Authorization: Bearer $POSTSTACK_API_KEY"{
"success": true
}Path parameters
idstringrequired- The email's
em_…id.
Response fields
successtrue- The email will not be sent.
Status codes
| Status | Meaning |
|---|---|
| 200 | Cancelled. |
| 401 | The API key or access token is invalid, revoked or expired, or a GET had no Authorization header. |
| 403 | insufficient_scope (an OAuth token without the scope this endpoint needs), Team is suspended, or Invalid request origin (a POST or PATCH sent without an Authorization: Bearer header). |
| 404 | Email not found. |
| 422 | Only queued emails can be cancelled or Only future-scheduled emails can be cancelled. |
| 429 | More than 30 cancellations per minute per key. |
| 500 | Unexpected server error. Safe to retry; the SDKs retry it for you. |
Previewing#
Two endpoints run the checks a send would, without storing or sending anything. Neither counts against your quota. In the TypeScript SDK they are emails.preview() and emails.spamPreview(); you can also call them over HTTP, or use the preview_email tool in the MCP server.
Preview an email#
Render a template or body, measure it, spam-score it and run the deliverability checks in one call. Nothing is stored or sent.
https://api.poststack.dev/emails/previewcurl -X POST https://api.poststack.dev/emails/preview \
-H "Authorization: Bearer $POSTSTACK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "Acme <hello@acme.com>",
"to": ["user@example.com"],
"template_id": "tpl_4m8q2x7k1c9w5r3t0z6p8n2v",
"variables": { "first_name": "Alice" }
}'{
"from": "Acme <hello@acme.com>",
"to": ["user@example.com"],
"template_id": "tpl_4m8q2x7k1c9w5r3t0z6p8n2v",
"variables": { "first_name": "Alice" }
}{
"rendered_subject": "Welcome, Alice",
"rendered_html": "<p>Hi Alice, thanks for joining.</p>",
"rendered_text": null,
"size": { "html_bytes": 38, "text_bytes": 0 },
"spam": { "score": 2.1, "action": "no_action", "symbols": [], "skipped": false },
"warnings": [
{
"severity": "warning",
"code": "missing_text_body",
"message": "No plain text version included. Some email clients and spam filters prefer a text alternative."
}
]
}Body parameters
fromstringrequired- The sender you intend to use; its domain decides the DMARC check.
tostring[]required- 1–5 recipients, used only for spam scoring.
subjectstring- Required unless the template supplies one.
htmlstring- HTML body, if not using a template.
textstring- Text body, if not using a template.
template_idstring- A template to render. Unlike a send, drafts (unpublished) work too.
variablesobject- String values for the template’s tags.
Response fields
rendered_subjectstring- Subject after rendering.
rendered_htmlstring | null- HTML after rendering.
rendered_textstring | null- Text after rendering.
size.html_bytesinteger- UTF-8 size of the HTML.
size.text_bytesinteger- UTF-8 size of the text.
spam.scorenumber- rspamd score.
spam.actionstringno_action,greylist,add_header,rewrite_subject,soft_rejectorreject. A real send is refused onsoft_rejectandreject.spam.symbolsobject[]- Rules that added to the score:
{ name, score, description? }. spam.skippedboolean- True when the scanner was unreachable and the score is a placeholder 0.
warningsobject[]- Same checks as deliverability insights.
Status codes
| Status | Meaning |
|---|---|
| 200 | The preview. |
| 400 | Validation failed, or Subject is required (either directly or via template) / Body is required (html or text). |
| 401 | The API key or access token is invalid, revoked or expired, or a GET had no Authorization header. |
| 403 | insufficient_scope (an OAuth token without the scope this endpoint needs), Team is suspended, or Invalid request origin (a POST or PATCH sent without an Authorization: Bearer header). |
| 404 | Template not found. |
| 429 | More than 30 previews per minute. |
| 500 | Unexpected server error. Safe to retry; the SDKs retry it for you. |
Score content for spam#
Run only the spam scan on a subject and body. Nothing is stored or sent.
https://api.poststack.dev/emails/spam-previewcurl -X POST https://api.poststack.dev/emails/spam-preview \
-H "Authorization: Bearer $POSTSTACK_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": "hello@acme.com",
"to": ["user@example.com"],
"subject": "Your September invoice",
"text": "Your invoice is attached."
}'{
"from": "hello@acme.com",
"to": ["user@example.com"],
"subject": "Last chance!!!",
"html": "<p>Click <a href=\"https://bit.ly/x\">here</a></p>"
}{
"score": 6.42,
"action": "add_header",
"symbols": [
{ "name": "HTML_SHORT_LINK_IMG_2", "score": 3.0, "description": "HTML: short link + image" },
{ "name": "MIME_HTML_ONLY", "score": 0.2, "description": "Message has only HTML part" }
],
"skipped": false
}Body parameters
fromstringrequired- Sender address.
tostring[]required- 1–5 valid addresses.
subjectstringrequired- 1–998 characters.
htmlstring- HTML body.
textstring- Text body.
Response fields
scorenumber- rspamd score. Sends are refused at 10.0 by default.
actionstring- rspamd’s verdict, as in the preview above.
symbolsobject[]- Rules with a positive score, highest first.
skippedboolean- True when the scanner was unreachable.
Status codes
| Status | Meaning |
|---|---|
| 200 | The score. |
| 400 | Validation failed. |
| 401 | The API key or access token is invalid, revoked or expired, or a GET had no Authorization header. |
| 403 | insufficient_scope (an OAuth token without the scope this endpoint needs), Team is suspended, or Invalid request origin (a POST or PATCH sent without an Authorization: Bearer header). |
| 429 | More than 30 requests per minute. |
| 500 | Unexpected server error. Safe to retry; the SDKs retry it for you. |
Statuses and events#
An email's status is where it is now; its events are how it got there. A typical delivery is queued → sent → delivered, followed by any opened and clicked events.
| Status | Meaning |
|---|---|
queued | Accepted and waiting for a send worker — or for its scheduled_at. |
sending | A worker is building the message and handing it to the mail server. |
deferred | The recipient’s server answered with a temporary 4xx (greylisting, rate limit, full mailbox). The relay keeps retrying. |
delivered | The recipient’s server accepted the message (a 250 reply). |
bounced | The recipient’s server rejected it permanently. The address may be suppressed. |
complained | The recipient marked it as spam. The address is suppressed. |
failed | PostStack could not send it, or you cancelled it (the failed event then carries reason: "cancelled"). |
Events add detail the status does not hold: sent (handed to the mail server), soft_bounced, opened, clicked and unsubscribed. To react to them as they happen instead of polling, subscribe to the matching email.* webhook events.
Sender and recipients#
from accepts a display name — Acme <hello@acme.com> shows "Acme" in the inbox. to, cc, bcc and reply_to take bare addresses only.
Before queueing, PostStack drops every recipient on your suppression list and fires an email.suppressed webhook naming them. The rest still get the email. If every recipient — or every to recipient — is suppressed, the send fails with 422 All recipients are suppressed.
Attachments#
Attach up to 10 files per email, base64-encoded in content. Each file may be up to 10 MB and all files together up to 25 MB. Both limits are measured on the decoded file, not the base64 string, which is about a third larger — a 10 MB PDF is roughly 13.4 MB of content. Over a limit, the send fails with a 422 naming the file and its size.
import { readFileSync } from 'node:fs';
await poststack.emails.send({
from: 'billing@acme.com',
to: ['customer@example.com'],
subject: 'Your receipt',
html: '<p>Thanks for your order.</p><img src="cid:logo" alt="Acme" />',
attachments: [
{
filename: 'receipt.pdf',
content: readFileSync('receipt.pdf').toString('base64'),
content_type: 'application/pdf',
},
{
// Inline image: referenced from the HTML as cid:logo
filename: 'logo.png',
content: readFileSync('logo.png').toString('base64'),
content_type: 'image/png',
content_id: 'logo',
},
],
});An attachment with a content_id is sent as an inline part, so an <img src="cid:logo"> in the HTML renders in place. Messages submitted over SMTP keep their inline images automatically.
Templates#
Pass template_id and variables instead of a subject and body. The template must be published — a draft fails with 422 Template is not published, an unknown id with 404. The template's subject, HTML and text replace whatever the request carried.
await poststack.emails.send({
from: 'Acme <hello@acme.com>',
to: ['user@example.com'],
template_id: 'tpl_4m8q2x7k1c9w5r3t0z6p8n2v',
variables: {
first_name: 'Alice',
action_url: 'https://acme.com/activate?t=abc',
},
});Tags#
Tags are free-form labels — up to 10 per email, 64 characters each — that you can filter the list on with ?tag=. The filter matches one whole tag exactly.
await poststack.emails.send({
from: 'orders@acme.com',
to: ['user@example.com'],
subject: 'Order confirmed',
html: '<p>Your order is confirmed.</p>',
tags: ['transactional', 'order-confirmation'],
});
const { data } = await poststack.emails.list({ tag: 'order-confirmation' });Idempotency#
Networks fail after the server has already acted. Send an idempotency_key (or an Idempotency-Key header) and a retry of the same request returns the original email instead of sending a second copy: the response is still 202, with replayed: true and an X-Idempotent-Replay: true header. A replay sends nothing, fires no webhooks and uses no quota.
- A key is remembered for as long as the email it created is kept — your plan's log retention (14 days on Free and Starter, 30 on Pro, 90 on Scale, unlimited on Enterprise), not 24 hours.
- Reusing a key with a different payload is refused with
422(This idempotency key was already used for a different request. Use a new key to send a different email.) instead of replaying, so a key reused by mistake never reports an unsent email as sent. Retries are compared on the request as you sent it, so a template edited between attempts does not cause a conflict. - To send the same message again on purpose, use a new key (
order-123:resend-1). - Over SMTP, a reused
X-PostStack-Idempotency-Keyheader always replays, because a mail client's retry is rarely byte-identical.
The TypeScript, Python and Go SDKs add a random Idempotency-Key header to every send, so their own automatic retries are already safe. Set idempotency_key yourself to also cover retries of your own code — a job that runs twice, a queue redelivery.
Tracking#
Open and click tracking are switched on or off per domain (see Tracking). Override them for one message with tracking — typically to keep password resets and magic links free of rewritten URLs. Tracking only touches the HTML part.
await poststack.emails.send({
from: 'security@acme.com',
to: ['user@example.com'],
subject: 'Reset your password',
html: '<p><a href="https://acme.com/reset?t=abc">Reset your password</a></p>',
tracking: { opens: false, clicks: false },
});Spam scoring#
Every send is scored by rspamd before it is queued. At the warn threshold (5.0 by default) the email still goes out and the response carries spam_warnings. At the block threshold (10.0), or when rspamd's verdict is reject or soft_reject, the send fails with 422 Email rejected by spam filter (score 11.40) before it is stored — it uses no quota. Support can adjust both thresholds for your team.
If the scanner is unreachable, scoring is skipped and the email is sent normally: an outage of the scanner never blocks delivery. Check content before a campaign with Score content for spam.
Unsubscribe headers and links#
Gmail and Yahoo require bulk senders to support RFC 8058 one-click unsubscribe. Set unsubscribe: true on list-style mail (broadcasts always do) and PostStack adds:
List-Unsubscribe: <https://poststack.dev/u/TOKEN>
List-Unsubscribe-Post: List-Unsubscribe=One-ClickWhen the recipient uses the client's Unsubscribe button, the address is added to your suppression list and, if it is one of your contacts, the contact is marked unsubscribed. The token is signed, bound to the first to recipient, and expires 60 days after the email was sent. Leave unsubscribe off for purely transactional mail.
For a visible link, put {{unsubscribe_url}} in the HTML or text body of any send or template; PostStack replaces it with a signed link for the recipient. Including it also adds the headers above, so you do not need unsubscribe: true as well. {{preferences_url}} links to the preference centre, where the recipient can opt out of individual subscription topics — on its own it does not add the headers.
<footer>
<a href="{{unsubscribe_url}}">Unsubscribe</a> ·
<a href="{{preferences_url}}">Manage preferences</a>
</footer>Following the link opens a confirmation page; the opt-out is recorded only when the recipient confirms, so a link scanner cannot unsubscribe anyone.
Limits#
| Limit | Value |
|---|---|
| Recipients per email | 50 across to + cc + bcc |
| Emails per batch request | 100 |
| Subject length | 998 characters |
| Tags | 10 per email, 64 characters each |
| Custom header value | 1,024 characters |
| Attachments | 10 per email |
| Attachment size | 10 MB per file, 25 MB per email (decoded) |
| Request body | 36 MB (larger bodies get a bare 413 from the proxy) |
| Idempotency key | 64 characters, honoured for your plan’s log retention |
POST /emails | 60 requests per minute per API key (30 on the Free plan) |
POST /emails/batch | 10 requests per minute per API key (5 on the Free plan) |
POST /emails/:id/cancel | 30 per minute per API key (15 on the Free plan) |
/emails/preview, /emails/spam-preview | 30 per minute each (15 on the Free plan) |
| Per source IP (proxy) | 30 requests/s across the API; 10/s on /emails/batch and /emails/send |
Rate limits are counted per API key and per endpoint; each limited response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset, and a 429 adds Retry-After. See Rate limits. Separately, new accounts and Free accounts may make a limited number of sends to external addresses per 24 hours (a send counts once, however many recipients it has); the 429 error names the number.
Troubleshooting#
422 Domain 'example.com' is not verified
The domain in from is on your team but its DNS records have not all checked out. Open it under Domains, fix the record it flags, and verify again. not found for this team instead means the domain was never added — or you are using a key from another team.
403 API key is restricted to a different domain
The key was created for one domain and from is on another. Send from the key's domain, or use a key without a domain restriction. See Domain-restricted keys.
202 but the email never arrives
Check the response for test_mode: true — a test key never delivers. Then read the email's events: a deferred status means the recipient's server is still refusing it temporarily; bounced events carry the server's reason in metadata. If it shows delivered, the recipient's server accepted it — look in their spam folder and run GET /emails/:id/insights.
202 with replayed: true, but nothing was sent
The idempotency key was used before, so the original email was returned. Use a new key for a deliberate resend.
400 to.0: must be a valid email address
Recipients must be bare addresses. Move display names out of to, cc and bcc — only from accepts Name <address>.
404 Email not found for an id from the list
You passed the numeric id. Every email endpoint takes the publicId (em_…).
413 with an HTML body
The request is over 36 MB, usually because of attachments. Keep attachments under 25 MB decoded, or send a link instead of the file.