๐Ÿ“จ Outbox

Errors#

Every error uses the same envelope:

json
{
  "statusCode": 422,
  "name": "missing_required_field",
  "message": "Missing `subject` field."
}

Switch on name. It is the stable machine-readable slug; message is written for humans and may change.

Reference#

400#

nameCause
validation_errorThe request body failed schema validation
invalid_parameterA parameter had an unacceptable value
invalid_idempotency_keyThe key was empty or longer than 256 characters
invalid_idempotent_requestThe same idempotency key was reused with a different body

401#

nameCause
missing_api_keyNo Authorization header
restricted_api_keyA sending_access key called a non-send endpoint
invalid_accessA dashboard endpoint was called without a session

403#

nameCause
invalid_api_keyThe key does not exist, or is domain-restricted and was used elsewhere
invalid_user_agentNo User-Agent header
invalid_from_addressfrom is malformed, its domain is unverified, or sending is disabled for it

404#

nameCause
not_foundThe resource, or the endpoint, does not exist

409#

nameCause
concurrent_idempotent_requestsThe original request with this key is still in flight
invalid_parameterA uniqueness constraint was violated โ€” a duplicate

422#

nameCause
missing_required_fieldA required field was absent
validation_errorThe body did not match the expected shape
invalid_attachmentMissing content, a blocked extension, or over the size limit
invalid_accessThe operation is not permitted on this resource

429#

nameCause
rate_limit_exceededMore than RATE_LIMIT_PER_SECOND requests in a second

5xx#

nameCause
application_errorAn unhandled server error. Check the server log

Handling them#

ts
const res = await fetch(`${base}/emails`, { method: "POST", headers, body })

if (!res.ok) {
  const error = await res.json()

  switch (error.name) {
    case "rate_limit_exceeded": {
      const wait = Number(res.headers.get("retry-after") ?? 1) * 1000
      await new Promise((r) => setTimeout(r, wait))
      return retry()
    }
    case "invalid_from_address":
      // Not retryable โ€” verify the domain first.
      throw new Error(error.message)
    case "missing_required_field":
    case "validation_error":
      // A bug in the caller. Do not retry.
      throw new Error(error.message)
    default:
      if (res.status >= 500) return retryWithBackoff()
      throw new Error(error.message)
  }
}

What to retry#

RetryDo not retry
429 โ€” after retry-after4xx validation and auth errors
5xx โ€” with backoffinvalid_from_address
Network timeouts โ€” with an idempotency keyrestricted_api_key

Always use an idempotency key when retrying a send. A timeout does not tell you whether the message was accepted, and retrying without one is how people send the same receipt twice.

Common mistakes#

403 invalid_user_agent โ€” you cleared the header. Set it to something identifying your application.

403 invalid_from_address in production but not locally โ€” locally you were on the console transport, which skips the domain check. Verify the domain.

422 validation_error with an SDK โ€” usually a camelCase key that reached the wire unconverted. Check request_body in the logs.

409 concurrent_idempotent_requests โ€” two identical requests raced. Wait and retry; the first will have completed.