SECURITY.md explains how to report a vulnerability. This
page describes the gateway's trust boundaries, controls, and known limits.
UNTRUSTED GATEWAY PROCESS TRUSTED
──────────────────── ────────────────────────── ─────────────────────────
agent input ────► validates agent input ────► merchant backend
payment proofs may hold a local administrator configured
authorization proofs facilitator gas key
protocol traffic
configuration ◄──── administrator
environment ◄──── operator
Agent input is untrusted. Configuration and environment variables are trusted operator input, so only they may select backend URLs. The payment flow needs no buyer or merchant key; local facilitator mode holds a separate key that pays gas and broadcasts transactions. ERC-8409 quotes require a separate, unfunded signing key. Signing quotes does not transfer funds.
The default request log records the method, the URL with any query string
replaced by ?[REDACTED], the host, and the remote address and port, but no
request headers. The built-in logger redacts the Authorization,
PAYMENT-SIGNATURE, Agent-Authorization and Idempotency-Key request
headers and the privateKey, signerPrivateKey, signature, seed,
mnemonic, secret, challengeSecret, apiKey, adminToken and token
fields. Some call sites reduce caught errors with describeError. MCP, A2A and
ACP log toLogInfo(): the CommerceError.toInfo() fields, which can include
details, plus the cause chain with every URL cut to its scheme and host. The
rest of each cause's message is logged as thrown.
Logger field redaction covers the top level and one nested level. It does not
cover a secret at a.b.privateKey, so do not log raw nested objects that may
contain credentials. Do not put secrets in CommerceError details. Receipt
redaction is recursive, and the pipeline omits raw payment and authorization
proofs from receipts. Custom code that writes receipt or event metadata must
keep them out too.
On a resource with idempotency.redelivery, the
client's retry key is a credential. On x402, PAYMENT-SIGNATURE contains both
the proof and the buyer's id in unsigned extensions. MPP uses a separate
Idempotency-Key with the original Authorization credential. These can
retrieve the stored response until retention ends, with matching input and any
required mandate. Generate keys randomly and keep these credentials out of
proxy, APM and access logs. The gateway stores the key's SHA-256 hash; it does
not log the raw key, record it in events or send it to the merchant or facilitator.
Environment-substitution errors name the template token and path, never its resolved value. Later validation errors may echo substituted non-secret values, such as URLs, so keep secrets in fields designated for credentials.
AP2 uses operator-configured public keys; it performs no JWKS, issuer metadata,
revocation, jku, or x5u lookup. Removing a key from configuration and
restarting the process revokes it. See AP2 trust and rotation.
Local policy fixes verification to ES256 over P-256. The presented iss and
kid select one configured key. If iss is absent, kid selects the issuer;
a kid shared by two mandate issuers is refused. The verifier does not try
other keys after selecting one.
Replay identity uses the issuer-signed token. Checkout binding hashes the compact checkout JWT as presented. Neither value is reconstructed from parsed JSON before hashing.
The built-in HTTP executor calls only configured backend URLs:
- backend URLs are administrator-controlled configuration only;
- request input can replace
{param}tokens in the configured path or query. Tokens in the scheme, host, or port are rejected at config load, and substituted values are URL-encoded; - without
inputBindings, remaining input becomes query parameters for GET and DELETE, or the JSON body for POST, PUT and PATCH. Bindings instead select the path, query and body groups; - redirects are not followed (
redirect: 'manual'); a 3xx is aBACKEND_ERROR; - every call has a timeout and a 1 MiB response-body cap.
Empty path values are rejected. Values . and .. are also rejected because
URL parsing would normalize them and could remove configured path segments.
After substitution, the path must still start with the template's literal
prefix. Request input also cannot replace a query parameter already fixed in
backend.url; collisions fail as INPUT_INVALID.
There is no IP/CIDR allowlist or private-address blocklist. A configured URL
such as http://169.254.169.254/… will be called, so configuration is
privileged.
The OpenAPI importer gets backend URLs from the operator's document or
--base-url. It rejects relative or unresolved server URLs and makes no
network requests. Review its generated configuration before use.
On a non-2xx response, the executor returns the backend status but not its body.
It logs at most 512 body characters at debug; default log settings may hide
that detail. This prevents stack traces, internal hosts, and similar backend
details from reaching the client.
On POST /api/resources/:id/invoke, a delivery keeps the backend's
Content-Type when that is a text type (text/*, JSON, XML or JavaScript) that
fits the relayed body: a JSON type for a parsed JSON body, any other for text.
The header then says charset=utf-8, because the gateway relays text as UTF-8.
Otherwise JSON goes out as application/json and text as text/plain. Binary
responses are not supported: the executor decodes every body as UTF-8, so a
binary type is never relayed. Every text delivery also carries
Content-Security-Policy: sandbox and X-Content-Type-Options: nosniff, so an
HTML page from the backend cannot run script as the gateway's origin.
| Input | Check |
|---|---|
| Resource input | Config-loaded schemas are closed recursively by default; an explicit additionalProperties setting is preserved. For a config-loaded resource, a missing input accepts only an empty object. A directly constructed resource without an input schema accepts any input. Validation checks own properties; top-level __proto__, _payment and _authorization are removed first. |
| Path parameters | Empty values and dot segments are rejected; other values are URL-encoded. |
| Body size | 256 KiB. Fastify enforces it on parsed routes; raw protocol mounts enforce the same exported limit on the socket, replying 413 when they still can. MCP, A2A and ACP also cap what their adapters buffer. |
| Content type | HTTP invoke accepts JSON, plain text or an empty body. Raw mounts delegate parsing to their protocol: MCP applies its own rules, ACP requires JSON for a POST body, and A2A does not check content type. |
| Payment proof | The selected provider decodes and validates it. |
| Configuration | Strict Zod shape validation followed by business-rule validation before startup. |
The gateway removes the reserved _payment and _authorization carriers
before validating resource input.
Covered in detail in payment-flow.md. The invariants:
- Paid resources fail closed: payment or authorization failures do not deliver
the resource. With
idempotency.redelivery, matching exact resends can retrieve an already-paid response without verification until retention ends, even after proof expiry. This performs no settlement or new backend call. See retry after a lost response. verifyhas no fund-moving side effects; onlysettledoes.- Replay is defended twice: on-chain via EIP-3009
authorizationState, the Permit2 nonce bitmap, Solana's processed-transaction check or the NEAR access-key nonce, and in the gateway via areplayKeyreserved under aUNIQUEconstraint before settlement. The key is derived from the authorization, not the request, so a replay against a different request still collides. - The payment flow does not need buyer or merchant keys. In local mode the configured facilitator key pays gas and broadcasts a transfer whose recipient the buyer fixed in the signed authorization.
- The effective settlement destination is visible in
/.well-known/agent-commerceand indoctor, so misconfiguration is noticeable rather than silent. - A remote facilitator, with the Solana fee payer or NEAR relayer that pays for broadcasting, is trusted with liveness and with its settlement report, not with the payment terms. The buyer's signature fixes the token, amount and recipient, so the facilitator cannot redirect funds, but it can delay or drop a broadcast until the authorization expires. The gateway accepts a reported success once it names the offer's network and a transaction in that family's form; it does not read the chain to confirm it.
All routes first pass Host validation. Requests with an Origin header also
pass the server.allowedOrigins check, whose default is empty.
| Route | Audience | Authentication |
|---|---|---|
POST /api/resources/:id/invoke |
Agents | None; paid resources require payment. |
/mcp |
Agents | None; available when MCP is enabled; paid resources require payment. |
GET /api/resources, /health, /.well-known/agent-commerce |
Public | None. |
GET /ready |
Operators | None; it returns fixed status vocabulary rather than raw details. |
GET /api/receipts, /api/events, /api/payment-attempts |
Operators | server.adminToken, compared in constant time. |
GET /.well-known/agent-card.json |
Public | None; available when A2A is enabled. |
/a2a |
Agents | None; available when A2A is enabled; paid resources require payment. |
GET /.well-known/acp.json |
Public | None; it omits private configuration. |
/acp/checkout_sessions… |
Agents | protocols.acp.auth.token, compared in constant time. |
The operator routes carry the merchant's commerce ledger. With no
server.adminToken configured they return 404, not open data - a missing
control must not read as an absent restriction.
An allowed browser origin receives CORS headers. Requests without Origin do
not. The shared onRequest hook covers raw mounts such as MCP and uses Host
validation to limit DNS-rebinding attacks.
Every published port in docker-compose.yml binds 127.0.0.1. Port 8545 in
particular is an Anvil node with unlocked accounts and the full anvil_* admin
namespace; on a shared network that would be unauthenticated control of the dev
chain.
Every ACP checkout route requires Authorization: Bearer <token>. The adapter
hashes both values before comparison, so different token lengths do not change
the comparison. Authentication and idempotency-key shape checks run before the
request body is read.
Discovery at /.well-known/acp.json is public. It omits the bearer token,
backend URLs, mapped resource ids, and any idempotency file path. The token is
not persisted; idempotency keys are scoped by the public base URL, so rotating
the token does not release outstanding claims.
Signature and Timestamp verification are not implemented or advertised,
and Signature cannot replace the bearer token. The adapter forwards both to
the merchant unverified, among the
headers sent to the merchant. It
never forwards the bearer token, and a configured backend header wins over a
caller header of the same name. Use TLS because the bearer token is otherwise
sent in clear text.
Completion payment_data passes to the merchant backend as business input. The
built-in gateway does not log it, store it in a receipt, or treat it as an Agent
Commerce payment proof. Delegated payment and delegated authentication are not
implemented.
GET /ready exposes fixed status terms, not dependency messages. Returned
details are logged at debug. Store and provider health throws are logged at
error; adapter throws become failed health results whose details are logged
at debug. Only fail blocks readiness. Results are cached briefly and
concurrent probes share one in-flight check.
Available bounds include:
- a 256 KiB request cap on parsed routes and raw protocol mounts
- a 1 MiB response cap and timeout in the built-in backend executor
- a bounded list limit on every receipts/events query, applied in the store
- 20 concurrent requests per mounted protocol adapter, checked before the body
is read; excess requests get
GATEWAY_BUSYwithRetry-After: 1 - on
/mcp, 8 concurrent tool calls and a queue of 64; excess work getsGATEWAY_BUSY - readiness memoization and single-flight, shared by
/readyand/.well-known/agent-commerce, so polling either cannot amplify into one upstream RPC call per request - the audit request id is always generated, so a caller cannot write its own
string into audit rows or reuse another flow's id; a client
X-Request-Idis logged only when it matches[A-Za-z0-9._:-]{1,64} - an 8192-byte
Agent-Authorizationheader cap, checked before decoding; MCP and A2A authorization carriers use the request body cap instead - a cache of up to 1024 signed ERC-8409 quotes per provider, reused while a full payment window remains; each cache miss requires a signature per EVM offer
Rate limiting, per-agent quotas, and adaptive backpressure are not implemented. Add them at the edge for public deployments.
Runtime dependencies are pinned exactly, and the release workflow runs
npm audit. Review the current audit rather than relying on a count recorded in
this document.
Facilitator auth.type: cdp signs JWTs with Node's built-in crypto, without an
extra package. Auth error messages and the binding's description omit the API
key secret.
The repository contains Anvil's well-known development accounts, used only on
the local demo chain and labeled LOCAL DEVELOPMENT ONLY - DO NOT FUND. They
are public knowledge and anyone can spend from them. Never send real assets to
those addresses, and never reuse them anywhere else.
Shared x402 deployment checks reject a well-known Anvil recipient on a non-local deployment and reject the zero address. Config validation and x402 provider construction both run these checks. The separate well-known Anvil signer-key check runs only when a local x402 provider is constructed, where it rejects that key against a non-local RPC. For quote signing keys, the x402 provider rejects well-known Anvil keys when the offers use non-local networks, regardless of the RPC host.
The MPP provider builds its x402 settlement provider from its own options, so configured and direct MPP construction both run these checks.
Mainnet requires allowMainnet: true, a remote HTTPS facilitator and a
non-development recipient. EIP-3009 offers using a built-in mainnet's USDC
must match its EIP-712 domain. Other EVM tokens specify their own decimals
and, for EIP-3009, domain; Permit2 needs no token domain. MPP configuration,
Solana and NEAR require USDC on mainnet.
With auth.type: none, also set allowUnauthenticatedFacilitator: true.
Config validation and provider construction share the mainnet guards. The
provider also checks RPC and local-key safety.
A facilitator cannot change an EIP-3009 transfer's signed recipient, amount, or chain. A Permit2 signature binds the same terms and the x402 exact proxy as the only spender, which pays only the signed witness recipient. On Solana, the buyer signs the transaction message; the facilitator adds its fee payer signature without changing that message. On NEAR, the relayer wraps the buyer's signed delegate in its own transaction without changing the token, recipient or amount; the signature names no chain. A facilitator can observe authorizations and refuse to broadcast them. A request without facilitator credentials falls under the facilitator's anonymous-access terms and limits, so mainnet requires a separate acknowledgment for that configuration.
The in-process facilitator serves only the local chain, eip155:31337, which
keeps its gas-paying key off every public network. Config accepts any non-empty
local key, but provider construction requires 32-byte hex and rejects
well-known Anvil keys against public RPCs. Local health checks also require the
RPC to answer as Anvil, since an RPC can report any chain id.
This table maps scenarios to their closest tests. Some unit tests cover only the named state transition or returned outcome; end-to-end payment tests also check balances on chain. The two mainnet smoke tests run manually because they move real funds.
| Scenario | Outcome | Where |
|---|---|---|
missing PAYMENT-SIGNATURE |
402, no delivery | tests/unit/gateway/server.test.ts |
malformed PAYMENT-SIGNATURE over HTTP |
PAYMENT_INVALID; no backend call |
tests/integration/adversarial-payment.test.ts |
an x402 v1 client sending only X-PAYMENT |
402 with error: "invalid_x402_version" in PAYMENT-REQUIRED; no backend call |
tests/unit/gateway/server.test.ts |
| x402 signature, nonce or payer changed after signing | rejected before settlement; balances unchanged | tests/e2e/payment/x402-settlement.e2e.test.ts |
| network substitution | invalid_network before settlement |
tests/e2e/payment |
| requirement changed to another deployed token, or buyer proof signed for that token | wrong_asset or invalid signature before settlement; balances unchanged |
tests/e2e/payment/x402-settlement.e2e.test.ts |
proof recipient differs from configured payTo |
invalid_exact_evm_payload_recipient_mismatch before settlement |
tests/e2e/payment/x402-settlement.e2e.test.ts, tests/unit/payments-x402/provider.test.ts |
| amount differs from the price in either direction | invalid_exact_evm_payload_authorization_value_mismatch before settlement |
tests/e2e/payment/x402-settlement.e2e.test.ts |
an x402 proof whose accepted differs from the offer, or whose resource.url differs from the challenge's |
invalid_payment_requirements before the facilitator is called |
tests/unit/payments-x402/provider-remote-facilitator.test.ts |
multi-offer x402 proof whose accepted matches zero or multiple offers |
invalid_payment_requirements; no offer is selected and the facilitator is not called |
tests/unit/payments-x402/provider-remote-facilitator.test.ts |
| an AP2 mandate approved for one x402 offer, presented with a proof for another | selected destination, network or asset fails mandate binding; no settlement | tests/unit/core/execution/pipeline-authorization.test.ts |
| an EIP-3009 authorization signed for one EVM chain, presented as another chain's offer | invalid_exact_evm_signature: signature fails against the other chain's EIP-712 domain; neither chain's balances change |
tests/e2e/payment/x402-multichain.e2e.test.ts |
| the same payer, token address and nonce on two EVM chains | both settle: the replay key includes the chain id | same |
| a Permit2 proof naming another spender, token, amount or witness recipient than the offer | invalid_permit2_spender, permit2_token_mismatch, permit2_amount_mismatch or invalid_permit2_recipient_mismatch before the facilitator is called; balances unchanged |
tests/unit/payments-x402/provider-remote-facilitator.test.ts, tests/e2e/payment/x402-permit2.e2e.test.ts |
| a Permit2 proof altered after signing | invalid_permit2_signature from the facilitator; balances unchanged |
tests/e2e/payment/x402-permit2.e2e.test.ts |
| a Permit2 payment from a buyer without a Permit2 allowance | 412 with error: permit2_allowance_required in a fresh PAYMENT-REQUIRED; permit2_simulation_failed and 402 on a chain without Multicall3; balances unchanged |
tests/e2e/payment/x402-permit2.e2e.test.ts |
| a proof of the other transfer method, or one carrying both an EIP-3009 and a Permit2 authorization | unsupported_scheme before the facilitator is called, which would settle the Permit2 one |
tests/unit/payments-x402/provider-remote-facilitator.test.ts, tests/unit/payments-x402/provider.test.ts |
| an EIP-3009 authorization whose value, time bounds or nonce are not in canonical form | invalid_payload before the facilitator is called: amounts and times must be decimal, the nonce 32 bytes of hex |
tests/unit/payments-x402/provider.test.ts |
| a gas-sponsoring extension in a Permit2 payload | not forwarded: the facilitator receives no extensions |
tests/unit/payments-x402/provider-remote-facilitator.test.ts |
| an ERC-8409 quote altered after signing, or signed by another key | a buyer that verifies the signature against the published issuer rejects it | tests/unit/payments-x402/quote.test.ts, tests/integration/x402-quotes.test.ts |
| an altered ERC-8409 quote echoed in a payment | ignored: verification and settlement use the offer and payer authorization; the facilitator receives no extensions |
tests/integration/x402-quotes.test.ts |
| a leaked quote signing key | its holder can sign quotes as the merchant. Keep the key unfunded. Rotation changes the published issuer after a restart; doctor detects a gateway still using the old key |
tests/integration/x402-quotes.test.ts, tests/unit/cli/doctor.test.ts |
| unpaid requests that vary the input to force quote signing | each cache miss requires a signature per EVM offer; repeated input reuses a cached quote while a full payment window remains | tests/unit/payments-x402/quote.test.ts |
a quote signing key that controls an EVM payTo, or a well-known Anvil key outside the local chain |
CONFIG_INVALID during provider construction; the message omits the key |
tests/unit/payments-x402/quote.test.ts |
| one Permit2 authorization on two requests at once | settles once; the other gets 409 PAYMENT_REPLAYED. The key is chain, Permit2, owner and nonce |
tests/e2e/payment/x402-permit2.e2e.test.ts |
| a Permit2 offer on a network without Permit2 or its x402 proxy | health fails, naming the missing contract | tests/e2e/payment/x402-permit2.e2e.test.ts |
| a Solana transaction paying another wallet or a smaller amount, presented as the offer | the facilitator refuses it: invalid_exact_svm_payload_recipient_mismatch or invalid_exact_svm_payload_amount_mismatch; balances unchanged |
tests/e2e/payment/x402-solana.e2e.test.ts |
| an echoed Solana offer with an address in another case, or a transaction that does not decode | invalid_payment_requirements, or an invalid_exact_svm_payload_transaction* code, before the facilitator is called |
tests/unit/payments-x402/provider-svm.test.ts, tests/unit/payments-x402/svm.test.ts |
| the same Solana transaction with other signature bytes, while the first copy is settling | 409 PAYMENT_REPLAYED: the key is the network and the message hash, not the signatures; one transfer |
tests/e2e/payment/x402-solana.e2e.test.ts, tests/unit/payments-x402/svm.test.ts |
| a settled Solana transaction presented again | the facilitator's simulation refuses it: invalid_exact_svm_transaction_simulation_failed; balances unchanged |
tests/e2e/payment/x402-solana.e2e.test.ts |
| a Solana broadcast that lands but whose RPC response is an error | the SDK's catch-all without a signature: settlement-uncertain, no delivery |
tests/e2e/payment/x402-solana.e2e.test.ts |
| a Solana settlement already in flight at the facilitator, or confirmed without a visible transfer | duplicate_settlement or post_settlement_transfer_not_confirmed: settlement-uncertain, no delivery |
tests/unit/payments-x402/provider-svm.test.ts |
| an unconfirmed broadcast the facilitator could not record for a retry | the catch-all with a transaction and an errorMessage starting settlement_pending: settlement-uncertain, no delivery |
tests/unit/payments-x402/provider-svm.test.ts, tests/unit/payments-x402/provider-sdk-mocked.test.ts |
| a settlement success naming another network, or no transaction in its family's form | outcome unknown: settlement-uncertain, no delivery |
tests/unit/payments-x402/provider-svm.test.ts, tests/unit/payments-x402/provider-remote-facilitator.test.ts |
| a settlement failure that names a transaction, with a reason other than its family's on-chain failure | outcome unknown: settlement-uncertain, no delivery. The EVM SDK reports a mined transfer whose Transfer log it cannot match this way |
tests/unit/payments-x402/provider-sdk-mocked.test.ts, tests/unit/payments-x402/provider-near.test.ts |
| a Solana RPC serving another cluster, a mint with other decimals, or a facilitator without a fee payer | health fails; without a fee payer no challenge is built either | tests/unit/payments-x402/provider-svm.test.ts, tests/e2e/payment/x402-solana.e2e.test.ts |
| a Solana payee without a token account for the mint | health fails: the payment's transaction cannot create the account | tests/unit/payments-x402/provider-svm.test.ts |
| a Solana facilitator refusing the offer itself: a fee payer it does not manage or that is missing, another scheme or network | no verdict: PAYMENT_PROVIDER_UNAVAILABLE, not a buyer rejection |
tests/unit/payments-x402/provider-svm.test.ts |
| a NEAR delegate for another token, recipient, amount, method or deposit, or with an extra action | refused before the facilitator: invalid_exact_near_payload_*; balances unchanged |
tests/unit/payments-x402/near.test.ts, tests/e2e/payment/x402-near.e2e.test.ts |
| a NEAR delegate in a non-canonical encoding: trailing bytes, other base64, truncated or oversized | invalid_exact_near_payload_signed_delegate_action or invalid_exact_near_payload_shape before the facilitator |
tests/unit/payments-x402/near.test.ts |
| a NEAR delegate signed with a function-call access key | the facilitator refuses it: invalid_exact_near_function_call_key_not_allowed; balances unchanged |
tests/e2e/payment/x402-near.e2e.test.ts |
| another NEAR delegate under the same sender, key and nonce, while the first is settling | 409 PAYMENT_REPLAYED: the key is the access-key nonce, not the delegate bytes; one transfer |
tests/e2e/payment/x402-near.e2e.test.ts, tests/unit/payments-x402/near.test.ts |
| a NEAR delegate signed again with the same nonce after a backend failure or a refused settlement | the retry takes over the rejected attempt; one transfer | tests/e2e/payment/x402-near.e2e.test.ts |
| a settled NEAR delegate presented again | the facilitator refuses it: invalid_exact_near_payload_delegate_action_nonce_already_used; balances unchanged |
tests/e2e/payment/x402-near.e2e.test.ts |
| a NEAR submission whose RPC response is lost | settlement_failed without a hash: settlement-uncertain, no delivery; the transfer lands |
tests/e2e/payment/x402-near.e2e.test.ts, tests/unit/payments-x402/provider-near.test.ts |
| a NEAR settle failure whose reason the SDK does not return before submitting | settlement-uncertain, no delivery; the reason may follow a submission |
tests/unit/payments-x402/provider-near.test.ts |
| a NEAR facilitator failing to read the chain during verify or settle | no verdict: 503 PAYMENT_PROVIDER_UNAVAILABLE. At settle nothing was submitted, so the attempt is recorded rejected and a retry with the same nonce can reserve it |
tests/unit/payments-x402/provider-near.test.ts, tests/unit/core/execution/pipeline.test.ts |
| a NEAR RPC serving another chain, a token without code or with other decimals, or an unregistered payee | health fails | tests/unit/payments-x402/provider-near.test.ts, tests/e2e/payment/x402-near.e2e.test.ts |
EVM token decimals that differ from assetDecimals |
health fails, naming both values | tests/unit/payments-x402/provider-health-mocked.test.ts, tests/e2e/payment/x402-builtin-token.e2e.test.ts |
| x402 options on two networks sharing one RPC endpoint, or mixing testnet and mainnet | CONFIG_INVALID at load |
tests/unit/config/x402-options.test.ts |
| a proof for an x402 offer absent from the resource's own list | refused before the facilitator: invalid_network on another network, invalid_payment_requirements otherwise; balances unchanged |
tests/e2e/payment/x402-multichain.e2e.test.ts, tests/unit/payments-x402/provider-remote-facilitator.test.ts |
| the reason an x402 offer fails its health check, which can name an RPC host | discovery publishes only each offer's status; the gateway logs the reason at debug level |
tests/unit/gateway/server.test.ts |
| replay, sequentially | refused, no second transfer | tests/e2e/payment/x402-settlement.e2e.test.ts, tests/mainnet/x402.smoke.test.ts |
| duplicate concurrent request | settles once, other gets PAYMENT_REPLAYED |
tests/integration/adversarial-payment.test.ts |
| replay after a gateway restart | still refused - the reservation is in SQLite | same |
| an invalid x402 proof over HTTP | 402 with a fresh PAYMENT-REQUIRED carrying an error code; no delivery |
tests/unit/gateway/server.test.ts |
| a settlement rejected by the facilitator | 402 with a failed PAYMENT-RESPONSE; MPP also sends a fresh WWW-Authenticate; no delivery |
tests/unit/gateway/server.test.ts, tests/integration/mpp-carriers.test.ts |
| an x402 replay of a settled attempt | 402 with a fresh challenge whose error is the paid network's replay code: EVM's or NEAR's spent nonce, or duplicate_settlement on Solana |
tests/unit/gateway/server.test.ts, tests/unit/protocols-mcp/result-mapping.test.ts |
| a replay while the first attempt is unfinished | 409 PAYMENT_REPLAYED without a fresh challenge; the original payment may still settle. This includes rejections for a landed transfer or an expired MPP challenge when the provider supplies a replay key |
tests/unit/gateway/server.test.ts, tests/unit/protocols-mcp/result-mapping.test.ts, tests/unit/core/execution/pipeline.test.ts, tests/e2e/payment/restore.e2e.test.ts, tests/integration/mpp-carriers.test.ts, tests/unit/payments-mpp/provider.test.ts |
| a settlement with no verdict | 502 with a failed PAYMENT-RESPONSE; settlement_pending when a transaction hash is known; no delivery |
same |
a backend failure after an MPP settlement (upfront flow) |
the error omits Payment-Receipt; PAYMENT-RESPONSE reports the settlement |
tests/integration/mpp-carriers.test.ts |
a backend failure under the MPP authorization flow |
nothing settles; the same credential again gets 402 PAYMENT_REPLAYED with a fresh challenge and moves no funds |
tests/integration/mpp-carriers.test.ts, tests/e2e/payment/mpp-settlement.e2e.test.ts |
an MPP settlement refused after the backend ran (authorization flow) |
the response is withheld; 402 with a fresh WWW-Authenticate and a failed PAYMENT-RESPONSE |
tests/integration/mpp-carriers.test.ts, tests/unit/payments-mpp/provider.test.ts |
| an x402 A2A extension payment sent twice on one task | after validating a follow-up message, the adapter atomically takes its task before execution; a competing message that loses the take gets TaskNotFoundError without another settlement |
tests/integration/a2a-x402-extension.test.ts, tests/unit/protocols-a2a/pending-store.test.ts, tests/unit/storage-receipts/conformance.test.ts |
| unauthenticated callers filling the store with unpaid A2A extension tasks | new unpaid tasks are refused with GATEWAY_BUSY when the receipt store already holds 256 unexpired tasks. Tasks expire with their requirement or within an hour; expired tasks cannot be paid, and waiting tasks are not evicted |
tests/unit/protocols-a2a/x402-extension.test.ts, tests/unit/protocols-a2a/pending-store.test.ts, tests/integration/a2a-x402-extension.test.ts |
expired authorization (validBefore in the past) |
refused before settlement | tests/e2e/payment |
not-yet-valid authorization (validAfter in the future) |
refused before settlement | tests/e2e/payment |
a {param} in the host position of backend.url |
refused at config load | tests/unit/config/schema.test.ts |
an unknown key nested under an additionalProperties schema |
rejected by the closed schema | same |
a built-in mainnet redeclared as a testnet under payments.x402.networks |
CONFIG_INVALID at load; a built-in network cannot be redeclared |
tests/unit/config/x402-options.test.ts |
the in-process facilitator configured for any network but the local eip155:31337 |
CONFIG_INVALID at load; eip155:84532 gets a pointer to the local id |
same |
| conflicting USDC metadata, or another token in public-network MPP, Solana or NEAR config | CONFIG_INVALID at load |
same |
a resource priced in a label other than USD or USDC against an option that names its network's USDC |
CONFIG_INVALID at load, as when the option omits the asset |
tests/unit/config/x402-options.test.ts |
facilitator auth.type: cdp at a URL other than CDP's, or an RPC URL that is not http(s) |
CONFIG_INVALID at load; neither message repeats the URL's path or query |
tests/unit/config/schema.test.ts, tests/unit/config/x402-options.test.ts |
| remote facilitator timeout during settlement | provider throws PAYMENT_PROVIDER_UNAVAILABLE; pipeline marks the attempt uncertain and returns PAYMENT_SETTLEMENT_FAILED |
tests/unit/payments-x402/provider-remote-facilitator.test.ts, tests/unit/core/execution/pipeline.test.ts |
an unclassified local settle() throw |
provider throws PAYMENT_PROVIDER_UNAVAILABLE; pipeline marks the attempt uncertain and returns PAYMENT_SETTLEMENT_FAILED |
tests/unit/payments-x402/provider-sdk-mocked.test.ts, tests/unit/core/execution/pipeline.test.ts |
a local settle() throw wrapping an RPC error, not a revert |
same as an unclassified throw; not transaction_reverted |
tests/unit/payments-x402/provider-sdk-mocked.test.ts |
| a broadcast that lands but whose RPC response is an error | the SDK's catch-all with no transaction hash; provider throws PAYMENT_PROVIDER_UNAVAILABLE; pipeline records settlement-uncertain, no delivery |
tests/e2e/payment/x402-settlement.e2e.test.ts, tests/e2e/payment/mpp-settlement.e2e.test.ts |
| a Permit2 broadcast that lands but whose RPC response is an error | the SDK's catch-all with its message appended and no hash; handled as the row above | tests/e2e/payment/x402-permit2.e2e.test.ts |
| remote facilitator 401 / 5xx during verification | transport failure, not a buyer rejection: 503 with Retry-After: 5 on the HTTP route |
tests/integration/adversarial-payment.test.ts, tests/unit/gateway/server.test.ts |
| malformed remote-facilitator response | transport failure; never read as a verdict | same |
remote facilitator refuses verification with a 400 and an invalidReason |
a buyer rejection carrying the sanitized reason; any other status with that body stays a transport failure | tests/integration/adversarial-payment.test.ts, tests/unit/payments-x402/provider-remote-facilitator.test.ts |
remote facilitator refuses settlement with a 400 and an errorReason |
a settlement rejection carrying the sanitized reason; any other status, or a body naming a transaction, stays a transport failure | tests/unit/payments-x402/provider-remote-facilitator.test.ts |
facilitator rejection reason is empty, over 128 characters or outside [A-Za-z0-9_.-] |
replaced with invalid_payment or settlement_failed |
tests/unit/payments-x402/provider-sdk-mocked.test.ts |
| backend timeout | BACKEND_TIMEOUT |
tests/unit/core/execution |
backend 500 after an upfront payment |
receipt records paid-and-undelivered; payer told it settled | tests/unit/gateway, tests/unit/core/execution |
| a rejected x402 attempt presented again (backend failure or refused settlement) | the same proof takes over the attempt and settles at most once; of two concurrent retries, one gets 409; the replaced row stays in payment_attempt_history |
tests/unit/storage-receipts/payment-attempts.test.ts, tests/e2e/payment/x402-settlement.e2e.test.ts |
x402 settlement refused after the backend ran (authorization flow) |
the response is withheld; 402 with a failed PAYMENT-RESPONSE |
tests/integration/x402-payment-flow.test.ts |
| payment-attempt reservation or replay-key lookup failure | STORAGE_ERROR, never mislabeled PAYMENT_REPLAYED or answered with a fresh challenge |
tests/unit/core/execution/pipeline.test.ts |
| the journal row cannot be inserted with the replay reservation | STORAGE_ERROR before settlement; no new replay reservation; release of any AP2 hold is attempted |
tests/integration/transaction-journal.test.ts |
| a payment-attempt, receipt or journal write fails after the replay reservation | Delivery or the original commerce error is preserved. Delivered outcomes and terminal events carry known failure labels; each write failure is logged at error level. A failed completed step adds no detail to those snapshots. The pipeline does not add the list to thrown errors |
tests/integration/transaction-journal.test.ts, tests/e2e/payment/crash-matrix.e2e.test.ts |
| a gateway crash during a paid purchase, from before settlement to before the receipt commit, in either payment flow | after restart, operator state reflects recorded steps and unresolved entries remain visible. reconcile can resolve reserved attempts at least --min-age old from final chain evidence, marking them rejected only with proof of an expired, unused authorization. It can also retry pending AP2 holds. After a payment and its hold are resolved, later runs leave them unchanged. Unknown delivery remains for operator review |
tests/e2e/payment/crash-matrix.e2e.test.ts |
| SIGTERM while a settlement is held, in either payment flow, then SIGKILL after the grace period | With AP2 disabled, the gateway drains until killed. After restart, the attempt remains reserved; its replay returns 409 without a challenge and moves no funds, whether or not the original transfer landed |
tests/e2e/payment/crash-matrix.e2e.test.ts |
| shutdown with a request in flight | In-flight requests finish before adapters stop; close() resolves after both. A second SIGTERM or SIGINT exits immediately with status 1 |
tests/unit/gateway/shutdown.test.ts, tests/unit/gateway/main.test.ts |
| a backup taken while the gateway writes, into a directory that holds files, or of a 2.5 database | Each copy is a consistent snapshot at 0600, hashed in backup.json; a new directory is created at 0700. A non-empty --out is refused without writing files. Read-only sources keep their schema and import state, including v4 for 2.5 |
tests/unit/cli/backup.test.ts |
| a restore of a backup older than some purchases | Spent EVM proofs cannot pay again, including those absent from the backup. Attempts restored as uncertain still need reconcile or resolve. An AP2 mandate consumed after the backup can authorize again until it expires |
tests/e2e/payment/restore.e2e.test.ts |
| a receipt write fails before a paid MCP or A2A response | The agent-commerce/delivery summary retains the receipt ID and adds receiptCommitted: false; a successful receipt save leaves the field absent |
tests/integration/receipt-committed.test.ts, demo/agent/test/run.test.ts |
| an unused authorization that can still land | unknown (not_expired) while it remains valid at finality; not-settled requires unused state at a final block beyond EVM's last valid second or NEAR's maxBlockHeight |
tests/e2e/payment/x402-reconcile.e2e.test.ts, tests/e2e/payment/x402-near.e2e.test.ts |
a used EVM nonce without a matching transfer (cancelAuthorization, invalidateUnorderedNonces or missing RPC logs) |
unknown (used_without_record); missing logs or receipts alone do not prove that a payment was not settled |
same |
an RPC serving a chain other than the configured offer's (EVM chain ID, Solana genesis hash, NEAR chain_id) |
unknown (chain_mismatch) before reading state or transfer evidence |
tests/e2e/payment/x402-reconcile.e2e.test.ts, tests/e2e/payment/x402-solana.e2e.test.ts, tests/e2e/payment/x402-near.e2e.test.ts |
| a referenced EVM or NEAR transaction that is not yet final | unknown (not_final); definitive outcomes require final evidence. Solana accepts only successful finalized transactions as settlement evidence |
tests/e2e/payment/x402-reconcile.e2e.test.ts, tests/unit/payments-x402/reconcile-svm-near.test.ts |
| an EIP-3009 attempt referencing another payment's transfer of the same amount | that reference does not prove settlement; settled requires this authorization's AuthorizationUsed log and a matching transfer |
same |
| two Permit2 payments with the same owner, amount and validity window | each attempt must match a proxy call carrying its own nonce | same |
| a Solana or NEAR attempt referencing another payment's transaction | that reference does not prove settlement; Solana requires a matching message hash, and NEAR requires the attempt's delegate (sender, access key and nonce) plus a matching successful transfer receipt | tests/unit/payments-x402/reconcile-svm-near.test.ts, tests/e2e/payment/x402-solana.e2e.test.ts, tests/e2e/payment/x402-near.e2e.test.ts |
| a Solana transaction that never landed | unknown: history_too_long at the search page cap; otherwise not_expired while the blockhash is valid, then blockhash_expired. Expiry alone does not prove not-settled |
tests/unit/payments-x402/reconcile-svm-near.test.ts, tests/e2e/payment/x402-solana.e2e.test.ts |
| a NEAR delegate's nonce used by another transaction, or its access key deleted | unknown (used_without_record or access_key_missing); key state alone does not prove that the delegate executed |
tests/unit/payments-x402/reconcile-svm-near.test.ts, tests/e2e/payment/x402-near.e2e.test.ts |
| competing payment resolutions or a later reconciliation run | the update requires the expected status and no existing resolution, allowing at most one resolution write. Later runs skip payment resolution but can retry pending holds. A losing manual HTTP resolution returns 409 already_resolved, or not_resolvable if the status changed without a resolution. SQLite rejects late pipeline writes to rows with recorded resolutions, logs them at warn and throws STORAGE_ERROR |
tests/unit/core/execution/reconcile.test.ts, tests/e2e/payment/x402-reconcile.e2e.test.ts, tests/unit/core/execution/resolve.test.ts, tests/unit/gateway/operator-transactions.test.ts |
an in-flight reserved attempt younger than --min-age |
excluded from reconciliation candidates; the default minimum age is 15 minutes. Manual resolution also refuses a reserved attempt younger than 15 minutes (too_recent) |
tests/unit/core/execution/reconcile.test.ts, tests/unit/core/execution/resolve.test.ts |
a reserved AP2 hold whose request recorded no payment attempt |
eligible for release through an enabled AP2 provider once its last update is older than --min-age; the gateway reserves an attempt before calling settlement. Current or replaced attempts exclude a hold. Dry runs report scanned holds without releasing them. An omitted or empty list of authorization providers skips both hold scans |
tests/unit/core/execution/reconcile.test.ts |
reconcile or resolve with AP2 enabled and an existing, unimported AP2 file |
STORAGE_ERROR before payment or hold updates; these commands do not import legacy files. The receipt store is initialized after a read-only schema check |
tests/unit/cli/resolve.test.ts |
reconcile or resolve on a receipt database below schema v5, such as one a 2.5 gateway still uses |
STORAGE_ERROR before payment, hold or schema updates; the message directs the operator to run agent-commerce backup, then start 2.6.0 once |
tests/unit/cli/reconcile.test.ts |
reconcile, resolve or doctor run beside a live gateway |
These commands neither import legacy files nor recover ACP claims. reconcile and resolve open the receipt store without legacy import options; doctor opens it read-only. They leave the gateway's existing in-flight ACP claims unchanged |
tests/unit/cli/reconcile.test.ts, tests/unit/cli/doctor-storage.test.ts |
| reconciliation calling a facilitator or broadcasting | x402 and MPP reconciliation read the configured offer's RPC without calling the facilitator or broadcasting | tests/e2e/payment/x402-reconcile.e2e.test.ts, tests/e2e/payment/x402-solana.e2e.test.ts, tests/e2e/payment/x402-near.e2e.test.ts |
| an attempt without a recorded locator | x402 and MPP return unknown (no_locator), leaving the attempt unresolved |
tests/unit/payments-x402/reconcile.test.ts, tests/unit/core/execution/reconcile.test.ts |
| an RPC error during reconciliation | unknown (rpc_error); RPC URLs in the report retain the origin and omit userinfo, path and query |
tests/e2e/payment/x402-reconcile.e2e.test.ts |
| an operator rejecting an attempt before its recorded expiry | resolve and POST /api/payment-attempts/:id/resolve refuse rejected while the recorded expiry is in the future (not_expired). A settled verdict has no expiry check; neither verdict is checked on chain |
tests/unit/core/execution/resolve.test.ts, tests/unit/gateway/operator-transactions.test.ts |
a rejected resolution without a valid recorded expiry, including Solana, NEAR and attempts written before the journal |
refused (expiry_unknown) unless the operator supplies confirmExpired or --confirm-expired; this confirmation does not bypass a future recorded expiry |
tests/unit/core/execution/resolve.test.ts, tests/unit/gateway/operator-transactions.test.ts, tests/unit/cli/resolve.test.ts |
| the operator view or resolve called without the token, with a wrong token, from a disallowed Origin, or with a malformed cursor | a disallowed Origin returns 403 before the token check; otherwise, no configured token gives 404 and a missing or wrong bearer token gives 401 before ledger access. A malformed cursor or unknown state returns INPUT_INVALID; a resolve body over 4 KiB gets 413 |
tests/unit/gateway/operator-transactions.test.ts |
| a journal row, locator, resolution or operator response carrying a proof, signature, mandate, input body or secret | built-in locators contain public chain fields, and core records an input digest when available instead of the body. SQLite redacts values under secret-shaped keys in locators, authorizations and resolutions before writing. Operator routes return those stored values; providers and callers must keep secrets out of other fields | tests/fixtures/x402/locator.ts (through the provider tests), tests/unit/storage-receipts/transactions.test.ts, tests/unit/gateway/operator-transactions.test.ts |
| a run stopping between the attempt write and the hold write | the journal hold remains reserved or uncertain, so a later run with AP2 enabled can retry the update. The reserving request ID accompanies each update, preventing a late release from changing a later reservation |
tests/unit/core/execution/resolve.test.ts, tests/unit/authorization-ap2/replay-store.test.ts, tests/unit/cli/resolve.test.ts |
| a local reader racing the ledger's creation | database is 0600 before SQLite opens it; WAL sidecars stay 0600 | tests/unit/storage-receipts/permissions.test.ts, tests/unit/storage-receipts/persistence.test.ts |
| RPC unreachable during verify | PAYMENT_PROVIDER_UNAVAILABLE, not "bad signature" |
tests/unit/payments-x402 |
an external $ref in an imported document |
refused; zero outbound requests | tests/unit/openapi/load.test.ts |
| an imported path value naming another host | percent-encoded into one segment of the configured origin | tests/integration/openapi-import.test.ts |
| an imported query group colliding with a pinned backend query | INPUT_INVALID before payment; nothing settled |
same |
| an imported operation with an unsupported required parameter | never becomes a resource at all | same |
__proto__ in an imported document, as a $ref segment, parameter name or property name |
refused; a $ref resolves own members only, so it never reaches Object.prototype |
tests/unit/openapi |
| an ACP request with missing, non-bearer, empty or wrong authorization | 401 before the body is read; zero merchant calls | tests/unit/protocols-acp/adapter.test.ts |
| an ACP request naming an unsupported API version | 400 naming supported_versions; never mapped to the pinned one |
same |
an ACP POST with no or an over-long Idempotency-Key |
400; zero merchant calls | tests/conformance/acp/protocol.test.ts |
| an ACP key replayed while the first request is in flight | 409; the merchant is called exactly once | tests/conformance/acp/idempotency.test.ts |
| an ACP operation the merchant acted on before timing out | 409 idempotency_unresolved by default; the gateway makes no second merchant call |
same |
| an ACP retry after a merchant 500 or 503 | claim held by default; with merchantIdempotent, released for retry with the same derived key |
tests/conformance/acp/idempotency.test.ts, tests/conformance/acp/errors.test.ts |
| an ACP retry after a merchant 429 | claim released because the gateway treats 429 as unprocessed; the same key runs again | tests/conformance/acp/idempotency.test.ts |
| an unresolved ACP record outliving its retention window | kept; only a completed record expires | tests/unit/protocols-acp/idempotency.test.ts |
| two ACP claims of one key racing in the receipt database | for the same deployment, endpoint and key, one claim wins and permits one pipeline execution | tests/unit/storage-receipts/conformance.test.ts, tests/unit/protocols-acp/idempotency-receipt-store.test.ts |
| an ACP claim left in flight by a crashed gateway | marked unresolved by default; with merchantIdempotent, released at the next start |
same |
| an ACP claim outliving a bearer-token rotation | kept; the scope is the deployment, never the credential | same |
| an ACP key reused with a different body | 422; the merchant is called exactly once | same |
| a merchant answering an ACP route with a non-ACP document | refused as processing_error; its body never forwarded |
tests/conformance/acp/errors.test.ts |
| a merchant putting an HTML tag or comment in ACP markdown | refused as processing_error; code spans and autolinks remain text |
tests/conformance/acp/errors.test.ts |
an ACP session id containing an encoded / |
stays one path segment and retains its own idempotency scope | tests/unit/protocols-acp/adapter.test.ts |
| a merchant leaking a connection string or stack in an error body | body text is not relayed; a valid ACP error contributes its type, safe code and optional param, while the gateway supplies the message | same |
an ACP Request-Id carrying a header-injection payload |
dropped, never echoed | tests/unit/protocols-acp/adapter.test.ts |
| an ACP caller header naming a header the operator configured | the configured value reaches the merchant | tests/integration/acp-over-gateway.test.ts, tests/unit/core/execution/backend-http.test.ts |
| an ACP bearer token bound for the merchant | never forwarded; only the six listed caller headers are | tests/conformance/acp/protocol.test.ts, tests/integration/acp-over-gateway.test.ts |
| a forwarded header with CR/LF or an illegal name | ACP drops a value that is not printable ASCII or exceeds 2048 characters; the backend executor refuses an illegal forwarded name or value as INPUT_INVALID before pricing |
tests/unit/protocols-acp/adapter.test.ts, tests/unit/core/execution/backend-http.test.ts, tests/unit/core/execution/pipeline.test.ts |
a forwarded header naming Idempotency-Key or Content-Type |
never forwarded; the merchant gets the derived key and the executor's content type | tests/unit/core/execution/backend-http.test.ts |
| a backend page with script, relayed to a browser | delivered as text with Content-Security-Policy: sandbox and nosniff, so the script cannot act as the gateway's origin |
tests/unit/gateway/server.test.ts |
a merchant Retry-After that is huge, an HTTP date or absent |
clamped to 1-300 seconds; 5 when absent or not whole seconds | tests/conformance/acp/errors.test.ts |
a merchant ACP error with an idempotency_* code |
not relayed; the gateway's own code is sent | same |
| an ACP checkout resource configured as paid | refused at config load; a defensive runtime challenge maps to 500 | tests/unit/config/schema.test.ts, tests/unit/protocols-acp/checkout-mapping.test.ts |
an ACP body or path schema that load closes before it accepts what the adapter sends |
refused at config load, not as INPUT_INVALID on every request | tests/unit/config/schema.test.ts, tests/unit/cli/examples.test.ts |
an AP2 mandate with a holder-key binding (cnf) |
AUTHORIZATION_INVALID, unsupported_mandate_type; nothing settles |
tests/unit/authorization-ap2/verifier.test.ts |
an A2A request sent without an id, as a notification |
-32600; nothing runs |
tests/conformance/a2a/errors.test.ts |
an x402 A2A extension payment whose contextId is not the task's |
-32602; the task stays pending |
tests/integration/a2a-x402-extension.test.ts |
an A2A GetTask or CancelTask naming a pending x402 task |
-32001 TaskNotFoundError; the task stays payable |
tests/integration/a2a-x402-extension.test.ts |
| an A2A 0.x follow-up, with a non-user role, on a pending task | -32602; the task stays pending |
same |
| an A2A message carrying a large array of invalid parts or task references | refused by its length (PROTOCOL_UNSUPPORTED) before any element is validated, so no validation issue is built per element |
tests/unit/protocols-a2a/message-mapping.test.ts |
| an AP2 mandate on an x402 extension payment message beside another resource or input | verified against the stored purchase; the other resource and input are ignored | same |
an MCP placeholder _payment beside a real proof in _meta |
the _meta proof settles; _payment is ignored |
tests/integration/mcp-payment-bindings.test.ts |
an MCP _meta["x402/payment"] string that is not base64 PaymentPayload JSON |
PAYMENT_INVALID with a fresh challenge; nothing settles |
same |
a JSON-RPC batch to /mcp under a revision that removed batching |
HTTP 400 with -32600; no tool runs |
tests/conformance/mcp/mcp-conformance.test.ts |
| an AP2 mandate with a tampered signature | AUTHORIZATION_INVALID; nothing settles |
tests/integration/ap2-x402-conformance.test.ts |
| an expired AP2 mandate | AUTHORIZATION_INVALID; nothing settles |
same |
| a mandate from an issuer that is not configured | refused at the trust allowlist, before any signature check | same, tests/unit/authorization-ap2 |
a mandate claiming a trusted kid but signed with another key |
refused at the signature; kid selects the key, never labels it |
same |
a mandate naming a kid the issuer does not have |
refused; no "try every key" fallback | same |
a mandate with no iss whose kid two trusted issuers share |
unknown_key; neither key is tried |
tests/unit/authorization-ap2 |
a mandate token with no aud or exp |
absence alone is allowed by default; strict settings may refuse it, and the checkout JWT has its own required aud and exp |
same |
| a delegation chain or key-bound presentation (autonomous mode) | unsupported_mandate_type; nothing settles |
same |
an AP2 delegation hop presented alone (key-bound typ, top-level sd_hash or issuer_jwt_hash) |
unsupported_mandate_type; nothing settles |
same |
a mandate whose checkout_hash does not match its checkout JWT |
checkout_binding_failed; nothing settles |
same |
| a mandate approved for another resource, input, amount, currency, payment method, network or asset | purchase_mismatch, one coarse reason; nothing settles |
same |
| a mandate silent about the chain the requirement names | refused - fail closed both ways | same |
| a mandate presented twice | AUTHORIZATION_REPLAYED; the second purchase moves no funds |
same, tests/e2e/authorization |
| the same mandate re-presented with a fresh, valid payment proof | still refused; balances unchanged on a real chain | tests/e2e/authorization |
| a mandate replayed under selective disclosure (one mandate, many presentation strings) | refused - the replay key is the issuer-signed token, not the presentation | tests/unit/authorization-ap2 |
| a released mandate re-presented after another mandate reserved or spent the same checkout | refused as replayed; the released row remains released | tests/unit/authorization-ap2/replay-store.test.ts |
| the AP2 replay store unreachable | AUTHORIZATION_PROVIDER_UNAVAILABLE, retryable, never the buyer's fault |
tests/integration/ap2-runtime.test.ts |
| a valid mandate presented without a payment proof | 402 challenge; the mandate is not consumed | tests/integration/ap2-x402-conformance.test.ts |
| a payment proof rejected at verification | the mandate is never reserved; a corrected proof reuses it | same |
| settlement definitively refused after the mandate was reserved | the reservation is released; a corrected proof reuses the mandate | same |
| settlement throws without a verdict, with or without a transaction hash | the mandate is marked uncertain, not handed back | tests/integration/ap2-x402-conformance.test.ts, tests/unit/core/execution/pipeline-authorization.test.ts |
| the AP2 replay store failing to record a consume or release after the payment outcome | the payment outcome is preserved. Pending replay and journal holds stay reserved or uncertain, blocking reuse; reconcile with AP2 enabled can retry the consume or release |
tests/e2e/payment/crash-matrix.e2e.test.ts, tests/unit/core/execution/resolve.test.ts |
| two holds racing on one mandate, or two mandates on one checkout | one reservation wins; the other is refused as replayed | tests/unit/storage-receipts/conformance.test.ts |
| a custom store that reserves one replay key twice | Fails the ./testing cases for repeated reservations, including variations in other fields and concurrent calls. These calls must reserve only one attempt per replay key. Failures outside these cases may go undetected |
tests/unit/storage-receipts/conformance.test.ts |
| an AP2 presentation reaching a stored hold, pending task or idempotency claim | the AP2 provider stores the mandate reference, checkout jti and issuer IDs; A2A keeps parsed mandates in memory. Task input outside reserved fields, adapterData and idempotency bodies are unredacted; callers must exclude presentations |
tests/unit/storage-receipts/no-secrets.test.ts, tests/unit/protocols-a2a/pending-store.test.ts, tests/unit/authorization-ap2/provider.test.ts |
| a mandate spent before the 2.6.0 upgrade, presented after it | refused as replayed after a successful legacy import: hold states are preserved, and an import cannot unblock a reference already held in the receipt database | tests/unit/storage-receipts/legacy-import.test.ts, tests/e2e/payment/upgrade.e2e.test.ts (both payment flows, on Anvil) |
| a deprecated store key removed, or its file missing, before the first 2.6.0 start | start returns STORAGE_ERROR for an enabled feature with past-use evidence and no recorded import or prior check. An empty file or :memory: explicitly accepts the loss; doctor reports that refusal as FAIL |
tests/unit/storage-receipts/legacy-import.test.ts, tests/unit/cli/doctor-storage.test.ts |
| an import failing part way | the failed source's rows and import marker roll back together; earlier source imports remain committed. start stops with STORAGE_ERROR, releases acquired locks and retries the failed source on the next start |
same |
| a 2.5 gateway writing a deprecated file after the import | refused while start holds the file's write lock, which is released at shutdown |
same |
| a 2.5 gateway opening a receipt database 2.6.0 migrated | refused by its schema version check; the file is unchanged | tests/unit/storage-receipts/migration-v5.test.ts |
| a free resource configured to require a mandate | refused at config load, and again on the execution path | tests/unit/config/ap2.test.ts, tests/unit/core/execution |
an oversized Agent-Authorization header |
AUTHORIZATION_INVALID; the pipeline is not called |
tests/integration/authorization-carrier.test.ts |
| an MPP challenge edited after issue, or signed with another gateway's secret | challenge_not_issued |
tests/unit/payments-mpp/provider.test.ts |
| an MPP challenge issued for another resource, price, recipient, asset or network | refused on the binding check, one reason per field | same |
| an MPP credential presented after its challenge expired | challenge_expired, on the gateway's clock |
same |
| an MPP authorization whose nonce is not the challenge hash | wrong_nonce |
same |
| an MPP credential sent with a body other than the one its challenge was issued for | body_digest_mismatch before settlement; the same credential works with the original body bytes |
tests/integration/mpp-carriers.test.ts, tests/unit/payments-mpp/provider.test.ts |
an MPP credential type other than EIP-3009 authorization |
unsupported_credential |
same |
| an MPP authorization signed by someone other than the payer, or for another amount or recipient | invalid_signature, wrong_amount or wrong_recipient |
same |
an MPP authorization outside its validAfter/validBefore window |
authorization_not_yet_valid or authorization_expired |
same |
| a valid MPP credential | local checks, then the facilitator's read-only check; nothing is broadcast | tests/unit/payments-mpp/provider.test.ts |
| an MPP copy that passes the facilitator check while its replay key is already reserved | PAYMENT_REPLAYED; no second settlement |
tests/unit/payments-mpp/provider.test.ts |
| a settled MPP credential presented again | 402 invalid-challenge, reason challenge_already_used, from the facilitator's reason or the token's authorizationState; no second settlement |
tests/integration/mpp-carriers.test.ts, tests/unit/payments-mpp/provider.test.ts |
| the facilitator is unreachable during MPP verification | verify throws PAYMENT_PROVIDER_UNAVAILABLE (503) before anything is reserved |
tests/e2e/payment/mpp-settlement.e2e.test.ts |
| an MPP settlement times out after a possible broadcast | provider throws PAYMENT_PROVIDER_UNAVAILABLE; pipeline records settlement-uncertain |
tests/unit/payments-mpp/provider.test.ts, tests/unit/core/execution/pipeline.test.ts |
an Authorization header in another scheme on an MPP resource |
no payment: 402 with a challenge |
tests/integration/mpp-carriers.test.ts |
an MPP credential in the x402 PAYMENT-SIGNATURE header |
ignored: 402 |
same |
| a valid MPP credential from the mppx client | settled on chain once: buyer down and merchant up by the price, and Payment-Receipt names the transaction |
tests/e2e/payment/mpp-settlement.e2e.test.ts |
| an MPP broadcast that is never confirmed | settlement-uncertain; no response; under upfront the backend never runs; the recorded transaction lands once mined |
tests/e2e/payment/mpp-settlement.e2e.test.ts, tests/unit/payments-mpp/provider.test.ts |
| an MPP buyer with no funds | facilitator check returns PAYMENT_INVALID before broadcast; nothing moved |
same |
| an AP2-gated MPP payment with no mandate, or a mandate approved for x402 | AUTHORIZATION_REQUIRED or AUTHORIZATION_INVALID; nothing settles |
tests/e2e/authorization/ap2-mpp.e2e.test.ts |
an MPP config on Base mainnet without allowMainnet, with a local facilitator, a noncanonical asset or Base Sepolia's EIP-712 name |
CONFIG_INVALID at load under the relevant payments.mpp field |
tests/unit/config/schema.test.ts |
| a valid MPP payment on Base mainnet, then the same credential again | settled once on chain; the later replay is refused with 402 or 409 and moves no funds again | tests/mainnet/mpp-base.smoke.test.ts (real funds, run by hand) |
start with a missing package in an enabled x402, MPP, MCP or AP2 startup import |
CONFIG_INVALID names the setting and package before the gateway listens or opens the receipt store. Solana, NEAR and CDP imports begin during provider construction, but their failures surface in provider health checks or payment requests |
tests/unit/gateway/main.test.ts (each feature, from source), tests/unit/gateway/optional-peer.test.ts (the error mapping), CI bare install (x402, from the built package) |
| a retry after a lost response, on either payment flow | with redelivery, an exact resend or a fresh proof from the same payer gets the stored response: one settlement, one merchant call, one charge |
tests/e2e/payment/safe-retries.e2e.test.ts ("%s flow: %s gets the stored response and the buyer is charged once"), tests/unit/core/execution/pipeline-retry.test.ts ("serves an exact resend with no provider, AP2 or backend call") |
| an exact resend or a fresh proof under a completed key | neither settles: the resend skips verification, and the fresh proof is verified and discarded | tests/unit/core/execution/pipeline-retry.test.ts ("serves an exact resend with no provider, AP2 or backend call", "serves a fresh proof from the same payer, verifying it with no AP2 or settle") |
| another payer's valid proof under a stored key | 409 payment_identifier_conflict after verification (422 on MPP); the error carries no stored body and nothing settles |
tests/unit/core/execution/pipeline-retry.test.ts ("refuses another payer, with no stored body in the error", "refuses a verified proof with an unknown payer"), tests/integration/payment-retries.test.ts ("answers a key used by another payer with a 422 about:blank, never invalid-challenge") |
| a forged or junk proof presented with a known key | the normal PAYMENT_INVALID refusal, without returning a stored response or revealing whether the key exists or what input it holds |
tests/unit/core/execution/pipeline-retry.test.ts ("gives a forged proof with a known key the normal refusal", "tells a junk proof with a used key and other input nothing about the key"), tests/unit/payments-x402/payment-id.test.ts ("refuses a proof signed for another amount, whatever id it carries") |
| a junk proof probing an in-flight or unresolved key | the same PAYMENT_INVALID refusal as for an unused key; only the proof that owns the claim gets the 409 before verification |
tests/unit/core/execution/pipeline-retry.test.ts ("tells a junk proof nothing about an in-flight key", "tells a junk proof nothing about an unresolved key", "refuses an exact resend of an in-flight purchase before verifying it") |
| one key with different input | a verified proof gets 409 payment_identifier_conflict (422 on MPP) after one verify() that moves no funds; the input is neither run nor answered from the stored response |
tests/unit/core/execution/pipeline-retry.test.ts ("refuses the same key with other input once the proof verifies, settling nothing"), tests/integration/payment-retries.test.ts ("answers the same id with other input with a 409 and no challenge") |
| two concurrent requests with one key | one settles; the other gets 409 payment_identifier_in_flight and can retry for the stored response once the first completes |
tests/unit/core/execution/pipeline-retry.test.ts ("lets one of two concurrent requests settle after the other is in flight", "lets one settle when both pass the read before either claims"), tests/e2e/payment/safe-retries.e2e.test.ts ("settles two concurrent proofs with one ID once, then redelivers to the refused request") |
a restart with ACP merchantIdempotent |
the ACP claim is freed for retry; a payment retry key left in flight becomes unresolved instead of being released |
tests/integration/payment-retries.test.ts ("frees an ACP claim under merchantIdempotent and still marks the payment key unresolved"), tests/unit/storage-receipts/conformance.test.ts ("recovery with an endpoint prefix leaves other endpoints alone") |
| a crash during settlement | the key becomes unresolved at startup. Exact resends and fresh proofs get 409 without settlement, even after reconcile records the attempt as not settled. The key expires at its retention deadline |
tests/integration/payment-retries.test.ts ("marks a key left in flight unresolved at start, so a retry gets 409"), tests/e2e/payment/safe-retries.e2e.test.ts ("keeps the ID unresolved before and after reconcile: both retry styles get 409 without settlement") |
a key on a settled purchase with no stored response, such as an upfront backend failure |
while the key is unexpired, 409 payment_identifier_unresolved without a challenge or Retry-After |
tests/e2e/payment/safe-retries.e2e.test.ts ("returns 409 without a challenge for both retry styles after a settled upfront purchase fails delivery, charging once"), tests/integration/payment-retries.test.ts ("answers a key with an unknown outcome with a 409, no Retry-After and no challenge"), tests/unit/core/execution/pipeline-retry.test.ts ("answers an unresolved key with 409 and no challenge, verifying only a fresh proof") |
| an unresolved payment key after its retention window | the key expires like a completed one; retries then follow the normal purchase flow. Unresolved ACP records never expire (see the ACP row above) | tests/unit/core/execution/pipeline-retry.test.ts ("keeps an unresolved key closed until retention ends, then sells again"), tests/unit/storage-receipts/conformance.test.ts ("an expired unresolved payment key reads as absent and can be claimed again") |
| a new key on a spent proof | the replay reservation still refuses it with PAYMENT_REPLAYED, and the new key is released |
tests/unit/core/execution/pipeline-retry.test.ts ("keeps a spent proof under a new key refused, and frees the new key") |
| a junk proof writing a key before verification | no claim is written for a junk proof, a throwing verify or a verified proof with no replay key |
tests/unit/core/execution/pipeline-retry.test.ts ("writes nothing for a junk proof", "writes nothing for a proof whose verification throws", "writes nothing for a verified proof with no replay key") |
| exposure of client keys and proofs | client keys are stored as SHA-256 hashes, and logs redact proof and Idempotency-Key headers. Raw keys and proofs stay out of events and merchant requests; the merchant key derives from the client key hash and payer. The facilitator receives the proof without extensions |
tests/unit/core/execution/pipeline-retry.test.ts ("puts no client key or proof in a log line, an event or an error", "never sends the client key to the merchant"), tests/unit/gateway/logger.test.ts ("redacts every wire header that carries a credential or a proof"), tests/unit/payments-x402/payment-id.test.ts ("sends the facilitator no part of the extension"), tests/integration/merchant-idempotency.test.ts ("sends the merchant no part of the proof") |
a stored response leaking Set-Cookie or cached publicly |
the row keeps only the content type; a redelivery carries Cache-Control: private and, for a text body, the sandbox policy |
tests/unit/core/execution/pipeline-retry.test.ts ("stores only the content type of the backend headers"), tests/integration/payment-retries.test.ts ("redelivers a text body with its type and sandbox policy, and no Set-Cookie", "redelivers an exact resend, marked as a replay, without a second charge") |
an x402 echo that drops or changes required |
extension_echo_mismatch before the facilitator is called |
tests/unit/payments-x402/payment-id.test.ts ("refuses an echo that %s, before the facilitator") |
| a stored response after its retention window | the response is no longer served; retries follow the normal purchase flow. A later claim sweep deletes the expired row | tests/unit/core/execution/pipeline-retry.test.ts ("still serves a retry just before retention ends", "treats a retry after retention as a new purchase"), tests/unit/storage-receipts/conformance.test.ts ("an expired completed claim reads as absent, and other checkout claims never expire") |
| a store write that fails after a charge | the response is still delivered, persistenceFailures gains response, and the key becomes unresolved (or stays in flight if that write also fails); a retry gets 409 and no second charge |
tests/unit/core/execution/pipeline-retry.test.ts ("delivers, and closes the key, when the receipt is not saved", "delivers, and closes the key, when the completed write fails", "delivers, and keeps the key closed, when both writes fail") |
a re-signed retry after an authorization-flow backend failure |
with merchantKey and redelivery, the merchant receives the same Idempotency-Key for the new proof |
tests/unit/core/execution/pipeline-retry.test.ts ("keeps the merchant key across a re-signed retry"), tests/integration/merchant-idempotency.test.ts ("sends the same key when a proof is retried after a backend failure"), tests/e2e/payment/safe-retries.e2e.test.ts ("reuses the merchant key for a re-signed retry after authorization backend failure, charging once") |
two buyers sharing one client key, or merchantKey on a resource without redelivery |
with redelivery, the merchant key hashes the client key with the payer; without it the client key is not read, and the proof's replay key names the operation |
tests/unit/core/execution/merchant-key.test.ts ("gives another %s a different key", "falls back to the replay key when the payer is %s"), tests/unit/core/execution/pipeline-retry.test.ts ("reads no client key for the merchant key without redelivery") |
a failure after the claim: AP2 refusal, replay refusal, storage error, refused settlement or authorization-flow backend failure |
the claim is released and the key can buy again; a possible charge leaves the key unresolved instead |
tests/unit/core/execution/pipeline-retry.test.ts ("leaves the expected claim after: %s", "releases the claim when the mandate is missing", "releases the claim when the replay reservation fails", "lets a released key buy again") |
| a wallet that can no longer afford the price | an exact resend still gets the stored response; a fresh proof gets 402 PAYMENT_INVALID and no charge |
tests/e2e/payment/safe-retries.e2e.test.ts ("redelivers an exact resend from an emptied wallet but rejects a fresh proof with 402 and no charge"), tests/integration/payment-retries.test.ts ("lets a wallet that can no longer pay recover by an exact resend over %s") |
a store without getIdempotencyKey, or a pipeline without deployment, under a resource with redelivery |
CONFIG_INVALID before any provider call; createGateway refuses to start with such a store |
tests/unit/core/execution/pipeline-retry.test.ts ("refuses before any provider call with %s"), tests/unit/gateway/server.test.ts ("refuses a 2.6-era store for a resource with redelivery, before recovering") |
removing redelivery or changing merchant.publicBaseUrl while a key is unresolved |
new requests cannot read the key; a fresh proof follows the normal purchase flow. doctor counts the unresolved key until its retention deadline |
tests/integration/payment-retries.test.ts ("recovers the key without the opt-in, sells again, and doctor still counts the key", "sells again after publicBaseUrl changes, and doctor still counts the key") |
| an MPP key conflict, in-flight or unresolved answer that invites a new challenge | Problem Details with type: about:blank and no WWW-Authenticate; never invalid-challenge, which could prompt a second payment |
tests/integration/payment-retries.test.ts ("answers a key used by another payer with a 422 about:blank, never invalid-challenge", "answers a request in flight as Problem Details with Retry-After", "answers a charged key with no stored response as Problem Details without Retry-After"), tests/unit/payments-mpp/problems.test.ts ("answers the retry-key 409 %s with about:blank, not invalid-challenge") |
| an MPP key conflict read as "retry unchanged" | a key reused with other input or another payer answers 422 Unprocessable Content, not 409; in-flight and unresolved stay 409 Conflict |
tests/integration/payment-retries.test.ts ("answers a key used by another payer with a 422 about:blank, never invalid-challenge"), tests/unit/payments-mpp/problems.test.ts ("answers a retry key reused with other input as 422 about:blank") |
| an exact resend after the proof or challenge expired | the stored response until retention ends, with no verification and no settlement | tests/integration/payment-retries.test.ts ("redelivers an exact resend after its challenge expired, with the receipt"), tests/unit/core/execution/pipeline-retry.test.ts ("serves an exact resend with no provider, AP2 or backend call") |
| a redelivery without the mandate the purchase used | an exact resend falls through to normal verification, and a verified fresh proof gets 409 payment_identifier_conflict; the same holds when the resource began requiring a mandate after the purchase |
tests/unit/core/execution/pipeline-retry.test.ts ("refuses %s the purchase used, serving nothing", "serves %s nothing once the resource requires a mandate it did not when it paid") |
| the payment proof header as a bearer credential | with matching input and any required mandate, PAYMENT-SIGNATURE retrieves the stored response until retention ends. Keep it out of proxy, APM and access logs; the gateway's logger redacts it |
tests/integration/payment-retries.test.ts ("redelivers an exact resend, marked as a replay, without a second charge"), tests/unit/gateway/logger.test.ts ("redacts every wire header that carries a credential or a proof") |
| a retention window outside 1 to 8760 hours | config load refuses it, and a pipeline built without config throws CONFIG_INVALID before any payment work |
tests/unit/config/schema.test.ts ("refuses a retention of %s hours as too long", "refuses a retention of %s", "accepts the longest retention, one year"), tests/unit/core/execution/pipeline-retry.test.ts ("refuses a retention of %s hours before any provider call", "accepts a retention of one year") |
| an A2A in-flight refusal that tells the client to resend on the consumed task | the failed task reports retryable: false with no retryAfterSeconds; the client starts a new task with the same payment id |
tests/unit/protocols-a2a/payment-resume.test.ts ("does not tell the client to retry an in-flight payment id on the consumed task") |
A facilitator verdict is a returned result. The SDK throws when a remote
/verify answers with a non-2xx status; the binding turns that throw back into
a verdict only for a 400 whose body names an invalidReason. Every other
remote-facilitator throw, and every verification throw from the local
facilitator, becomes PAYMENT_PROVIDER_UNAVAILABLE.
With the local binding, the pinned SDK returns a result for every settlement
failure from gas estimation through the receipt wait, so a settlement throw
comes from its checks before anything is sent. The provider rejects such a
throw as transaction_reverted when a contract revert is in its cause chain.
The rule depends on that ordering: viem also reports an RPC error on a send as
a revert, so a revert alone does not prove that nothing moved. Any other
settlement throw becomes PAYMENT_PROVIDER_UNAVAILABLE; the pipeline records
the attempt settlement-uncertain and returns PAYMENT_SETTLEMENT_FAILED.
For a failure it catches, the SDK returns invalid_exact_evm_transaction_failed
(invalid_exact_svm_transaction_failed on Solana) unless the error message
matches a known failure such as a used nonce. On the Permit2 path the reason
also carries the error message after a colon. The reason comes with a
transaction in two cases: the transaction failed on chain, or the SDK could not
confirm it and could not record it for a retry. The second has an
errorMessage starting with settlement_pending, and the provider treats it
as an unknown outcome. Any other failure that names a transaction is unknown
too: the EVM SDK reports a mined transfer whose Transfer log it cannot match
that way. Without a transaction the provider cannot tell a refused
call from a lost response, because of that viem behavior, so it throws
PAYMENT_PROVIDER_UNAVAILABLE. A revert during gas estimation that the SDK
does not recognize is therefore also recorded settlement-uncertain, although
nothing was sent.
Facilitator rejection reasons reach clients and storage only after trimming and
a 128-character, [A-Za-z0-9_.-] check. Other values become the fixed token
invalid_payment during verification or settlement_failed during settlement;
the raw value, with any URL cut to its origin, is available only in debug logs.
The importer reads one local, operator-supplied document and writes ordinary resource configuration for review. It is not on the request path.
- It rejects sources over 10 MiB and all external
$refvalues. Internal references have cycle and depth checks. - It makes no network requests and does not walk the filesystem.
- OpenAPI security declarations produce a warning and review comment; they do not import credentials or turn security headers into caller input.
- Vendor extensions cannot set pricing, payments, backend URLs, or exposure.
- Existing output requires
--force; failed runs write nothing; successful writes use a temporary sibling and rename. - The importer infers neither pricing nor exposure.
--freecan add free pricing and--exposecan add exposure; paid pricing must be written by hand. Imported resources then use the same validation and execution pipeline as handwritten resources.
See OpenAPI import for supported syntax and limitations.
The merchant's gateway handles individual purchases. Buyer wallets or agent providers must enforce budgets, per-task limits and kill switches across payments.
- x402's default
authorizationflow settles after backend success; backend failure leaves the payment unsettled.upfront, MPP's default, settles first. If the backend then fails, the gateway attempts to record an undelivered receipt and tells the buyer payment settled. See the fail-closed matrix. - A signed payment authorization can settle at most once. Fresh authorizations can fund repeated purchases; replay checks do not detect buying loops. See replay.
- A resource can require an experimental AP2 mandate bound to its exact input, price and settlement terms. Open mandates are rejected because their spending limits are not evaluated. See AP2.
- Receipts and events share a
requestId; operators read them at/api/receiptsand/api/events. They are not dispute-grade evidence; see AP2 receipt data.
The gateway offers no escrow, refunds or chargebacks. Payments settle directly
to the merchant; the gateway holds no buyer funds and needs no merchant wallet
key. Refunds come from the merchant's own wallet. Use
GET /api/receipts?undelivered=true to find paid purchases whose delivery failed.
An uncertain settlement leaves no receipt even though funds may have moved.
Use GET /api/transactions?unresolved=true to find journal entries awaiting a
payment or delivery outcome. GET /api/payment-attempts?uncertain=true also
covers settlement-uncertain and legacy failed attempts written before the
journal. agent-commerce reconcile checks EVM, Solana and NEAR evidence and
resolves attempts with a definite outcome. Check remaining attempts on chain
using the recorded transaction ID when available, then record the verdict
with agent-commerce resolve or POST /api/payment-attempts/:id/resolve.
doctor reports the count when it is nonzero.
The gateway attempts to save a receipt before sending the response. If the
connection then drops, a saved receipt for a successful backend call still
counts as delivered and is excluded from ?undelivered=true. Without
idempotency.redelivery, the gateway does not redeliver the response or accept
the settled payment proof again. The buyer must pay for a new request or ask the
merchant for a refund.
A resource with idempotency.redelivery (experimental, disabled by default)
lets the buyer retry a purchase with its original key. See
retry after a lost response.
- Reuse one random key per purchase. The SDK's
appendPaymentIdentifierToExtensionsgenerates a newidunless given one; a new key can start a second purchase. - An exact resend of the purchase's proof gets the stored response, even after the proof has expired or when the wallet can no longer cover the price. A fresh proof gets it only if it verifies as the same payer, and it is never settled. If verification refuses it, for example because the wallet can no longer cover the price, the buyer gets 402 and nothing is charged.
- The retry must carry the same input and, on a resource that requires a
mandate, the same mandate. A verified proof with other input or another
mandate gets 409
payment_identifier_conflict, or 422 on MPP. An exact resend without the mandate is verified like any other proof. - A purchase that may have charged but has no stored response returns 409
payment_identifier_unresolveduntil retention ends. After expiry, removal ofredeliveryor a change tomerchant.publicBaseUrl, reusing the key follows the normal purchase flow.
Stored merchant responses, including any personal data, are unencrypted in the
receipt database and its backups. retentionHours (1 to 8760, default 24) limits
reuse, not how long every copy exists: a later claim sweep deletes expired rows,
and backups persist until separately removed. Enable redelivery per resource
with this in mind, and set the window only as long as buyers need to retry.
Buyer identity and screening · fraud and disputes · refunds and chargebacks · multi-tenancy and RBAC · host compromise · supply-chain attestation · side-channel and timing analysis · protocol-level censorship or MEV around settlement · availability guarantees · a malicious facilitator withholding settlement (it cannot redirect funds, but it can decline to broadcast, and fail-closed means the resource is simply not delivered).