Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Errors

Every non-2xx response has this body:

{
  "error": {
    "code": "rate_limited",
    "message": "Too many requests for this API key.",
    "retryable": true,
    "fix": "Wait for the number of seconds in the Retry-After header, then retry with the same Idempotency-Key.",
    "request_id": "req_01J9Z4…",
    "details": { "retry_after": 12 }
  }
}
FieldMeaning
codeStable, machine-readable, snake_case. New codes can be added, so handle unknown codes by HTTP status
messageHuman-readable. It can change. Never parse it
retryabletrue means the same request can succeed later unchanged. Retry with backoff, and with the same Idempotency-Key for sends. false means the request has to change
fixOne sentence telling a developer, or an agent, what to do
request_idQuote it in bug reports. It is also in the Request-Id header
detailsOptional, code-specific

A 404 always carries one of this service’s own codes. A 404 without this envelope came from something else (a proxy, a wrong host) and must not be read as “already deleted”.

How a client should retry

if status == 2xx                         → done
if error.retryable == false              → do not retry; fix the request
if error.code == "rate_limited"          → sleep Retry-After, retry
if status >= 500 or network error        → retry with exponential backoff + jitter
                                           (0.5 s, 1 s, 2 s, 4 s … cap 60 s, give up after ~10 min)
for sends: ALWAYS reuse the same Idempotency-Key on every retry of the same message

A network error on a send is always safe to retry with the same key. You get the original result back, whether or not the first attempt reached the server.

Catalogue

Authentication and access

HTTPCodeRetryableWhen
401unauthenticatednoNo key, a malformed key, or an unknown key
401key_expirednoThe key passed expires_at
401key_revokednoThe key was revoked
403permission_deniednoThe key lacks the permission (details.required names it), or the action is turned off for API keys on this deployment: release with PM_QUARANTINE_KEY_RELEASE=off, where only a signed-in person can release
403scope_deniednoThe route or field needs a higher key level than the caller’s (for example tenant search with an identity key, or transport on PATCH /v1/domains/{domain_id} with a tenant key), or an identity key tried to write to its tenant’s domains or webhooks
403key_scope_exceedednoYou tried to create a key wider than your own
403tenant_suspendednoThe tenant is suspended
403test_mode_recipientnoA test tenant tried to send outside the simulator or this deployment
403policy_deniednoThe tenant’s policy forbids the action. In v1.0: a signed HTTP request (POST …/http-signatures) while tenant policy web_bot_auth.allowed is false, the default (O13). A platform operator turns it on in the tenant’s policy
403invalid_signaturenoAn SNS message to POST /hooks/ses (SES delivery events) or POST /hooks/ses/inbound (SES inbound mail) failed verification: SignatureVersion not 2, a bad signature, a signing certificate not on sns.{PM_SES_REGION}.amazonaws.com, another topic, or a stale Timestamp. Not returned to API callers

Validation

HTTPCodeRetryableWhen
400invalid_requestnoMalformed JSON or schema violation. details.errors[] has {path, message}. On POST /v1/keys, details.reason = "permission_not_allowed_for_level" when permissions lists one the new key’s level cannot hold (API › Permissions)
400idempotency_key_requirednoA send without Idempotency-Key
400invalid_idempotency_keynoLonger than 255 characters, or not printable ASCII
400invalid_querynoThe search query could not be parsed. details.position and details.expected say where and what
400address_invalidnoNot a valid RFC 5321 address
400address_unsupportednoAn SMTPUTF8 (non-ASCII) local part (A3). A non-ASCII name that looks like a reserved name, is mixed-script or contains right-to-left characters gets address_reserved instead
400address_reservednoA reserved or confusable local part (A4). On the shared platform domain this includes every RFC 2142 role name (info, marketing, sales, support, abuse, postmaster and the rest) where it would stand alone as the local part: usernames of the default tenant, whose suffix is empty (support.acme@… is not a role address). On a tenant’s own domain only postmaster and abuse are reserved, because mail to them goes to the tenant’s owner contact. Names such as noreply, mailer-daemon and journal are reserved everywhere
400local_part_too_longnoThe username and suffix leave no room for a thread token (more than 40 characters)
400header_not_allowednoA custom header outside the allowed set
400marketing_requirements_missingnokind: marketing without unsubscribe or consent
400too_many_recipientsnoMore than policy.max_recipients (hard maximum 49: Cloudflare allows 50 and one is kept for the hidden journal copy)
400smtp_port_not_allowednosmtp.port is not 465 or 587 (port 25 included: Workers cannot open outbound connections on it) on a domain create with method: smtp_relay or a PATCH /v1/domains/{domain_id} with smtp
400spf_lookup_limitnoAdding a domain whose merged SPF record would need more than 10 DNS lookups (or more than 2 void lookups). details.lookups has the count; fix names the includes to flatten (H2)
413message_too_largenoThe composed message exceeds the transport limit (5 MiB with Cloudflare)
413payload_too_largenoThe request body exceeds 7 MiB
422scope_too_largenoA tenant search over more than 100 identities without an identity_ids filter

Resources and state

HTTPCodeRetryableWhen
404tenant_not_found, identity_not_found, address_not_found, domain_not_found, thread_not_found, message_not_found, attachment_not_found, webhook_not_found, key_not_found, erasure_not_found, export_not_found, suppression_not_found, list_entry_not_found, member_not_found, invitation_not_found, job_not_found, dlq_item_not_foundnoThe resource does not exist or is outside your scope (deliberately indistinguishable). A signed link that is bad, expired or signed by a retired key also returns its target’s code (attachment_not_found or export_not_found). key_not_found also covers an identity signing key’s unknown kid, and the Web Bot Auth key directory (/.well-known/http-message-signatures-directory) while PM_WEB_BOT_AUTH=off. identity_not_found also covers a deleting or deleted identity, and the JWK Set (/.well-known/jwks/{identity_id}.json) of a paused or suspended identity
409idempotency_conflictnoSame key, different request. details.original_message_id is included when known
409request_in_progressyesSame key, and the first request is still running
409client_id_conflictnoSame client_id, different identity body
409username_takennoThe username is in use in this tenant
409slug_takennoAnother tenant uses this slug
409suffix_takennoAnother tenant uses this address suffix, or a second tenant asked for the empty suffix
409not_quarantinednorelease on a message that is not quarantined
409domain_not_suspendednoreprove on a domain that is not suspended
409existing_mxnoAdding a cloudflare_zone apex, or a dns_records domain, that already has MX records (none of them the expected ones), without "replace_mx": true (H5)
409domain_not_dedicatednoA nameservers domain whose name has A, AAAA or MX records, or whose www has a CNAME, A or AAAA record, without "confirm_dedicated": true. Moving the nameservers would stop that website or mail. details.records lists what was found
409zone_holdnoCloudflare refused to create the zone because of a zone hold (nameservers, delegated_subdomain). The fix asks the customer to release the hold (for subdomains)
409owner_requirednoTried to remove or demote the workspace owner; transfer ownership first
409plan_managed_by_stripenoTried to set a plan on a workspace whose plan is paid through Stripe
409address_takennoThe address belongs to another identity, or is tombstoned
409address_is_primarynoTried to retire or delete the primary address
409address_in_usenoTried to delete an address that received mail (retire it instead), or to retire or delete the identity’s platform address, which stays active as the fallback address for the identity’s whole life
409domain_not_readyyesA send from a domain that is pending or verifying, or from a pending address. A failing or suspended domain is not an error: the send is accepted and falls back to the platform address. Also an identity create whose primary address is on a domain that is not healthy or degraded, and a promote to such an address
409domain_in_usenoTried to remove a domain that still has active or retiring addresses. Also returned with details.reason = "routing_rule_limit" when a zone subdomain already has 200 literal routing rules and another address is added
409domain_existsnoThe domain is already registered in this deployment
409identity_pausednoThe identity is paused. details.reason says why (tenant_suspended for every identity of a suspended tenant). A paused identity also cannot sign agent assertions or HTTP requests (O1)
409identity_owner_requirednoThe identity has no accountable human
409thread_busyyesAnother send holds the thread lock. Retry after details.retry_after
409not_cancelablenoThe message is past queued
409not_uncertainnoresolve was called on a message that is not uncertain
409auto_reply_not_allowednoAn auto-reply to automated mail, or over the automatic-exchange limit (D6)
410raw_expirednoRaw MIME is past retention
410cursor_expirednoA pagination cursor older than 24 hours
423legal_holdnoDELETE …/messages/{message_id} on a message whose thread is under a legal hold. Nothing was created. Erasure requests (POST /v1/erasure-requests) never return it: they skip held threads and list them in the receipt

Policy and limits

HTTPCodeRetryableWhen
422all_recipients_suppressednoNever returned at send time: a fully suppressed send is accepted and ends suppressed. Returned only by a dry-run (?dry_run=true)
422recipient_blockednoA dry-run found a recipient on the send-block list
422transport_unavailablenoThe deployment or the domain’s method cannot do what was asked. details.reason is one of ses_not_configured (the SES transport, PM_SES_*, is not configured: dns_records, send_only, PATCH to ses), ses_receiving_not_configured (dns_records, or smtp_relay with inbound: ses, without PM_SES_INBOUND_TOPIC_ARN, bucket and queue), ses_identity_limit (the SES region already has 10,000 identities), subdomain_setup_disabled (delegated_subdomain while PM_CF_SUBDOMAIN_SETUP is not on), zone_creation_not_allowed (nameservers by a tenant key whose policy lacks domains.allow_create_zone: true) or method_not_supported (the domain’s method does not support the operation: PATCH with a transport it cannot use, probe without the smtp transport, test-forwarding without inbound: forward)
422smtp_tls_requirednoThe SMTP relay does not offer STARTTLS on port 587 (or TLS on 465). The credentials were not sent. Returned by domain create (smtp_relay) and by PATCH /v1/domains/{domain_id} with smtp; also a domain health issue
422smtp_auth_failednoThe SMTP relay answered 535 to AUTH. Returned by domain create (smtp_relay) and by PATCH /v1/domains/{domain_id} with smtp; also a domain health issue
422cf_token_requirednoPM_CF_API_TOKEN is not set, and a domain create with method cloudflare_zone, nameservers or delegated_subdomain, or an identity or address that needs a literal routing rule, needs it. For an apex cloudflare_zone, pmail domains add --local-token with your own Cloudflare token works instead (catch-all, no literal rules)
422agentic_disablednoAgentic search is turned off by tenant policy
422address_limit_reachednoThe identity already has 20 addresses in any state
422webhook_limit_reachednoThe tenant (or platform) already has 20 webhook endpoints
422web_bot_auth_disablednoSigned HTTP requests are turned off on this deployment (PM_WEB_BOT_AUTH=off, the default): POST …/http-signatures, and rotating the web_bot_auth key with POST /v1/platform/keys/web_bot_auth/rotate (O9)
402billing_limitnoA plan allowance is spent (details: feature, granted, used, resets_at, upgrade_url). Nothing was stored: upgrade or add a top-up, then retry with the same Idempotency-Key
429rate_limitedyesA per-key or per-identity rate limit (including signing: 600 assertions and HTTP signatures a minute per identity), or the per-domain limit of verify and probe (one a minute). Has Retry-After
429daily_cap_reachedyesA tenant or identity daily cap. details.resets_at says when it resets
429agentic_budget_exhaustedyesThe tenant’s daily agentic-search budget is spent
429upstream_rate_limitedyesA provider’s rate limit stopped the request before anything changed. Cloudflare refused to create a zone with error 1105 (too many attempts to add a domain), for nameservers or delegated_subdomain: Retry-After and details.retry_after are 10800 (3 hours). Or the deployment’s Amazon SES control-plane budget (one call per second, shared by every domain) had no slot within 5 seconds, for a domain create, PATCH or removal that calls SES: Retry-After is the wait, usually a few seconds

A 402 billing_limit is never returned for inbound mail (FR-BILL-8) or for replays of requests that already completed (FR-BILL-6). On a deployment with billing disabled, only operator quotas from tenant policy apply, and they return 429 daily_cap_reached.

Server and dependencies

HTTPCodeRetryableWhen
500internal_erroryesA bug. Logged with request_id
502upstream_erroryesA Cloudflare or SES API returned an unexpected error during a synchronous call (domain add, verify), or the SMTP relay could not be reached when a domain create (smtp_relay) or a PATCH with smtp tested it
503unavailableyesA dependency is temporarily unavailable (D1, a Durable Object overloaded)
503search_degradedyesOnly when the request set "require_mode": true and the requested mode is unavailable. Otherwise search degrades and sets degraded: true
504timeoutyesAn internal deadline was exceeded. For sends this happens before the message is queued, so a retry with the same key is safe

Send failures after 202

Errors after a send was accepted do not come back as HTTP errors. They arrive as message status and events (message.rejected, message.failed, message.uncertain, message.bounced). The reason codes in data.reason are:

ReasonStatusMeaning
provider_validationrejectedThe transport refused the content (header, size, format)
sender_domain_unavailablerejectedThe domain is not onboarded with the transport
recipient_suppressed_by_providerper recipient suppressedThe provider’s own suppression list. Synced into ours
quota_exhaustedfailedThe provider’s rate limit or daily limit, still refusing after 24 hours of back-off (rate-limit back-offs are capped at 24 hours too), or an SMTP relay still unavailable after 24 hours of retries
transport_timeoutuncertainNo answer from the transport. It may have been sent
transport_connection_lostuncertainThe connection dropped after the request was written
resolved_not_sentfailedA human resolved an uncertain send as not sent
domain_failing_no_fallbackfailedThe domain failed and fallback was disabled by policy

How provider errors map to reasons

The transport’s answer decides the reason. An answer that is not clearly definitive is treated as unknown, because an uncertain send that a person or a later event settles is safer than a retry that could send twice. uncertain sends are never resent automatically.

Provider answerStatusReasonRetried
Cloudflare validation and header codes (E_VALIDATION_ERROR, E_FIELD_MISSING, E_TOO_MANY_RECIPIENTS, E_CONTENT_TOO_LARGE, E_HEADER_*, E_HEADERS_*, E_TOO_MANY_ATTACHMENTS, E_RECIPIENT_NOT_ALLOWED); SES MessageRejected, BadRequestException and other 4xxrejectedprovider_validationnever
Cloudflare E_DELIVERY_FAILED (the recipient server refused the message)rejectedprovider_validationnever
A per-recipient provider event failed or rejected (Cloudflare event subscription, SES Reject or Rendering Failure)that recipient failed or rejected; the message rolls upprovider_validationnever
Cloudflare E_SENDER_DOMAIN_NOT_AVAILABLE, E_SENDER_NOT_VERIFIED; SES MailFromDomainNotVerifiedException, NotFoundExceptionrejectedsender_domain_unavailablenever
Cloudflare E_RECIPIENT_SUPPRESSEDthe recipient suppressed (or rejected when it cannot be identified)recipient_suppressed_by_providerthe other recipients, once
Cloudflare E_RATE_LIMIT_EXCEEDED, E_DAILY_LIMIT_EXCEEDED; SES TooManyRequestsException, LimitExceededException, SendingPausedException, AccountSuspendedExceptionstays queued with back-off, then failed after 24 hoursquota_exhausted (at the end)yes, it was definitely not sent
SMTP relay (smtp_relay domains): 535 or another 5xx to AUTH, or no STARTTLS on port 587 (credentials never sent)rejected; the domain gets issue smtp_auth_failed or smtp_tls_requiredsender_domain_unavailablenever
SMTP relay: 5xx to MAIL FROM or to the final ., or to every RCPT TOrejectedprovider_validationnever
SMTP relay: 5xx to one RCPT TOthat recipient rejected; the others continueprovider_validationnever for that recipient
SMTP relay: any failure before the final . is written (connect, TLS, 4xx to AUTH, MAIL FROM or every RCPT TO, a dropped connection), or 4xx to the final .. A 4xx to some RCPT TO keeps only those recipients queuedstays queued with back-off, then failed after 24 hoursquota_exhausted (at the end)yes, it was definitely not sent
Cloudflare E_INTERNAL_SERVER_ERROR, any Cloudflare code not listed here, an error without a code; SES 5xx; a dropped connection; an SMTP connection lost after the final . and before its replyuncertaintransport_connection_lostnever
No answer within 30 seconds; SMTP: no reply to the final . within 60 secondsuncertaintransport_timeoutnever

The full table, with the exact Cloudflare and SES codes, is in Outbound design › Transport outcome classification.