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.
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 and Agent-Authorization 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.
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.
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 |
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 idempotency database 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
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.
@coinbase/x402 is loaded dynamically only for facilitator auth.type: cdp.
It adds @coinbase/cdp-sdk, axios, and a Solana dependency tree, so audit that
optional installation before using it. Static-token facilitators can use
auth.type: bearer without this peer.
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.
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. Built-in mainnets require their canonical USDC
address and EIP-712 domain; declared mainnets have no default asset to check.
With auth.type: none, also set allowUnauthenticatedFacilitator: true.
Config validation and provider construction share these checks. 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 |
| 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 |
| 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, with no fresh challenge; the first attempt may still settle |
same |
| 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 | task removed before payment; second message gets TaskNotFoundError without settling again |
tests/integration/a2a-x402-extension.test.ts |
| unauthenticated callers filling memory with unpaid A2A extension tasks | 256 pending tasks per adapter, each expiring with its requirement or within an hour. A full store fails new purchases with GATEWAY_BUSY and never evicts a waiting task |
tests/unit/protocols-a2a/x402-extension.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 |
| an asset field that contradicts the network's USDC, or another token on a public built-in network | 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 failure | STORAGE_ERROR, never mislabeled PAYMENT_REPLAYED |
tests/unit/core/execution/pipeline.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 |
| 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 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 |
| 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 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.
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).