Errors
Machine-readable denials — a stable code, whether retrying helps, and what to do next.
Core denials use a common body shape, so an agent can recover without reading prose. Configuration errors and unexpected exceptions are separate TollstileError failures; adapters may rethrow them to the framework.
{
"error": {
"code": "insufficient_authorization",
"retryable": true,
"action": "pay",
"message": "The payment authorizes $0.03; this request costs $0.04.",
"detail": null
},
"resource": "POST /v1/translate",
"required": "$0.04",
"authorized": "$0.03"
}A payment challenge carries price, variable, quote, nonce, expiresAt, and accepts. A requirement denial with status 402 has the error envelope but no payment offers. A settlement rejection after the handler consumed the body can also lack a fresh quote; request a new challenge before constructing a new proof.
| Field | Use |
|---|---|
code | Stable. Branch on it. |
retryable | Whether the same logical request can still succeed |
action | pay: pay using accepts in this response. retry_later: send the same request with the same payment and idempotency key after Retry-After. fix_request: change the request. stop: do not retry. |
message | For humans; may change |
detail | The rail's or requirement's own reason, such as transfer_mismatch. Log it; do not branch on it. |
Codes
| code | Status | action | When |
|---|---|---|---|
payment_required | 402 | pay | No payment |
quote_required | 402 | pay | The price is computed per request and the payment carried no quote |
quote_invalid | 402 | pay | The quote is forged, expired, or for another resource |
quote_mismatch | 402 | pay | The request differs from the one the quote priced |
quote_offer_missing | 402 | pay | The quote has no offer for the rail used |
proof_invalid | 402 | pay | The rail rejected the payment; detail says why |
insufficient_authorization | 402 | pay | The payment authorizes less than the price; required, authorized |
authorization_expired | 402 | pay | The payment authorization expired |
payment_rejected | 402 | pay | The provider rejected an upfront payment |
settlement_rejected | 402 | pay | The provider rejected settlement after the handler; the output was withheld |
requirement_failed | 402 · 403 · 429 | pay · stop · retry_later | A requirement refused; requirement, detail |
access_denied | 403 | stop | No access policy admits the caller |
proof_already_used | 409 | stop | A single-use payment was already used by another request |
request_in_progress | 409 | retry_later | Same idempotency key; the first attempt is still running |
already_paid | 409 | stop | Same idempotency key; the request was already paid; chargeId, settlement, result |
idempotency_key_reused | 422 | fix_request | Same idempotency key, different request |
invalid_request | 400 | fix_request | Malformed idempotency key or MCP envelope |
payment_unavailable | 503 | retry_later | A provider needed to verify or challenge is down |
payment_outcome_unknown | 503 | retry_later | Settlement outcome unknown; retry with the same payment and key |
requirement_unavailable | 503 | retry_later | A requirement cannot check its evidence right now |
Denials include Cache-Control: no-store. Only action: "retry_later" includes Retry-After (including retryable 429), and the body carries the same wait as retryAfter in seconds, for transports that have no headers. The wait is spread around five seconds rather than fixed, so a hundred clients denied at the same instant do not all come back at the same one; a client should add its own backoff on top, and never retry a denial whose action is not retry_later. already_paid and proof_already_used have action: "stop" and no retry header. On MCP, tool-result denials carry the body in _meta["tollstile/payment-required"]; MPP challenge errors use the protocol-specific JSON-RPC shape described in MCP.
An agent's loop
const response = await fetch(url, { headers });
if (response.ok) return response;
const { error, accepts } = await response.json();
switch (error.action) {
case "pay": return payAndRetry(accepts); // new payment, same Idempotency-Key
case "retry_later": return retryAfter(response.headers.get("retry-after"));
case "fix_request":
case "stop": throw new Error(`${error.code}: ${error.message}`);
}