# Errors

Every error uses the same format:

```json
{
  "error": {
    "code": "INSUFFICIENT_FUNDS",
    "message": "Credit is insufficient for this quote. Add funds in Account → Credits.",
    "retryable": false,
    "request_id": "req_…",
    "action_url": "/app?view=credits&amount_minor=1000",
    "details": {"required_minor": 499, "available_minor": 200, "shortfall_minor": 299,
                "suggested_topup_minor": 1000, "currency": "USD"}
  }
}
```

| Field | Always present | Meaning |
|---|---|---|
| `code` | Yes | A stable code from the table below. Branch on this. |
| `message` | Yes | A human-readable explanation. Don't parse it; it may change. |
| `retryable` | Yes | `true` if the problem is temporary and repeating the **same request with the same key** may succeed |
| `request_id` | No | Include this when you contact support |
| `action_url` | No | A page where the account owner can fix the problem |
| `details` | No | Extra data, such as `reason` for `ACTION_NEEDED` |

Over MCP, the same `error` object comes back in the tool result's `structuredContent`, with `isError: true`.

## Error codes

| Code | HTTP | What happened | What to do |
|---|---|---|---|
| `UNAUTHENTICATED` | 401 | The credential is missing, expired or revoked | Check the `Authorization` header. Refresh the OAuth token or create a new key. |
| `INSUFFICIENT_FUNDS` | 402 | Your available balance is less than the quote | Open `action_url` to add credits (the amount is filled in), then send again with a new key |
| `FORBIDDEN` | 403 | The credential lacks the scope, or the permission belongs to another key or assistant | Check the key's scopes and the permission's client ID |
| `LIMIT_EXCEEDED` | 403 | The send would go over a sending permission's limit | Wait for the daily reset at midnight UTC, or raise the limit on the website |
| `LIMIT_EXCEEDED` | 409 | You already have 10 webhook endpoints | Remove one you don't use |
| `NOT_FOUND` | 404 | The resource doesn't exist or isn't yours | Check the ID, and that you're using a live key for live records and a test key for test records |
| `REQUEST_NOT_FOUND` | 404 | No request was recorded for that operation and key | Repeat the original request with the same key and body |
| `ACTION_NEEDED` | 409 | Something must be done first. See `details.reason` below. | Fix the cause, often by showing the owner `action_url`, then retry with a new key |
| `VERSION_CONFLICT` | 409 | The draft changed since you read it | Read the draft again and use its current `version` |
| `PROOF_STALE` | 409 | The draft or renderer changed since the proof was made | Render a new proof, then get a new quote |
| `QUOTE_EXPIRED` | 409 | The quote is past `expires_at` | Get a new quote with a new key |
| `QUOTE_MISMATCH` | 409 | The quote, proof, total or send intent doesn't match | Build the send request from the latest quote |
| `IDEMPOTENCY_CONFLICT` | 409 | The key was already used with a different body | Use the original body, or a new key for a new request |
| `PAYMENT_ROUTE_CONFLICT` | 409 | This mailing or draft version already has an order | Read the order in `details.order_id` instead of sending again |
| `CANCELLATION_TOO_LATE` | 409 | The order is already being prepared | The letter will be mailed. Nothing was changed. |
| `VALIDATION_ERROR` | 422 | A field is missing or invalid | Fix the request body, then send it with a new key. The message names the field; missing template variables are listed in `details.missing`. |
| `RATE_LIMITED` | 429 | Too many requests | Wait for the `Retry-After` header's seconds, then repeat the same request and key |
| `TEMPORARILY_UNAVAILABLE` | 503 | A temporary problem on Pony's side | Wait, then repeat the same request and key |

## ACTION_NEEDED reasons

| `details.reason` | What it means |
|---|---|
| `grant_required` | This assistant has no sending permission. Add one under **Let my assistant send letters**. |
| `grant_unavailable` | The sending permission expired or was revoked. Create a new one. |
| `recipient_not_permitted` | The permission doesn't allow this recipient or sender |
| `option_not_permitted` | The permission doesn't allow this paper, envelope or style |
| `address_confirmation` | Confirm the sender and recipient addresses on the website |
| `unsupported_destination` | Use a US address |
| `account_frozen` | A refund, dispute or negative balance has paused sending. Resolve it in **Account → Credits**. |
| `test_mode` | Test keys can't create real funding links. Use `POST /test/credits`. |
| `missing_final_price`, `missing_final_tax` | Pony can't price this letter right now. Try again later. |
| Anything else | Contact support with the `request_id` |

## Retrying safely

There are two kinds of retry, and they use keys differently:

- **Temporary failure** (`retryable: true`, `429`, `5xx` or no response at all): repeat the **same request with the same idempotency key**. Pony returns the original result if the first attempt got through.
- **Rejected request** (any other `4xx`): Pony stores the rejection against that key, so repeating it returns the same error. Fix the cause, then send the request with a **new idempotency key**. For a send, keep the same `client_reference` and `send_intent_id` so the letter still can't go out twice.

Also:

- Back off between retries, starting at a few seconds and doubling. Honor `Retry-After` when it's present.
- If you aren't sure whether a write succeeded, [look it up](/docs/concepts.md) before retrying.
- Never respond to an error by raising a limit, switching recipients or adding credits automatically. Those are the owner's decisions.
