Reference
Errors
Errors return a machine-readable type and a message written for a human reading a log at 2am. Branch on the type, not on the status code — four different things return 429 and they need four different responses.
The envelope
{
"error": {
"type": "invalid_request",
"message": "The request body is not valid",
"fields": [
{ "path": "to", "message": "must not contain line breaks" }
]
}
}| Field | Notes |
|---|---|
| error.type | Stable, machine-readable, snake_case. Always present on a handled error. New types may be added, so treat an unrecognised one as transient rather than crashing. |
| error.message | Human-readable, and safe to log. Do not parse it and do not branch on it — the wording can change; the type will not. |
| error.fields[] | Only on some 400s. Present when a whole-body schema failed validation, with a path and message per problem. Several endpoints return a single message instead, so never assume the array exists. |
| Extra keys | Some errors carry their own numbers alongside the message — limit, used, sentToday, sentThisMonth, messageCount, retryAfterSeconds. |
An unhandled 500 does not use this envelope. There is no catch-all error handler, so a genuinely unexpected server-side failure surfaces as the framework’s own JSON — { statusCode, error, message } — with no error.type at all, and a proxy-level failure may not be JSON in the first place.
So: parse defensively. Read the status code first, attempt the envelope second, and fall back to logging the raw body. A client that does body.error.type unconditionally will throw a TypeError during exactly the incident you most want your logs to be readable in.
Every type
Types marked session only in the cause column are reachable from the dashboard and never from an API key.
| Type | Status | Cause | Retry? |
|---|---|---|---|
| unauthorized | 401 | Key missing, malformed, unknown, wrong secret, environment mismatched, revoked, expired, or the account suspended. Identical in every case, deliberately. | No. Check the credential. |
| unauthenticated | 401 | A dashboard-session-only endpoint was called without a session. | No. |
| forbidden | 403 | Authenticated, but the credential lacks the scope the endpoint requires. The message names the scope. | No. Mint a key with the right scope. |
| csrf_failed | 403 | Cookie-authenticated mutation without a matching CSRF header. Browsers only. | No. |
| email_unverified | 403 | Creating an API key from a session whose user has not confirmed their email address. | No. Confirm the address first. |
| invalid_request | 400 | Body or query failed validation. Often carries fields[] naming each problem; some endpoints return a single message instead. | No. Fix the request. |
| not_found | 404 | No such object, an id of the wrong kind, or an object belonging to another account — invisible rather than forbidden, so an id cannot be probed for existence. | No. |
| conflict | 409 | That domain is already on this account. | No. Read it from the list instead. |
| domain_in_use | 409 | Deleting a domain that has messages on record. Carries messageCount. Removing it would destroy delivery history. | No. Stop sending from it instead. |
| address_taken | 409 | That inbound address is already receiving mail somewhere on the platform. The address index is global. | No. |
| label_exists | 409 | A mailbox label with that name already exists on the account. Names are unique per account, case-insensitively — two spellings of one label is a filing system that quietly splits in half. | No. Use the existing label, or pick another name. |
| invalid_policy | 422 | The MTA-STS policy is well-formed and would be harmful: enforcing with no MX host tells every sender to deliver nowhere, and max_age outside a day-to-a-year window is either constant re-fetching or a mistake that outlives most incidents. | No. Fix the policy. |
| session_required | 403 | A draft belongs to a person, and an API key is the account acting rather than anybody in particular — so there is no author to attribute one to. Sign in instead. | No. Use a signed-in session for drafts. |
| attachment_infected | 403 | The attachment matched a virus signature when it arrived. It is stored and listed on the message so you know it came, and carries the signature name, but it will not be served — handing it over would make us the delivery mechanism for the thing we detected. | No. |
| route_exists | 409 | That address or domain already has an inbound routing rule. One rule per target — a second is an ambiguity, not a fallback, and one resolved by insertion order is one nobody can predict. | No. Update the existing rule instead. |
| webhook_not_found | 404 | An inbound routing rule named a webhook endpoint that does not exist on this account. 404 rather than 403 deliberately: whether somebody else’s endpoint exists is not information this caller is entitled to. | No. Create the endpoint, or omit webhookId to reach every subscribed one. |
| already_subscribed | 409 | Checkout for the plan the account is already on. Session only. | No. |
| invalid_address | 422 | A sender or recipient address is not usable, or has no domain part. | No. |
| domain_not_found | 422 · 400 | The domain has not been added to this account. 422 when sending, 400 when creating an inbound address. | No. Add and verify the domain. |
| domain_not_verified | 422 · 400 | The domain exists but its DKIM record has not been verified. Same status split as above. | Only after verification passes. |
| domain_limit_reached | 422 | The plan includes fewer domains than requested. Carries limit and used. | No. Upgrade. |
| suppressed | 422 | The recipient is on the suppression list. Carries address and reason — one of hard_bounce, complaint, spam_trap or manual. | No. A complaint or spam_trap is permanent. A hard_bounce or manual entry can be cleared from the suppressions page once you know it is stale. |
| fanout_too_large | 422 | Recipients times attachment bytes is more than this platform writes in one transaction. Carries recipients, attachmentBytes and limitBytes. | No. Send to fewer recipients at a time, or attach less. |
| attachments_too_many | 422 | More than 10 files on one message. Carries limit and count. | No. Split across more than one message. |
| attachments_too_large | 422 | More than 10 MiB combined, once decoded. Carries limitBytes and totalBytes. | No. Send fewer or smaller files. |
| attachment_type_blocked | 422 | An executable file, by its final extension or its content type — the set the big mailbox providers bounce at the door anyway. Carries filename. | No. Zip it, or send a link instead. |
| attachment_invalid | 422 | Structurally unusable — a malformed contentType, or disposition: inline with no cid. Carries filename. | No. Fix the attachment and resend. |
| content_blocked | 422 | The pre-send lint found something broken in the content itself: a link whose scheme executes rather than navigates (javascript:, data:, vbscript:, file:), or a message that renders to nothing at all. Carries check, naming which one, and findings — the whole report, including the warnings that did not refuse it. | No. Fix the body and resend; the same bytes get the same answer. |
| unknown_stream | 422 | The stream you named does not exist on this account. Refused rather than quietly sent on the default stream: naming a stream is how you choose which suppression list applies, and silently using a different one is how a complaint on one stream stops protecting anybody. | No. Fix the slug, or create the stream first. |
| bulk_send_refused | 422 | Too many different recipients got substantially the same message too quickly — the shape of a bulk send rather than transactional mail. Carries recipients and windowMinutes, so you can see the number you would have to stay under. | No, not as-is. Spread the send out, or talk to us about what you are building. |
| account_suspended | 403 | The account has been suspended. Sustained complaint or bounce rates well above what receivers accept suspend an account automatically, and an operator can suspend one by hand. Not a limit: there is no number to stay under and nothing lifts at midnight. | No. Get in touch — only a person can lift it, and the mail will not send until they do. |
| batch_too_large | 422 | More messages, or more expanded recipients, than one batch may carry. Carries the counts and the ceiling. | No. Split it across more calls. |
| unknown_template | 422 | No template with that id (or that version) on this account. A version that was never published counts as unknown. | No. Publish the version, or send a different one. |
| invalid_template | 422 | The template exists but cannot be rendered as asked — usually a declared variable that was not supplied. Nothing is sent rather than a blank appearing in somebody's inbox. | No. Supply the missing variables and retry. |
| template_in_use | 409 | Messages have been sent from this template. Deleting it would erase what those messages contained, and a delivery record you cannot read is not a record. | No. It stays for as long as the messages it explains. |
| slug_taken | 409 | This account already has a stream or template with that slug. | No. Choose another. |
| reserved_slug | 409 | That slug is the stream every account starts with and cannot be created again. | No. Choose another. |
| nested_sub_account | 409 | A sub-account cannot have sub-accounts of its own. The hierarchy is deliberately one level deep. | No. |
| sub_account_limit_reached | 403 | Your plan includes a fixed number of sub-accounts and they are all used. Carries limit and used. | No. Upgrade, or reuse one. |
| billed_to_parent | 409 | This is a sub-account; its plan is set by the account that pays for it. | No. Change the plan on the parent and it applies here. |
| seat_limit_reached | 403 | Your plan includes a fixed number of seats and they are all used. Carries limit and used. | No. Upgrade, or remove somebody first. |
| webhook_limit_reached | 403 | Your plan includes a fixed number of webhook endpoints and they are all in use. Carries limit and used. | No. Delete an endpoint you no longer listen to, or upgrade. |
| api_key_limit_reached | 403 | Your plan includes a fixed number of API keys and they are all in use. Revoked keys do not count, so a rotation never costs you a slot. Carries limit and used. | No. Revoke a key you no longer need, or upgrade. |
| contact_limit_reached | 403 | Your plan includes a fixed number of contacts and they are all used. Carries limit and used. An import is not refused: rows past the limit are counted in overLimit instead. | No. Upgrade, or delete contacts you no longer mail. |
| list_limit_reached | 403 | Your plan includes a fixed number of contact lists and they are all used. Carries limit and used. | No. Upgrade, or delete a list you no longer use. |
| contact_exists | 409 | That address is already in your contacts. Carries the existing contact’s id; use PATCH to change it, or import to merge. | No. Update the existing contact instead. |
| name_taken | 409 | You already have a contact list with that name. Names are compared case-insensitively. | No. Choose another name. |
| transactional_stream | 422 | A broadcast named the transactional stream. Announcements must not share its suppression list, or one complaint would stop somebody’s password resets. | No. Use announcements, or another stream you created. |
| too_many_drafts | 409 | The account holds 50 unsent broadcast drafts. | No. Send or delete a draft first. |
| not_a_draft | 409 | Only a draft broadcast can be edited or sent. This one is already scheduled, sending, sent or canceled. | No. Cancel it and create another if it needs to change. |
| broadcast_has_history | 409 | The broadcast sent mail, so its record is kept. | No. Cancel it instead if it is still running. |
| list_missing | 422 | The broadcast’s list was deleted. | No. Edit the draft to name another list. |
| list_empty | 422 | The broadcast’s list has nobody on it. | No. Add contacts to the list, then send. |
| template_unpublished | 422 | The broadcast uses a template with no published version. | No. Publish the template, then send. |
| not_running | 409 | Only a scheduled or sending broadcast can be paused. | No. |
| not_paused | 409 | Only a paused broadcast can be resumed. | No. |
| quality_pause | 409 | The broadcast was stopped because too many recipients bounced or complained, and it cannot be resumed. | No. Cancel it, clean the list, and send a new broadcast — or write to support. |
| finished | 409 | The broadcast is already sent or canceled. | No. |
| not_a_team_member | 422 | A test send named an address that is not a person on this account’s team. Carries addresses. | No. Test sends go to your own team only. |
| template_limit_reached | 403 | Your plan includes a fixed number of stored templates and they are all used. Carries limit and used. | No. Upgrade, or delete a template you no longer send. |
| stream_limit_reached | 403 | Your plan includes a fixed number of message streams and they are all used. The count includes the transactional stream every account starts with, so a plan allowing one stream allows no additional ones. Carries limit and used. | No. Upgrade to add more. |
| inbound_address_limit_reached | 403 | Your plan includes a fixed number of inbound addresses, counted across every domain on the account, and they are all claimed. Carries limit and used. | No. Upgrade, or remove an address you no longer receive on. |
| analytics_window_exceeded | 403 | The delivery analytics window you asked for is longer than your plan allows. Carries limit (the days your plan permits) and used (the days you asked for). Refused rather than quietly shortened, so a figure is never labelled with a window it does not cover. | No. Ask for a shorter window, or upgrade. |
| already_a_member | 409 | That address is already on this account. | No. They are already a member — check the team list. |
| address_unavailable | 409 | That address cannot be invited to this account. Deliberately says no more than that: a message naming why would let anyone who can invite test an arbitrary address for the existence of a Posthaste account. | No. Invite a different address, or ask them which address to use. |
| last_owner | 409 | An account must always have an owner, so the last one cannot be removed or demoted. | No. Promote somebody else to owner first. |
| not_deletable | 409 | This account cannot be deleted — usually because a deletion is already scheduled, or the account is not in a state where erasure is meaningful. | No. Check the deletion status first. |
| weak_password | 400 | The password is too short or too easily guessed. The rule is enforced on the server, so a client that skips its own check still gets this. | No. Choose a stronger one. |
| invalid_token | 400 | A single-use link — an invitation, a password reset, an email verification — that has already been used, has expired, or was never ours. | No. Ask for a new one. |
| invalid_code | 400 | A verification or two-factor code that does not match. Codes are burned after a handful of wrong guesses, so a live code cannot be brute-forced. | No, not with the same code. |
| invalid_credentials | 401 | The email address and password do not match. Answered identically whether or not the address exists, so this cannot be used to discover who has an account. | No. |
| totp_required | 401 | The account has two-factor authentication enabled and the request did not carry a code. | Yes, with the code. |
| no_password | 409 | This account signs in another way (Google, or a passkey) and has no password to check. | No. Use the sign-in method the account was created with. |
| account_locked | 429 | Too many failed attempts. Locked briefly, which is what stops an unlimited guessing run. | Yes, after a few minutes. |
| unsupported | 409 | The action is not available for this payment method — pausing, for instance, is not offered on every rail. | No. The message names what to do instead. |
| internal_error | 500 | Something failed on our side. It is logged with a request id; nothing about your request is wrong. | Yes, and tell us if it persists. |
| schedule_too_far | 422 | scheduledAt is further out than your plan allows — each plan has its own scheduling horizon, bounded platform-wide. Carries maxDays (the limit that actually applied) and the parsed scheduledAt. | No. Choose a nearer time, or a bigger plan. |
| not_scheduled | 422 | Canceling a scheduled send that already released, or an id that was never scheduled at all. Carries status, the message’s current one. | No. It already sent — or never was scheduled. |
| suppression_hard_bounce | 422 | Deleting a hard_bounce suppression. The receiving server said permanently that the mailbox does not exist, which is a fact about the address rather than about your mail. | No. Contact support if the bounce is genuinely stale — an operator can remove it after review, and the reason is recorded. |
| suppression_protected | 422 | Deleting a complaint or spam_trap suppression. These can never be removed. | Never. |
| suppression_platform | 422 | Deleting a platform-wide suppression. Not removable by an account. | No. Contact support. |
| token_required | 400 | No Cloudflare token supplied and none stored for the account. | No. |
| cloudflare_token_invalid | 400 | Cloudflare rejected the token. | No. |
| cloudflare_zone_not_found | 400 | The token is valid but does not cover this zone. | No. |
| cloudflare_write_failed | 400 | The DNS write was refused — usually the token lacks Zone:DNS:Edit. | No. |
| rate_limited | 429 | Too many requests for this API key. Carries retryAfterSeconds, plus retry-after and x-ratelimit-* headers. | Yes, after the stated interval. |
| too_many_streams | 429 | Too many live tails open at once on this account. Carries limit. Not a throttle — closing one frees a slot immediately. | Only after closing an open tail. |
| daily_limit_reached | 429 | The account daily sending cap. Carries limit and sentToday. Retry-After is midnight UTC. | Yes, tomorrow. |
| monthly_limit_reached | 429 | The plan monthly allowance. Carries limit and sentThisMonth. Retry-After is the start of next month. | Yes, next month — or upgrade now. |
| platform_paused | 429 | The PLATFORM-wide daily send ceiling, not this account’s. Carries no limit or count, because none of them are the reason. Retry-After is 300 seconds. | Yes, in a few minutes. Throttling, not exhausted quota. |
| not_configured | 503 | Billing is not configured on this deployment. Session only. | No. |
| provider_error | 502 | The payment provider failed or refused. Session only. | Yes, with backoff. |
| internal | 500 | A handled server-side failure that still carries the envelope. | Yes, with backoff. |
| logo_too_large | 413 | A brand logo above 512 KB. Carries limit. A logo needing more than that is not a logo. | No. Send a smaller image. |
| unsupported_logo | 415 | A brand logo that is not a PNG, JPEG or WebP — determined by reading the bytes, not by the type you declare. SVG is refused specifically: it can carry script, and the file is served from our domain to your recipients. | No. Convert it to a raster format. |
| validation_limit_reached | 429 | The plan’s monthly address-validation allowance. Carries limit and used. A batch that would cross the ceiling is refused WHOLE rather than served partly — a half-checked list is worse than an unchecked one, because you cannot tell which half. Addresses that came back unknown are never counted. | Yes, next month — or upgrade now. |
| verification_limit_reached | 429 | The plan’s monthly VERIFICATION allowance, which is a separate meter from the email allowance — a verification spends both. Carries limit and used. Distinct from monthly_limit_reached precisely so it is never ambiguous which ceiling you met. | Yes, next month — or upgrade now. |
| destination_locked | 429 | Three verifications to this address ran out of attempts within the hour, so it stops accepting new codes. Different from destination_throttled: that one means you have sent enough codes for now, this means the last three were GUESSED at until they burned. It is what makes a short code length survivable, and it is worth looking at the account rather than the retry logic. | Yes, after Retry-After — but read it first. |
| destination_throttled | 429 | Too many verification codes have been started for this address recently. Carries retryAfter. This is the limit that bounds a brute force: attempts cap guesses within one code, this caps how many codes can be opened at all. | Yes, after Retry-After. |
| resend_too_soon | 429 | A verification code was sent moments ago and the cooldown has not passed. Carries retryAfter. | Yes, after Retry-After. |
| resend_limit_reached | 409 | This verification has been sent the maximum number of times. Carries sends and maxSends. Not a throttle — waiting does not help. | No. Start a new verification. |
| verification_max_attempts | 409 | Too many incorrect codes were submitted, so the verification is closed. The correct code no longer works either — the cap burns the code rather than merely counting. Carries attempts and maxAttempts. A wrong code on a LIVE verification is not this: that is a 200 with status pending. | No. Start a new verification. |
| verification_expired | 409 | The code passed its expiry. Resending does not extend it — a resend mails a fresh code on the original deadline. | No. Start a new verification. |
| verification_already_used | 409 | The code was already approved. A code works once, deliberately. | No. Start a new verification. |
| verification_canceled | 409 | The verification was cancelled, by you. | No. Start a new verification. |
| verification_not_pending | 409 | Cancel found the verification already approved, expired or out of attempts — it was used, so there was nothing to call off. Cancelling one that is already cancelled is NOT this: that answers 200, because the state you asked for already holds. | No. |
| verification_not_resendable | 409 | The message the first code went out on is no longer available, so a resend has no sender address to reuse. Resends deliberately take the sender from that message rather than from the request, so a second code can never arrive under a different name than the first. | No. Start a new verification. |
One value that looks like an error and is not: status: "duplicate" on a 200 from POST /v1/emails. It means your idempotencyKey had already been used and the original message id is being returned. That is success.
A handler worth copying
Three buckets: fix the request, wait and retry, retry with backoff. Everything above sorts into one of them.
import { Posthaste, isPosthasteError } from '@posthaste/sdk'
const posthaste = new Posthaste({ apiKey: process.env.POSTHASTE_KEY })
async function send(body) {
try {
// Resolves for BOTH successes: 202 queued and 200 duplicate.
// result.duplicate tells you which one it was.
return await posthaste.emails.send(body)
} catch (err) {
if (!isPosthasteError(err)) throw err
switch (err.type) {
// Fix the request. Retrying changes nothing.
case 'invalid_request':
case 'invalid_address':
case 'domain_not_found':
case 'domain_not_verified':
case 'suppressed':
case 'attachments_too_many':
case 'attachments_too_large':
case 'attachment_type_blocked':
case 'attachment_invalid':
case 'content_blocked':
case 'schedule_too_far':
case 'not_scheduled':
case 'forbidden':
throw new PermanentError(err.type, err.message, err.fields ?? [])
// Transient throttling, measured in seconds. platform_paused belongs
// HERE, not below: it says the platform's own daily total is full, not
// that this account has spent anything. err.isRateLimited covers both.
case 'rate_limited':
case 'platform_paused':
throw new RetryAfter(err.retryAfterSeconds ?? 60)
// Exhausted quota — hours or days, not seconds. Do not sit on it.
case 'daily_limit_reached':
case 'monthly_limit_reached':
throw new QuotaExhausted(err.retryAfterSeconds)
// Includes 'unknown_error' (an unhandled 500, which does NOT use the
// envelope), 'connection_error' and 'timeout' — both status 0.
default:
throw new TransientError(err.status, err.message)
}
}
}The SDK raises a typed PosthasteError for every failure — including the ones that never reached a server — so the defensive parsing in the second tab is already done, and the retry buckets are the only thing left to write. It also retries the transient bucket itself, and deliberately does not retry the quota one.
Pair retries with an idempotencyKey. A timeout or a 502 tells you nothing about whether the message was accepted — the failure may have happened after we committed it. With a key, retrying is free; without one, it sends twice. See idempotency.
NextPagination →Keyset cursors, and the one condition a paging loop must terminate on.