> ## 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 route not found / 405 wrong method | **No** | Fix the path or method; see `/v1/openapi.json` |
| 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 |
| `route_not_found` | 404 | No API route at that path. Check `/v1/openapi.json`; the MCP endpoint is `POST /api/mcp` |
| `method_not_allowed` | 405 | The route exists but not for this HTTP method; the `Allow` header lists the supported ones |
| `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 | The payment processor accepted the session and returned no URL to send the payer to |
| `no_client_secret` | 502 | The payment processor 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 ($100/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 payment link |
| 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 — the card is captured |

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 payment link
(captured after the order clears screening). Quotes, sandbox orders, and
every `GET` in this document are free by construction.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.