Get 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 — 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" }
    ]
  }
}
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.
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. The message names the reason.No. Remove the address from your list.
suppression_protected422Deleting a complaint 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.
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.
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.

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.

NextPaginationKeyset cursors, and the one condition a paging loop must terminate on.