Skip to main content

Response envelope

Every /v1/* response — success or error — is generated from the same zod schemas in lib/schemas.ts that publish the OpenAPI spec, so the shape below is a stable contract to code against, not incidental JSON. A failure looks like this:
Treat this as a contract: status is always one of ok, action_required, or failed on every /v1/* response — success bodies included — so it’s always safe to switch on. code values are stable identifiers wherever they appear (every error, and the confirmation-gate action_required responses below) — match on them, never on reason wording. reason is human prose (log it, don’t parse it), and next is the recovery playbook — what a support person would tell you, machine-readable. The same envelope is published as the Error component in the OpenAPI spec, generated from the server’s own schemas so it cannot drift.

Retry semantics

The same rule reaches agents as a boolean: every MCP tool failure carries retryable, computed from the status above, so a tool-calling loop does not have to parse prose to decide whether to try again. Always send an Idempotency-Key on POST /v1/orders. With it, every retry above is safe by construction: a replay returns the original order ("replayed": true, same id) instead of creating a second one. This is why a timeout is never a reason to fear resubmitting — proven live in the benchmark. Every code below is one entry in lib/error-codes.ts, and a test fails the build if this page documents a code that does not exist, or omits one that does. The tables are the catalogue, not a summary of it.

Request errors — fix the input

Credential errors — only if you send a key

An API key is optional on every route: omitting Authorization is a supported way to call, and a key exists to narrow what a caller may do (hand an agent a mail:quote key and it can price a letter but cannot spend a cent). A key that is sent and doesn’t verify is refused rather than quietly downgraded to anonymous, so a typo fails loudly instead of silently dropping the restriction you meant to apply.

Confirmation errors (action_required) — the spend-safety gate

A send is never the first call: POST /v1/orders on the agent path requires a single-use confirmation_token from POST /v1/quotes, bound to recipient, content, class, options, and price.

Capability errors — order-scoped grants

cancel_token and review_token both carry the ppc_ prefix; they’re told apart by the action baked into the signature, not by the prefix. Both live 7 days.

Payment errors

State errors — the order is real and isn’t in the state you asked about

Limits — back off, don’t rewrite the request

Every 429 carries Retry-After in seconds.

Platform errors — our side, retryable

If fulfillment fails after payment capture, you don’t handle it: an hourly reconcile loop detects the failure and refunds automatically. failed orders in the lifecycle below are always accompanied by a refund.

Platform errors that are 503 and must NOT be retried

These two are the exception to everything above. They are 503, which normally means “try again” — here it means we stopped on purpose, because retrying could put a second physical piece in a real person’s mailbox. Mail is not idempotent once it is printed, and no status code can be un-mailed.
Do not auto-retry either code. In both cases we have already decided that sending again is the more expensive mistake — you were not charged, so a retry buys you nothing and risks a duplicate. Resubmit only once a human has confirmed nothing arrived.

Order lifecycle

What actually creates a charge

Verified against lib/service.ts and app/v1/orders/route.ts — not a guess. The only request that can ever spend real money is a live (non-sandbox) POST /v1/orders, and only once payment actually completes — either via credit_code (debited in that same call) or a completed Stripe checkout (captured after the order clears screening). Quotes, sandbox orders, and every GET in this document are free by construction.
Last modified on September 17, 2026