Sending
Send an email
One request, one message per recipient. Everything that could stop a message going out is checked before it is accepted, so a 202 means it is really on its way.
Request
/v1/emailsemails:send| Field | Type | Notes |
|---|---|---|
| from | string required | 3–320 characters. Must be on a verified domain on this account. A display name is allowed — Acme Billing <[email protected]> — and is what the recipient reads; everything we check (ownership, DKIM alignment) uses the bare address. |
| to | string or string[] required | One address, or an array. "x" and ["x"] are the same request. Up to 50 distinct recipients across to, cc and bcc together. |
| cc | string[] | Named on every other copy. Counts as a send, like any recipient — see below. |
| bcc | string[] | Delivered, and named nowhere. Their copy carries no Bcc header and lists no other recipient. Counts as a send. |
| subject | string optional | Up to 998 characters. Rejected if it contains CR or LF. |
| text | string optional | Plain-text body, up to 5,000,000 characters. At least one of text or html is required. Sending both produces a multipart message, which is what most mail clients prefer. |
| html | string optional | HTML body, up to 5,000,000 characters. Stored and sent as you wrote it — this is your own content, and rewriting it would misrepresent what the recipient saw. |
| replyTo | string optional | Up to 320 characters. Sets the Reply-To header, so replies go somewhere you monitor while from stays the address you send as. Useful when from is a no-reply address. |
| headers | object optional | Extra headers as a flat string-to-string map. Names up to 200 characters, values up to 2,000. Names and values containing CR or LF are rejected. Returned verbatim on the message record, so they are useful for correlating with your own systems. |
| listUnsubscribe | string optional | Up to 1,000 characters. Sets List-Unsubscribe, and we add List-Unsubscribe-Post: List-Unsubscribe=One-Click alongside it. |
| idempotencyKey | string optional | 1–255 characters, unique per account. Retry safely — the same key never sends twice. See idempotency. |
| scheduledAt | string optional | ISO-8601 with a timezone offset. Send later instead of immediately — see Scheduling below. |
| attachments | array<object> optional | Up to 10 files, 10 MiB combined once decoded. See attachments below for the shape of each item. |
import { Posthaste } from '@posthaste/sdk'
const posthaste = new Posthaste({ apiKey: process.env.POSTHASTE_KEY })
const result = await posthaste.emails.send({
from: 'Acme Billing <[email protected]>',
to: '[email protected]',
subject: 'Invoice 2026-114',
text: 'Your invoice is attached to your account.',
html: '<p>Your invoice is attached to your account.</p>',
replyTo: '[email protected]',
headers: { 'X-Acme-Invoice': '2026-114' },
listUnsubscribe: '<https://yourdomain.com/u/abc123>, <mailto:[email protected]>',
// A BODY field, and the SDK puts it there. The Idempotency-Key header is
// never read by any endpoint.
idempotencyKey: 'invoice-2026-114',
})
result.id // 'msg_AZLm3kQ8T2Sf9pXbNc7HrQ'
result.status // 'queued' (202) | 'duplicate' (200)
result.duplicate // false — both are success; this says which happened
if (result.duplicate) {
// Nothing was sent. result.id is the message we accepted the first time.
}Multiple recipients
to takes an address or an array, and cc and bcc take arrays. Up to 50 distinct recipients across all three; the same address listed twice is one recipient, matched case-insensitively, keeping the first of to, cc, bcc.
to, two cc and one bcc counts as four against your allowance. To, Cc and Bcc are counted identically, on this API and over SMTP. This is not a surcharge: each one is a separate delivery to a separate mail server, and it is what your sending reputation is judged on.Each recipient gets its own message, with its own id, its own delivery record and its own bounce handling. That is why a bounce can name the address that bounced and suppress only that one, and why one recipient failing does not put the others in doubt.
What comes back
A single recipient answers exactly as it always has — { id, status }, nothing added. More than one, and the response names every recipient:
# 202 — four recipients, one of them suppressed
{
"id": "msg_AZLm3kQ8T2Sf9pXbNc7HrQ",
"groupId": "0198f2c1-...",
"status": "queued",
"emails": [
{ "id": "msg_AZLm3kQ8T2Sf9pXbNc7HrQ", "to": "[email protected]", "kind": "to", "status": "queued" },
{ "id": "msg_Bd7pQ2xR5Tf8mWnKc3LsPa", "to": "[email protected]", "kind": "cc", "status": "queued" },
{ "to": "[email protected]", "kind": "cc", "status": "suppressed", "reason": "hard_bounce" },
{ "id": "msg_Ce9rS4yT7Vh0oXpMd5NuQc", "to": "[email protected]", "kind": "bcc", "status": "queued" }
],
"suppressed": [
{ "to": "[email protected]", "reason": "hard_bounce" }
]
}
# Charged: 3. The suppressed recipient was not sent to and not counted.id is the first to copy and is kept so code reading { id, status } still works, but emails is the authoritative answer: every recipient you asked for appears in it exactly once.
A suppressed recipient is skipped, not fatal
If one address on a ten-person send is suppressed, the other nine go and that one is named in suppressed with the reason. It is not sent to and not charged. Failing the whole send because one address bounced last March would be worse, and quietly dropping it would be worse still.
Every other refusal — quota, an unverified domain, an invalid address, an attachment we will not carry — refuses the whole call and nothing is sent to anybody. If every recipient is suppressed, nothing was accepted, so you get the ordinary 422 suppressed rather than a 202 that sent to nobody.
Bcc is blind, structurally
A blind copy is its own message that names no other recipient. We do not write a Bcc header on anything — there is no field for one on the wire and no column for one in storage, so a Bcc address can only ever appear as the recipient of its own copy.
Retrying a multi-recipient send
With an idempotencyKey, each copy gets a key derived from that key and the recipient address — never its position in the list. Retry with the same key and the recipients in a different order and every copy still matches: the unit of idempotency is (key, recipient). Change one address and that one recipient is a new send while the rest come back as duplicates.
List-Unsubscribe is worth setting
Gmail and Yahoo have required a one-click unsubscribe header on bulk mail since 2024, and both weigh its presence when deciding where mail lands. Transactional mail is not bulk mail, but the header is still the cheapest deliverability win available on any message a recipient might reasonably want to stop receiving — notifications, digests, receipts for a subscription.
Two forms are recognised by mail clients, and supplying both is standard: <https://…>, <mailto:…>. Because we also send List-Unsubscribe-Post, the HTTPS URL must accept a POST and unsubscribe the recipient without asking them to confirm — that is what “one-click” means, and a link that lands on a confirmation page fails the requirement it was added for.
We do not act on the header ourselves. Honouring the unsubscribe is your application’s job. If you would rather we stopped sending to an address altogether, add it to the suppression list.
Attachments
content is base64 of the raw bytes, not the file’s own encoding — the request body is JSON, so there is no way to send a byte stream directly. Each item also takes a filename (up to 255 characters, no path separators) and a contentType as type/subtype with no parameters — image/png, not image/png; charset=binary.
BASE64=$(base64 -w0 invoice.pdf)
curl -X POST https://api.posthastemail.dev/v1/emails \
-H "authorization: Bearer $POSTHASTE_KEY" \
-H "content-type: application/json" \
-d "$(jq -n --arg content "$BASE64" '{
from: "Acme Billing <[email protected]>",
to: "[email protected]",
subject: "Invoice 2026-114",
html: "<p>Your invoice is attached.</p>",
attachments: [{
filename: "invoice.pdf",
contentType: "application/pdf",
content: $content
}]
}')"
# 202 {"id":"msg_AZLm3kQ8T2Sf9pXbNc7HrQ","status":"queued"}An inline image is the same shape with two extra fields: disposition: 'inline' and a cid, which the html then references as cid:logo rather than a URL. cid is required whenever disposition is inline — omitting it is an attachment_invalid refusal, not a silently-dropped image.
LOGO=$(base64 -w0 logo.png)
curl -X POST https://api.posthastemail.dev/v1/emails \
-H "authorization: Bearer $POSTHASTE_KEY" \
-H "content-type: application/json" \
-d "$(jq -n --arg content "$LOGO" '{
from: "Acme Billing <[email protected]>",
to: "[email protected]",
subject: "Welcome",
html: "<p>Hello <img src=\"cid:logo\"></p>",
attachments: [{
filename: "logo.png",
contentType: "image/png",
content: $content,
disposition: "inline",
cid: "logo"
}]
}')"
# 202 {"id":"msg_AZLm3kQ8T2Sf9pXbNc7HrQ","status":"queued"}Up to 10 files, 10 MiB combined once decoded. Executable file types are refused outright — the same set Gmail bounces at the door anyway (.exe, .js, .bat, .vbs, .apk, .iso, .msi and the rest of that list), checked against the final extension — invoice.pdf.exe is blocked, invoice.exe.gz is not. We do not open archives to look inside them, so a .zip containing an .exe passes here and will most likely bounce at the recipient’s mailbox provider instead.
Attachments are encrypted at rest the same way the body is, covered by the DKIM signature like the rest of the message, and purged along with the body once your plan’s 30-day retention window closes. Read them back on the message record.
Scheduling a send
Add scheduledAt to send later instead of immediately. It has to be ISO-8601 with a timezone offset — 2026-08-25T09:00:00-05:00, not "tomorrow 9am". Natural language is ambiguous about whose timezone and whose locale that is; an offset is not, and it is what Date.toISOString() already produces in every SDK we know of, so there is nothing extra to compute on your side.
Two horizon rules, both enforced before anything is written. A timestamp within 60 seconds of now collapses to an ordinary immediate send — refusing something that just slipped into the past would punish clock skew, not intent. Beyond that, how far ahead you can go depends on your plan:
| Plan | Schedule up to |
|---|---|
| Free | 1 day ahead |
| Starter | 3 days ahead |
| Growth | 7 days ahead |
| Scale | 14 days ahead |
Enterprise negotiates its own ceiling the same way it negotiates everything else — see pricing. Twenty-one days is also the platform-wide maximum, and it is not arbitrary: message content is retained for 30 days, and a schedule must never outlive its own body. Going past your plan’s ceiling returns 422 schedule_too_far carrying maxDays — the limit that actually applied to you — and the scheduledAt you sent.
const result = await posthaste.emails.send({
from: 'Acme Billing <[email protected]>',
to: '[email protected]',
subject: 'Renewal reminder',
text: 'Your plan renews in three days.',
// ISO-8601 WITH an offset — not "tomorrow 9am".
scheduledAt: '2026-08-25T09:00:00-05:00',
})
result.status // 'scheduled'
result.scheduledAt // '2026-08-25T14:00:00.000Z' — normalised to UTCQuota is decided on the day it sends, not the day you schedule it
Accepting a scheduled message books nothing against today: no delivery job is created and no quota is consumed, because none of that is decided yet. Everything that could stop the message — suppression, domain verification, your daily and monthly allowance — is re-checked in full when the message actually releases, on its own day. An address that lands on the suppression list between scheduling and sending is still never mailed: the message ends up canceled, exactly as if you had called it off yourself. See messages and the waybill for the full status list and how the chain reads for a scheduled send.
The one check that runs early is the monthly allowance, as advice rather than the final word — if your month is already spent, you hear that now rather than three days from now. The authoritative check still runs at release.
If quota only runs out because the platform’s own daily ceiling is briefly full, the message is not failed outright — it stays scheduled and is retried on the next pass, for up to 24 hours past its due time, before giving up honestly with schedule_failed.
Cancel a scheduled send
/v1/emails/:id/scheduleemails:sendWorks any time before the message releases to delivery. Calling it again after a successful cancel returns the same 200 — a retried cancel is a success, the same idea as a retried send. Calling it after the message has already released returns 422 not_scheduled, naming the message’s current status.
await posthaste.emails.cancelSchedule('msg_AZLm3kQ8T2Sf9pXbNc7HrQ')
// { id, status: 'canceled' } — a retry after a lost response returns the same thing again| Status | When |
|---|---|
| 200 | { id, status: "canceled" }. Also what a retried cancel returns. |
| 404 not_found | No such message, or another account’s message. |
| 422 not_scheduled | The message already released, or was never scheduled at all. The body carries its current status. |
An idempotency key follows the message, not the send. Cancel a scheduled message and the idempotencyKey you sent it with is still attached to that (now canceled) message — retrying the same key returns duplicate with the canceled message’s id, not a fresh send. To genuinely resend after a cancel, use a new key.
Response
| Status | When |
|---|---|
| 202 Accepted | { id, status: "queued" }. The message, its content, its delivery job and its first event were committed in one transaction. It is durably stored and on the queue — not delivered. A scheduled send returns the same 202 with status: "scheduled" and scheduledAt echoed back instead — see Scheduling above; nothing is queued for delivery until it releases. |
| 200 OK | { id, status: "duplicate" }. This idempotencyKey has been used before; the id is the original message. This is success, not an error — see idempotency. |
Treat 200 and 202 the same way other than for logging. A client that only accepts 202 will treat its own successful retry as a failure and retry again, which is the exact loop idempotency exists to break.
The SDK resolves both and returns { id, status, duplicate }, so the distinction is available without reading the HTTP status — result.duplicate is true only for the replay.
Pre-send lint
Every message is looked at before it is signed. Reputation is shared — all of our customers’ mail leaves from one IP — so content that reads as spam to Gmail costs everybody else placement. That is why the check exists. It is not a reason to refuse mail on a hunch, and almost nothing here does.
Two checks can refuse a send, and both are about content that is broken rather than content that is suspicious: an href whose scheme executes instead of navigating (javascript:, data:, vbscript:, file:), and a message that renders to nothing at all — no text, no visible HTML, no image, no link, no attachment. Both come back as 422 content_blocked with a check naming which one.
Everything else warns and sends. Missing List-Unsubscribe on mail sent on a stream of its own, images with almost no words beside them, a link shortener, a link nothing can resolve, HTML past Gmail’s 102 KB clip point, HTML with no plain-text part. They arrive as warnings[] on the 202, one array for the whole call however many recipients it had, and the field is absent entirely when there is nothing to say.
The asymmetry is deliberate: refusing a legitimate send is worse than delivering a mediocre one, so no heuristic ever blocks. A warning is information, not a threat — we do not throttle, penalise or refuse a later send because of one.
A template preview runs the same checks on the same rendered bytes, so you can see all of this before anything is sent.
# 202 — accepted, with what we noticed about it
{
"id": "msg_AZLm3kQ8T2Sf9pXbNc7HrQ",
"status": "queued",
"warnings": [
{
"check": "missing_text_part",
"severity": "warn",
"message": "HTML with no plain-text part. Add `text` alongside `html` — filters and non-graphical clients read it, and mail without one is a common bulk-sender signal."
},
{
"check": "html_too_large",
"severity": "warn",
"message": "The HTML is 140 KB. Gmail clips a message past 100 KB and hides the rest behind "View entire message", which takes the unsubscribe footer with it.",
"bytes": 143360,
"limitBytes": 102400
}
]
}
# 422 — refused on the content itself
{
"error": {
"type": "content_blocked",
"check": "dangerous_link",
"message": "A link in this message uses the "javascript:" scheme. No mail client will follow it, and we will not sign a message that carries one — use an https:// link.",
"href": "javascript:steal()",
"scheme": "javascript",
"findings": [ /* the whole report, warnings included */ ]
}
}Refusals
The cheap structural checks run before any query, suppression is checked before anything is written, and the sending cap is checked last — so a message refused for any other reason never consumes quota.
import { isPosthasteError } from '@posthaste/sdk'
try {
await posthaste.emails.send({ from, to, text, idempotencyKey })
} catch (err) {
if (!isPosthasteError(err)) throw err
err.status // 422
err.type // 'domain_not_verified'
err.message // 'yourdomain.com is not verified. Publish its DNS records…'
// fields[] is present on SOME 400s and absent on others — the SDK leaves it
// undefined rather than inventing an empty array, so always check.
for (const field of err.fields ?? []) {
console.error(field.path || '(whole body)', field.message)
}
}| Status | When |
|---|---|
| 400 invalid_request | The body failed validation — including a malformed content field on an attachment, which is not valid base64. error.fields[] names each problem with a path and a message. Fix the request; retrying is pointless. |
| 403 forbidden | The key lacks emails:send. |
| 422 invalid_address | One of the addresses is not usable, or is missing a domain part. |
| 422 domain_not_found | The domain in from has not been added to this account. |
| 422 domain_not_verified | The domain exists but its DKIM record has not been verified. Publish and verify it. |
| 422 suppressed | The recipient is on the suppression list. The message names the reason. Do not retry — remove the address from your list. |
| 422 attachments_too_many | More than 10 files on one message. Body carries limit and count. |
| 422 attachments_too_large | More than 10 MiB combined once decoded. Body carries limitBytes and totalBytes. |
| 422 attachment_type_blocked | An executable file, by extension or content type — see attachments above. Body carries filename. |
| 422 attachment_invalid | Structurally unusable — a malformed contentType, or disposition: 'inline' with no cid. Body carries filename. |
| 422 schedule_too_far | scheduledAt is further out than your plan allows — see Scheduling. Body carries maxDays (the limit that applied) and scheduledAt. |
| 429 daily_limit_reached | Your account’s cap for today. Body carries limit and sentToday; Retry-After points at midnight UTC. |
| 429 monthly_limit_reached | Your plan’s monthly allowance. Body carries limit and sentThisMonth; Retry-After points at the start of next month. Sending limits → |
Nothing over a cap is queued and delivered later. A refused message was never accepted, is never billed, and never appears in your usage figures.
Header injection is rejected, not sanitised
A carriage return or newline anywhere in an address, subject, custom header name, custom header value or listUnsubscribe returns 400. It is checked twice — once at the API boundary and again in the composer — so a path that skipped the first check still cannot produce a malformed message.
Silently stripping the characters instead would be worse than refusing: a crafted subject could append a Bcc: header that nobody, including you, would ever see in the record.
NextMessages and the waybill →Read back a message, its status, its events and the SMTP conversation.