Skip to content

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.',
});

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.

POSThttps://api.poststack.dev/emails
Request
import { 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');
HTTP/1.1 202 Accepted

{
  "id": "em_7f3kq9x2m4p8r1v6t0w5y2za",
  "spam_score": 1.2
}

Headers

Idempotency-Keystring
Used as idempotency_key when 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.com or Your Name <you@yourdomain.com>. The domain must be added to your team and verified, otherwise the send is refused with 422.
tostring[]required
1–50 recipient addresses. Bare addresses only — Name <a@b.com> fails validation here. to, cc and bcc together 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, text or both unless you send a template_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 replace subject, html and text from the request.
variablesobject
Values for the template's {{name}} tags. Every value must be a string — a number or boolean fails validation with 400.
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_type and content_id (for an inline cid: 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 Z suffix, e.g. 2026-10-01T09:00:00Z. An offset such as +02:00 fails 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.
unsubscribebooleandefault false
Adds RFC 8058 one-click List-Unsubscribe headers. See Unsubscribe headers and links.
signature"auto" | "none"default "auto"
auto appends the stored signature of the mailbox that from belongs to, if it has one; a {{signature}} tag in the body places it there instead. none never 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: id is that email and nothing was sent this time. The response also carries X-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

StatusMeaning
202Accepted and queued (or scheduled). Also returned for an idempotent replay.
400Body 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.
401The API key or access token is invalid, revoked or expired, or a GET had no Authorization header.
403insufficient_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).
403Also: 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).
404Template not found — template_id is not one of your templates.
413The request body is over 36 MB. The proxy answers with an HTML page, not JSON.
422The 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.
429Branch 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).
500Unexpected 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.

POSThttps://api.poststack.dev/emails/batch
Request
const { 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);
});
HTTP/1.1 202 Accepted

{
  "data": [
    { "id": "em_2b8xw4n0c7q5k1m9d3r6t8ya" },
    { "error": "All recipients are suppressed" }
  ]
}

Headers

Idempotency-Keystring
One key for the whole batch: element n without its own idempotency_key gets <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 400 and 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, 422 or 429.
data[].codestring
Present on a refused element when the refusal has a specific code — the quota codes monthly_limit_exceeded, daily_limit_exceeded and send_limit_exceeded. Retrying that element will not help.

Status codes

StatusMeaning
202The batch was processed. Some elements may still carry error.
400Validation failed — no emails, more than 100, or an invalid element (emails.3.to.0: must be a valid email address). Nothing was sent.
401The API key or access token is invalid, revoked or expired, or a GET had no Authorization header.
403insufficient_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).
413The request body is over 36 MB.
42910 batch requests per minute per key exceeded. Check Retry-After.
500Unexpected 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.

GEThttps://api.poststack.dev/emails
Request
const { 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);
{
  "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

pageintegerdefault 1
Page number, 1 or higher.
per_pageintegerdefault 20
1–100. A higher value is rejected with 400.
statusstring
queued, sending, deferred, delivered, bounced, complained or failed. Anything else is a 400.
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 with 400.
date_fromstring (ISO 8601)
Created at or after this moment. A date (2026-09-01) or a full timestamp; anything unparseable is a 400.
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, fastmail or other.
countrystring
Emails with at least one open or click from this two-letter country code (DK).
devicestring
Emails opened or clicked on a desktop, mobile or tablet.

Response fields

data[]object[]
Email objects with every field listed under Retrieve an email except htmlBody, textBody, attachments and events.
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

StatusMeaning
200The page of emails (possibly empty).
400An invalid filter: unknown status, unparseable date, per_page over 100, non-numeric domain_id.
401The API key or access token is invalid, revoked or expired, or a GET had no Authorization header.
403insufficient_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).
500Unexpected server error. Safe to retry; the SDKs retry it for you.

Retrieve an email#

One email with its bodies, attachment metadata and events.

GEThttps://api.poststack.dev/emails/:id
Request
const email = await poststack.emails.get('em_7f3kq9x2m4p8r1v6t0w5y2za');
console.log(email.status, email.testMode);
{
  "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 or publicId in 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_to address, 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, fastmail or other.
email.unsubscribeEnabledboolean
Whether unsubscribe: true was 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 — size is the decoded size in bytes and content_type is null when you did not send one. The content is not included here: download one file with GET /emails/:id/attachments/:index, where index is 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

StatusMeaning
200The email.
401The API key or access token is invalid, revoked or expired, or a GET had no Authorization header.
403insufficient_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).
404Email not found — wrong id, another team’s email, or deleted by log retention.
500Unexpected 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.

GEThttps://api.poststack.dev/emails/:id/events
Request
// 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);
{
  "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, or reason: "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 | null
desktop, mobile or tablet.
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

StatusMeaning
200The events (at least queued for any accepted email).
401The API key or access token is invalid, revoked or expired, or a GET had no Authorization header.
403insufficient_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).
404Email not found.
500Unexpected 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.

GEThttps://api.poststack.dev/emails/:id/insights
Request
const warnings = await poststack.emails.getInsights('em_7f3kq9x2m4p8r1v6t0w5y2za');
for (const w of warnings) console.log(w.severity, w.code, w.message);
{
  "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[].codestring
missing_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

StatusMeaning
200The warnings (possibly none).
401The API key or access token is invalid, revoked or expired, or a GET had no Authorization header.
403insufficient_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).
404Email not found.
500Unexpected 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.

With a test key the email is marked delivered at once, whatever scheduled_at says — so it can be neither rescheduled nor cancelled.

Reschedule an email#

Move a scheduled email to a new time. The old delivery time is removed, so it cannot fire twice.

PATCHhttps://api.poststack.dev/emails/:id
Request
await poststack.emails.reschedule('em_7f3kq9x2m4p8r1v6t0w5y2za', '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 Z suffix. Must be in the future.

Response fields

successtrue
The email is scheduled for the new time.

Status codes

StatusMeaning
200Rescheduled.
400scheduled_at is missing or not an ISO 8601 UTC timestamp.
401The API key or access token is invalid, revoked or expired, or a GET had no Authorization header.
403insufficient_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).
404Email not found.
422Only queued emails can be rescheduled, Only future-scheduled emails can be rescheduled, or New scheduled time must be in the future.
500Unexpected 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.

POSThttps://api.poststack.dev/emails/:id/cancel
Request
await poststack.emails.cancel('em_7f3kq9x2m4p8r1v6t0w5y2za');
{
  "success": true
}

Path parameters

idstringrequired
The email's em_… id.

Response fields

successtrue
The email will not be sent.

Status codes

StatusMeaning
200Cancelled.
401The API key or access token is invalid, revoked or expired, or a GET had no Authorization header.
403insufficient_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).
404Email not found.
422Only queued emails can be cancelled or Only future-scheduled emails can be cancelled.
429More than 30 cancellations per minute per key.
500Unexpected 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.

POSThttps://api.poststack.dev/emails/preview
Request
curl -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" }
  }'
{
  "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.actionstring
no_action, greylist, add_header, rewrite_subject, soft_reject or reject. A real send is refused on soft_reject and reject.
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

StatusMeaning
200The preview.
400Validation failed, or Subject is required (either directly or via template) / Body is required (html or text).
401The API key or access token is invalid, revoked or expired, or a GET had no Authorization header.
403insufficient_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).
404Template not found.
429More than 30 previews per minute.
500Unexpected 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.

POSThttps://api.poststack.dev/emails/spam-preview
Request
curl -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."
  }'
{
  "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

StatusMeaning
200The score.
400Validation failed.
401The API key or access token is invalid, revoked or expired, or a GET had no Authorization header.
403insufficient_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).
429More than 30 requests per minute.
500Unexpected 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.

StatusMeaning
queuedAccepted and waiting for a send worker — or for its scheduled_at.
sendingA worker is building the message and handing it to the mail server.
deferredThe recipient’s server answered with a temporary 4xx (greylisting, rate limit, full mailbox). The relay keeps retrying.
deliveredThe recipient’s server accepted the message (a 250 reply).
bouncedThe recipient’s server rejected it permanently. The address may be suppressed.
complainedThe recipient marked it as spam. The address is suppressed.
failedPostStack 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.

Brand names in the sender name

A sender name that claims to be a bank or public authority, sent from a domain that is not that organisation's own, is refused with 403 and puts sending on hold for review. Contact support if you legitimately send on their behalf.

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.

typescript
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.

typescript
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.

typescript
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-Key header 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.

typescript
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.

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:

text
List-Unsubscribe: <https://poststack.dev/u/TOKEN>
List-Unsubscribe-Post: List-Unsubscribe=One-Click

When 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.

html
<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#

LimitValue
Recipients per email50 across to + cc + bcc
Emails per batch request100
Subject length998 characters
Tags10 per email, 64 characters each
Custom header value1,024 characters
Attachments10 per email
Attachment size10 MB per file, 25 MB per email (decoded)
Request body36 MB (larger bodies get a bare 413 from the proxy)
Idempotency key64 characters, honoured for your plan’s log retention
POST /emails60 requests per minute per API key (30 on the Free plan)
POST /emails/batch10 requests per minute per API key (5 on the Free plan)
POST /emails/:id/cancel30 per minute per API key (15 on the Free plan)
/emails/preview, /emails/spam-preview30 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.

Next steps

Was this page helpful?

Related

Email API overviewWhat the REST, SMTP and MCP sending surfaces offer, and how they compare.

Framework guides

Background reading