Sending
Send an email
One request, one recipient, one message. Everything that could stop the 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 required | 3–320 characters. A single recipient. There is no cc, no bcc and no array: one message, one address, one delivery record. To reach five people, send five messages. |
| 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. |
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.
}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.
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. |
| 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.
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. 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. |
| 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.