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: 5 (including retryable 429). 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}`);
}