PosthasteDocsGet an API key

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" }
    ]
  }
}
FieldNotes
error.typeStable, 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.messageHuman-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 keysSome 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.

TypeStatusCauseRetry?
unauthorized401Key missing, malformed, unknown, wrong secret, environment mismatched, revoked, expired, or the account suspended. Identical in every case, deliberately.No. Check the credential.
unauthenticated401A dashboard-session-only endpoint was called without a session.No.
forbidden403Authenticated, but the credential lacks the scope the endpoint requires. The message names the scope.No. Mint a key with the right scope.
csrf_failed403Cookie-authenticated mutation without a matching CSRF header. Browsers only.No.
email_unverified403Creating an API key from a session whose user has not confirmed their email address.No. Confirm the address first.
invalid_request400Body or query failed validation. Often carries fields[] naming each problem; some endpoints return a single message instead.No. Fix the request.
not_found404No 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.
conflict409That domain is already on this account.No. Read it from the list instead.
domain_in_use409Deleting a domain that has messages on record. Carries messageCount. Removing it would destroy delivery history.No. Stop sending from it instead.
address_taken409That inbound address is already receiving mail somewhere on the platform. The address index is global.No.
label_exists409A 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_policy422The 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_required403A 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_infected403The 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_exists409That 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_found404An 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_subscribed409Checkout for the plan the account is already on. Session only.No.
invalid_address422A sender or recipient address is not usable, or has no domain part.No.
domain_not_found422 · 400The 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_verified422 · 400The domain exists but its DKIM record has not been verified. Same status split as above.Only after verification passes.
domain_limit_reached422The plan includes fewer domains than requested. Carries limit and used.No. Upgrade.
suppressed422The 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_large422Recipients 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_many422More than 10 files on one message. Carries limit and count.No. Split across more than one message.
attachments_too_large422More than 10 MiB combined, once decoded. Carries limitBytes and totalBytes.No. Send fewer or smaller files.
attachment_type_blocked422An 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_invalid422Structurally unusable — a malformed contentType, or disposition: inline with no cid. Carries filename.No. Fix the attachment and resend.
content_blocked422The 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_stream422The 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_refused422Too 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_suspended403The 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_large422More messages, or more expanded recipients, than one batch may carry. Carries the counts and the ceiling.No. Split it across more calls.
unknown_template422No 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_template422The 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_use409Messages 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_taken409This account already has a stream or template with that slug.No. Choose another.
reserved_slug409That slug is the stream every account starts with and cannot be created again.No. Choose another.
nested_sub_account409A sub-account cannot have sub-accounts of its own. The hierarchy is deliberately one level deep.No.
sub_account_limit_reached403Your plan includes a fixed number of sub-accounts and they are all used. Carries limit and used.No. Upgrade, or reuse one.
billed_to_parent409This 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_reached403Your plan includes a fixed number of seats and they are all used. Carries limit and used.No. Upgrade, or remove somebody first.
webhook_limit_reached403Your 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_reached403Your 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_reached403Your 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_reached403Your 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_exists409That 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_taken409You already have a contact list with that name. Names are compared case-insensitively.No. Choose another name.
transactional_stream422A 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_drafts409The account holds 50 unsent broadcast drafts.No. Send or delete a draft first.
not_a_draft409Only 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_history409The broadcast sent mail, so its record is kept.No. Cancel it instead if it is still running.
list_missing422The broadcast’s list was deleted.No. Edit the draft to name another list.
list_empty422The broadcast’s list has nobody on it.No. Add contacts to the list, then send.
template_unpublished422The broadcast uses a template with no published version.No. Publish the template, then send.
not_running409Only a scheduled or sending broadcast can be paused.No.
not_paused409Only a paused broadcast can be resumed.No.
quality_pause409The 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.
finished409The broadcast is already sent or canceled.No.
not_a_team_member422A 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_reached403Your 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_reached403Your 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_reached403Your 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_exceeded403The 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_member409That address is already on this account.No. They are already a member — check the team list.
address_unavailable409That 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_owner409An account must always have an owner, so the last one cannot be removed or demoted.No. Promote somebody else to owner first.
not_deletable409This 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_password400The 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_token400A 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_code400A 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_credentials401The 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_required401The account has two-factor authentication enabled and the request did not carry a code.Yes, with the code.
no_password409This 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_locked429Too many failed attempts. Locked briefly, which is what stops an unlimited guessing run.Yes, after a few minutes.
unsupported409The 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_error500Something 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_far422scheduledAt 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_scheduled422Canceling 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_bounce422Deleting 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_protected422Deleting a complaint or spam_trap suppression. These can never be removed.Never.
suppression_platform422Deleting a platform-wide suppression. Not removable by an account.No. Contact support.
token_required400No Cloudflare token supplied and none stored for the account.No.
cloudflare_token_invalid400Cloudflare rejected the token.No.
cloudflare_zone_not_found400The token is valid but does not cover this zone.No.
cloudflare_write_failed400The DNS write was refused — usually the token lacks Zone:DNS:Edit.No.
rate_limited429Too many requests for this API key. Carries retryAfterSeconds, plus retry-after and x-ratelimit-* headers.Yes, after the stated interval.
too_many_streams429Too 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_reached429The account daily sending cap. Carries limit and sentToday. Retry-After is midnight UTC.Yes, tomorrow.
monthly_limit_reached429The plan monthly allowance. Carries limit and sentThisMonth. Retry-After is the start of next month.Yes, next month — or upgrade now.
platform_paused429The 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_configured503Billing is not configured on this deployment. Session only.No.
provider_error502The payment provider failed or refused. Session only.Yes, with backoff.
internal500A handled server-side failure that still carries the envelope.Yes, with backoff.
logo_too_large413A brand logo above 512 KB. Carries limit. A logo needing more than that is not a logo.No. Send a smaller image.
unsupported_logo415A 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_reached429The 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_reached429The 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_locked429Three 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_throttled429Too 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_soon429A verification code was sent moments ago and the cooldown has not passed. Carries retryAfter.Yes, after Retry-After.
resend_limit_reached409This 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_attempts409Too 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_expired409The 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_used409The code was already approved. A code works once, deliberately.No. Start a new verification.
verification_canceled409The verification was cancelled, by you.No. Start a new verification.
verification_not_pending409Cancel 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_resendable409The 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.