> ## Documentation Index
> Fetch the complete documentation index at: https://paperplane-justin-winter-s-projects.vercel.app/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Error contract

> Every error code, its retry semantics, and the machine-readable recovery step — a contract, not a troubleshooting page.

## Response envelope

Every `/v1/*` response — success or error — is generated from the same zod
schemas in `lib/schemas.ts` that publish the
[OpenAPI spec](https://sendpaperplane.com/v1/openapi.json), so the shape below
is a stable contract to code against, not incidental JSON. A failure looks
like this:

```json theme={null}
{
  "status": "failed",          // or "action_required" when a next step can fix it
  "code": "confirmation_used", // stable, machine-matchable — never parse `reason`
  "reason": "This confirmation_token was already used to send a letter.",
  "next": ["Quote again — POST /v1/quotes (MCP: quote_letter) — to authorise another letter."]
}
```

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

| You got                         | Retry unchanged?                | Do instead                                                   |
| ------------------------------- | ------------------------------- | ------------------------------------------------------------ |
| 400 / 413 request errors        | **No** — same input, same error | Fix what `next` names, resubmit                              |
| 401 / 403 credential errors     | **No**                          | Fix the key, or omit `Authorization` and call anonymously    |
| 404 / 409 state errors          | **No**                          | Re-read state with `GET /v1/orders/:id`                      |
| 410 expired token               | **No**                          | The grant is gone. Create a new order for a fresh token pair |
| 422 fulfillment rejection       | **No**                          | Follow `next`; the piece itself was rejected                 |
| 428 confirmation required       | **No**                          | Quote first, then send with the token it returns             |
| 429 rate limits                 | **Yes, with backoff**           | Honor `Retry-After`                                          |
| 500 / 502 / 503 platform errors | **Yes, later**                  | Our side; retry after a delay                                |

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](/docs/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

| `code`                         | HTTP      | What it means                                                                                                   |
| ------------------------------ | --------- | --------------------------------------------------------------------------------------------------------------- |
| `validation_error`             | 400       | A field failed schema validation; `reason` lists `field: problem` pairs                                         |
| `invalid_request`              | 400       | Body was well-formed JSON but not usable as sent                                                                |
| `invalid_page_count`           | 400       | `page_count` is not a whole number of at least 1                                                                |
| `page_limit_exceeded`          | 400       | Document renders longer than the 12-page maximum                                                                |
| `tracking_included`            | 400       | You added tracking to a class that already includes it — `first_class` only                                     |
| `empty_document`               | 400       | The PDF/text contains nothing printable                                                                         |
| `unsupported_characters`       | 400       | The letter text has characters the chosen font can't print (CJK, Arabic, emoji, …) — upload it as a PDF instead |
| `invalid_pdf`                  | 400       | The bytes aren't a readable PDF                                                                                 |
| `invalid_base64`               | 400       | `pdf_base64` isn't valid base64                                                                                 |
| `low_text_density`             | 400       | Scanned-image PDF; text documents only at launch                                                                |
| `file_too_large`               | 400 / 413 | Over the 10MB cap (413 when PUT to the upload route)                                                            |
| `pdf_fetch_failed`             | 400       | Your `pdf_url` wasn't publicly fetchable as a PDF                                                               |
| `pdf_required`                 | 400       | This piece format needs a PDF and got none                                                                      |
| `blocked_address`              | 400       | `pdf_url` points at a private, reserved, or non-http(s) address                                                 |
| `dns_failed`                   | 400       | `pdf_url`'s host did not resolve                                                                                |
| `redirect_limit`               | 400       | `pdf_url` redirected too many times                                                                             |
| `invalid_upload_key`           | 400       | `upload_key` isn't one this server issued                                                                       |
| `upload_not_found`             | 400       | The `upload_key` expired or was already consumed                                                                |
| `unknown_pack`                 | 400       | No credit pack with that id                                                                                     |
| `invalid_stars`                | 400       | A review with `stars` outside 1–5                                                                               |
| `unsupported_mail_class`       | 400       | The rail printing this piece doesn't offer that class                                                           |
| `unsupported_piece`            | 400       | The rail doesn't produce that piece format                                                                      |
| `unknown_format`               | 400       | No such piece format                                                                                            |
| `class_unavailable_for_format` | 400       | That class can't carry that piece — a postcard can't go certified                                               |

## 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.

| `code`                  | HTTP | What it means                                                                   |
| ----------------------- | ---- | ------------------------------------------------------------------------------- |
| `invalid_key`           | 401  | An `Authorization` header was sent and did not verify                           |
| `key_disabled`          | 403  | The key is real and has been turned off                                         |
| `insufficient_scope`    | 403  | Valid key without the scope this route needs; `reason` names what it does carry |
| `key_store_unavailable` | 503  | We couldn't check the key, so we don't honor it                                 |

## 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.

| `code`                     | HTTP | What it means                                                                                                                                                    |
| -------------------------- | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `confirmation_required`    | 428  | No token supplied on a surface that requires one                                                                                                                 |
| `confirmation_malformed`   | 400  | Not a paperplane quote token                                                                                                                                     |
| `confirmation_invalid`     | 400  | Signature check failed — altered, or signed by another deployment                                                                                                |
| `confirmation_expired`     | 400  | Past its 30-minute window; quote again                                                                                                                           |
| `confirmation_mismatch`    | 409  | Valid token, different letter — recipient, class, color, tracking, content, or price changed since the quote                                                     |
| `confirmation_used`        | 409  | Already redeemed — **the letter you quoted was sent**; this is replay protection working, not a failure to recover from                                          |
| `confirmation_unavailable` | 503  | No signing secret configured, so no token could be minted or checked                                                                                             |
| `quote_content_required`   | 400  | The quote carried no letter content, so nothing could be bound. A token bound to no document is a blank cheque for any document the send path cannot fingerprint |

## 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.

| `code`                   | HTTP | What it means                                                                    |
| ------------------------ | ---- | -------------------------------------------------------------------------------- |
| `invalid_capability`     | 403  | Missing, malformed, or issued for a different order or action                    |
| `expired_capability`     | 410  | Past its 7-day window. Gone, not forbidden — create a new order for a fresh pair |
| `capability_unavailable` | 503  | No signing secret configured, so tokens can't be issued or checked               |

## Payment errors

| `code`                    | HTTP      | What it means                                                                                                          |
| ------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------- |
| `unknown_code`            | 402 / 404 | No credit account with that code (404 from the balance lookup, 402 when paying with it)                                |
| `insufficient_balance`    | 402       | The code exists but can't cover this order; `next` states the balance                                                  |
| `x402_not_configured`     | 501       | Agent payments are off here; use `/v1/credits/checkout` or a `credit_code`                                             |
| `payment_required`        | 402       | No payment header supplied; the response body is the x402 challenge to sign and resubmit                               |
| `region_unsupported`      | 403       | The x402 stablecoin lane is hidden in this region (New York); use `/v1/credits/checkout` or a `credit_code`            |
| `x402_payment_invalid`    | 402       | The `X-PAYMENT` header isn't a payload we can read                                                                     |
| `x402_verify_failed`      | 402       | The facilitator would not verify the payment payload                                                                   |
| `x402_settle_failed`      | 402       | Verification passed, settlement didn't. Nothing was minted                                                             |
| `payments_not_configured` | 503       | This deployment can't take payments right now                                                                          |
| `no_checkout_url`         | 502       | Stripe accepted the session and returned no URL to send the payer to                                                   |
| `no_client_secret`        | 502       | Stripe accepted an embedded-checkout session (`checkout_ui_mode: "embedded"`) and returned no `client_secret` to mount |

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

| `code`                      | HTTP | What it means                                                                                                                                     |
| --------------------------- | ---- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `not_found`                 | 404  | No such order. Sandbox orders are cleared on redeploy                                                                                             |
| `idempotency_key_in_flight` | 409  | An earlier request with this `Idempotency-Key` has not finished. Retry with **the same key** after `Retry-After`; a new key mails a second letter |
| `too_late`                  | 409  | Already handed to the carrier; cancellation window closed                                                                                         |
| `not_reviewable`            | 409  | Not delivered, or already reviewed                                                                                                                |
| `not_held`                  | 409  | Review action on an order that isn't held                                                                                                         |
| `address_undeliverable`     | 422  | USPS verification rejected the address; `reason` says why                                                                                         |
| `rejected`                  | 422  | The fulfillment rail terminally refused the piece — fix the piece, don't retry                                                                    |

## Limits — back off, don't rewrite the request

Every 429 carries `Retry-After` in seconds.

| `code`                | HTTP | What it means                                                                                       |
| --------------------- | ---- | --------------------------------------------------------------------------------------------------- |
| `rate_limited`        | 429  | Per-IP throttle on a guest endpoint                                                                 |
| `velocity_limit`      | 429  | Per-sender send cap (5/day, 20/month)                                                               |
| `spend_limit`         | 429  | Per-credential spend cap ($50/day, $200/month)                                                      |
| `caller_unidentified` | 403  | We can't identify you well enough to hold you to a cap, so the send is refused rather than uncapped |

## Platform errors — our side, retryable

| `code`                    | HTTP | What it means                                                                                                                                         |
| ------------------------- | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `unavailable`             | 503  | Transient fulfillment failure; safe to retry                                                                                                          |
| `rail_unavailable`        | 503  | Valid request, but no rail currently serves it                                                                                                        |
| `limit_check_unavailable` | 503  | The cap ledger was unreachable, so the send is refused rather than run uncapped                                                                       |
| `audit_unavailable`       | 503  | The audit trail was unreachable. We don't mail what we can't attribute                                                                                |
| `below_cost_floor`        | 503  | This piece can't be sent at its current price — a rail-cost or plan-tier fault on our side. Nothing was charged; try a different format or mail class |
| `internal`                | 500  | Unexpected; retry once, then report it with the response body                                                                                         |

<Note>
  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](#order-lifecycle) below are always accompanied by a refund.
</Note>

## 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.

| `code`                   | HTTP | What it means                                                                                                                          |
| ------------------------ | ---- | -------------------------------------------------------------------------------------------------------------------------------------- |
| `retry_unsafe`           | 503  | The rail did not confirm the send and cannot be asked whether it landed, so we refused to retry. Nothing was sent, nothing was charged |
| `submit_outcome_unknown` | 503  | The rail could not tell us whether the piece reached the print facility. Nothing was charged, but a piece may already be in the mail   |

<Warning>
  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.
</Warning>

## Order lifecycle

```
pending_payment → screening → held_for_review ──┬─ allowed → submitted → mailed → delivered
                  (payment)                        └─ refused  → refunded
```

| State             | Meaning                                                    | You can                    |
| ----------------- | ---------------------------------------------------------- | -------------------------- |
| `draft`           | Created but not yet priced or paid; you will rarely see it | Cancel.                    |
| `pending_payment` | Awaiting payment at `payment_url`                          | Cancel (releases hold).    |
| `screening`       | Content check in progress                                  | Cancel.                    |
| `held_for_review` | Flagged for a quick human review                           | Cancel.                    |
| `submitted`       | Print facility accepted it                                 | Track. Too late to cancel. |
| `mailed`          | USPS has it                                                | Track.                     |
| `delivered`       | USPS reports delivery                                      | Review.                    |
| `refused`         | Screening refused; payment released                        | Place a new order.         |
| `canceled`        | You canceled before print                                  | —                          |
| `failed`          | Print could not mail it; **refunded automatically**        | Place a new order.         |

## What actually creates a charge

Verified against `lib/service.ts` and `app/v1/orders/route.ts` — not a guess.

| Call                                                                                      | Money moves?                                                                               |
| ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| `POST /v1/quotes`                                                                         | Never — pricing only; no order is created                                                  |
| `POST /v1/orders` with `sandbox: true`                                                    | Never — the sandbox rail is a mock; nothing reaches a real payment processor or carrier    |
| `GET /v1/orders/:id` (or any other `GET`)                                                 | Never — read-only                                                                          |
| Preview endpoints (address autocomplete, `suggest-class`, CSV mapping, etc.)              | Never — no order, no payment                                                               |
| Live `POST /v1/orders` that comes back `action_required` with a `payment_url`             | Not yet — the order sits in `pending_payment` until a human completes that Stripe checkout |
| Live `POST /v1/orders` with `credit_code`                                                 | Yes, immediately — the prepaid balance is debited as part of that same call                |
| A live checkout completing (`checkout.session.completed`) once the order clears screening | Yes — Stripe captures the card                                                             |

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.
