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,5xxor 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 sameclient_referenceandsend_intent_idso the letter still can't go out twice.
Also:
- Back off between retries, starting at a few seconds and doubling. Honor
Retry-Afterwhen it's present. - If you aren't sure whether a write succeeded, look it up before retrying.
- Never respond to an error by raising a limit, switching recipients or adding credits automatically. Those are the owner's decisions.