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 — three different things return 429 and they need three 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. |
| 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. The message names the reason. | No. Remove the address from your list. |
| suppression_protected | 422 | Deleting a complaint 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. |
| 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. |
| 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. |
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 'forbidden':
throw new PermanentError(err.type, err.message, err.fields ?? [])
// Transient throttling, measured in seconds.
case 'rate_limited':
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.