Reference
Configuration
The complete config.yaml reference: every block, every rule the loader enforces, and why.
One file, config.yaml, validated before the server starts. Generate one with agent-commerce init or start from config.example.yaml.
Principles
- Validated, not trusted. YAML is parsed, then validated with Zod. Unknown keys fail rather than being ignored.
- Fail before startup. An invalid configuration stops the process with a message naming the config path and what was expected.
- Secrets by reference only.
${VAR}placeholders resolve from the environment; an unresolved variable is an error that names the variable. - Explicit version.
version: 1. A future version is rejected, not guessed.
File location
The loader resolves the config path in this order:
- --config
Pass
--configtovalidate,start,doctor,reconcile,resolveorbackup, or callloadConfig({ path })in code. - AGENT_COMMERCE_CONFIG
The environment variable, resolved against the working directory.
- config.yaml
The default, in the working directory.
Top level
| Key | Required | Purpose |
|---|---|---|
version | yes | must be 1 |
merchant | yes | id, name, publicBaseUrl |
server | yes | port, host, adminToken, allowedOrigins |
storage.receipts | yes | driver: sqlite, path |
protocols | yes | which surfaces are enabled |
resources | yes | the capabilities you expose |
payments | yes | rail configuration; {} when every resource is free |
authorization | no | AP2 mandate verification |
version: 1
merchant:
id: demo-store
name: Demo Data Store
publicBaseUrl: ${GATEWAY_PUBLIC_BASE_URL}
server:
port: ${GATEWAY_PORT}
host: 0.0.0.0
adminToken: ${ADMIN_TOKEN} # key omitted => operator routes 404
allowedOrigins: # browsers only; empty by default
- ${DASHBOARD_ORIGIN}
storage:
receipts:
driver: sqlite
path: ${RECEIPT_STORE_PATH}
protocols:
http:
enabled: true
mcp:
enabled: true
mountPath: /mcp
a2a:
enabled: false
mountPath: /a2a
acp:
enabled: false
mountPath: /acpResources
The map key is the resource id, so it must be unique. It is the HTTP path segment and, for a resource exposed via mcp, the tool name, which config load checks against MCP's tool-name rule (A-Z a-z 0-9 . _ -, 1–128 characters).
resources:
market_report:
name: Premium Market Report
description: Latest premium market analysis.
mimeType: application/json # default; what the API returns, e.g. text/html
input: # JSON Schema for the agent-visible input
type: object
properties: {}
additionalProperties: false
backend:
type: http
method: GET
url: ${MERCHANT_API_BASE_URL}/api/report
timeoutMs: 10000 # bounded, always
headers: # secrets by reference only
Authorization: Bearer ${BACKEND_TOKEN}
pricing:
type: fixed # free | fixed (dynamic is rejected)
amount: "0.01" # decimal string, never a float
currency: USDC
expose: [http, mcp]
payments: [x402]Resource fields
namestringrequireddescriptionstringinputJSON Schemainput gets an empty closed schema — declaring nothing means accepting nothing.backend.methodGET | POST | PUT | PATCH | DELETErequiredbackend.urlstringrequired{param} templating from validated input, URL-encoded. Administrator configuration only.backend.timeoutMsnumberdefault: 10000backend.headersmap${VAR}. Each must be a legal HTTP header name and value; config load refuses one that fetch would reject and names the header, never its value. A configured header wins over the same header forwarded by ACP from the agent.backend.inputBindingsobjectpricing.typefree | fixedrequiredpricing.amountdecimal stringfixed. Display units, e.g. "0.01".pricing.currencystringfixed. USDC or USD on an offer that settles in USDC; USDC for MPP.expose(http | mcp | a2a | acp)[]requiredprotocols.payments(x402 | mpp)[][x402]. An ordered list: the first enabled rail serves the resource. A request cannot choose a rail, and nothing falls back to the next one after a failure.x402.acceptsoffer[]paymentFlowauthorization | upfrontpaymentFlow for this resource, on whichever rail serves it. Allowed only on a fixed resource whose payments lists x402 or mpp.mimeTypestringdefault: application/jsontext/html. Each x402 challenge declares it as resource.mimeType before the buyer pays; an HTTP delivery keeps the backend's own Content-Type when it is text and fits the body. Allowed only on a fixed resource whose payments lists x402.authorization.requiredstring[][ap2]. Only on paid resources.backend.inputBindings
Without it (the legacy behavior), {param} values come from top-level input and everything left over becomes the query string for GET/DELETE or the JSON body otherwise. With it, each group is sourced independently, so one operation can carry path, query and body at once — and input no binding names is not forwarded at all.
input:
type: object
properties:
path:
type: object
properties:
userId: { type: string }
required: [userId]
query:
type: object
properties:
notify: { type: boolean }
body:
type: object
properties:
productId: { type: string }
required: [path]
backend:
type: http
method: POST
url: ${MERCHANT_API_BASE_URL}/users/{userId}/orders
inputBindings:
path: path
query: query
body: bodyConfig load rejects, before the gateway starts:
- a binding to a property the input schema does not declare;
pathorquerybound to something that is not an object schema;- two locations bound to one property, or a binding to the reserved
_paymentor_authorization; - a
bodybinding onGETorDELETE; - explicit bindings with no
pathbinding whileurlis templated; - a path group not in the input's
required, or a{param}not declared and required inside it.
At request time the same rules run before pricing, so a request that breaks them is refused before any payment. Two checks run only at the backend call, which follows settlement under the upfront flow: a URL that cannot be parsed, and path parameters that resolve outside the template's literal prefix.
Payments
payments:
x402:
enabled: true
network: ${X402_NETWORK:-eip155:31337} # CAIP-2; the local chain
rpcUrl: ${X402_RPC_URL:-http://127.0.0.1:8545}
asset: ${X402_ASSET} # ERC-20 with EIP-3009
assetName: ${X402_ASSET_NAME} # EIP-712 domain name
assetVersion: ${X402_ASSET_VERSION}
assetDecimals: ${X402_ASSET_DECIMALS}
payTo: ${MERCHANT_WALLET} # merchant-controlled. NEVER the gateway's.
maxTimeoutSeconds: 120
paymentFlow: authorization # default; or upfront
facilitator:
mode: local # eip155:31337 only
signerPrivateKey: ${X402_FACILITATOR_PRIVATE_KEY}payments.x402
networkCAIP-2requiredrpcUrlhttp(s) URLnetworks.<id>.rpcUrl. Health checks and reconcile use it; remote facilitators handle settlement.payTostringrequiredvalidate checks an EVM address's form; gateway startup also checks the EIP-55 checksum of a mixed-case one.asset / assetName / assetVersion / assetDecimalsasset and assetDecimals for Permit2; assetName is the EIP-712 domain name, not the symbol. See The settlement asset.assetTransferMethodeip3009 | permit2default: eip3009maxTimeoutSecondsnumberrequiredauthorization flow, backend time counts toward it: the buyer's authorization must still be valid at settlement.paymentFlowauthorization | upfrontdefault: authorizationauthorization settles only after the backend succeeds, so a backend failure settles nothing; a refused or uncertain settlement withholds the backend's response. upfront settles first, and a later backend failure leaves the payment settled. Use upfront when backend side effects must follow payment.facilitatorobjectrequiredmode: local with signerPrivateKey, on eip155:31337 only, or mode: remote with url and an optional auth, allowed on any network and required on every public one.networksmaprpcUrl for a built-in network, or kind, displayName and rpcUrl to declare another EVM network.optionsoffer[]allowMainnetbooleantrue on any mainnet. Never a default.allowUnauthenticatedFacilitatorbooleanfacilitator.auth.type is none.quote{ enabled, signerPrivateKey }Network specifics and mainnet guardrails: Networks & facilitators. The samples below show only the fields they change; every payments.x402 block also needs enabled, an RPC endpoint, maxTimeoutSeconds and facilitator.
The settlement asset
- Without asset fields, a public built-in network settles in its USDC with the EIP-712 domain and decimals from the network registry. Price those resources in
USDCorUSD;USDis USDC at face value, with no exchange-rate lookup. - x402 accepts other tokens on built-in, local and declared EVM networks. Set all four asset fields (
assetandassetDecimalsfor Permit2); none are inherited from USDC. Health fails if the token's on-chain decimals differ from the configuration. It checks the facilitator's advertised network and scheme, so confirm token support separately. Solana and NEAR offers require their network's USDC. - Overrides of the default token must match its metadata. Naming its address is the same as omitting it, so those resources are priced in
USDCorUSDtoo. A payment in another token uses the resource's label.
Permit2
For an ERC-20 without transferWithAuthorization, set assetTransferMethod: permit2 to use Permit2 and the x402 exact Permit2 proxy. Permit2 signs under its own EIP-712 domain, so assetName and assetVersion are not needed.
payments:
x402:
network: eip155:31337
payTo: ${MERCHANT_WALLET}
asset: ${TOKEN_ADDRESS}
assetDecimals: 18
assetTransferMethod: permit2- The buyer first approves Permit2,
0x000000000022D473030F116dDEE9F6B43aC78BA3, to spend the token. The gateway sends no approval and accepts no gas-sponsoring extension; without the allowance the invoke route answers412 Precondition Failed. - Health checks require code at Permit2 and at the proxy,
0x402085c248EeA27D92E8b30b2C58ed07f9E20001. For a remote facilitator they check only that it listsexacton the network, not that it settles Permit2.
Solana
Solana offers settle the network's USDC through a remote facilitator that pays the transaction fee. They need the optional peers @x402/svm and @solana/kit, loaded only for a Solana offer.
payments:
x402:
network: solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1 # built-in Solana Devnet
rpcUrl: ${SOLANA_DEVNET_RPC_URL}
payTo: ${SOLANA_MERCHANT_WALLET} # no asset fields: the network's USDC
facilitator:
mode: remote
url: ${X402_FACILITATOR_URL}- The merchant wallet's USDC token account must already exist; the SDK client does not create it, and health fails without it.
- Each offer names the facilitator's fee payer from its
/supportedresponse. Without one, readiness fails and no challenge is built. - Health checks the RPC's genesis hash against the network id, the mint's decimals, and that the facilitator lists the network.
- A signed transaction expires with its blockhash, about 60 to 90 seconds after signing. Use
upfrontfor a backend that can take longer.
NEAR
NEAR offers settle the network's NEP-141 USDC through a remote facilitator. The buyer signs a NEP-366 delegate action holding one ft_transfer; the facilitator's relayer submits it and pays gas. The optional peer @x402/near loads only for a NEAR offer. Support is partial: no payment has settled through a hosted facilitator yet.
payments:
x402:
network: near:testnet # built-in NEAR Testnet
rpcUrl: ${NEAR_TESTNET_RPC_URL}
payTo: ${NEAR_MERCHANT_ACCOUNT} # no asset fields: the network's USDC
facilitator:
mode: remote
url: ${X402_FACILITATOR_URL}payToandassetare NEAR account ids: named, subaccounts or implicit 64-hex accounts. The suffix does not select the network.payTomust hold a storage deposit with the token (NEP-145storage_deposit), or every transfer to it fails. Health fails until it does.- The buyer signs with a full-access key:
ft_transferneeds one attached yoctoNEAR, which a function-call key cannot attach. The buyer needs no NEAR for fees, but an implicit account exists only once it has received some. - The signed delegate names no chain, so use separate buyer keys for mainnet and testnet. A delegate that expires during a slow backend call is refused at settlement; use
upfrontfor handlers that can outlastmaxTimeoutSeconds. - Verification saves relayer accounts from the facilitator's
/supportedresponse, cached for 60 seconds, forreconcile. If the lookup fails or takes too long, the gateway logs a warning and omits the relayers. An otherwise valid payment still passes verification, butreconcilethen lacks the accounts needed to find its transaction by hash.
Several options
options offers x402 resources on several networks or in several tokens; the buyer pays one offer. Entries take the fields above, and a one-entry list resolves like the direct form. All offers settle through the facilitator under payments.x402, which must list each network and the scheme.
payments:
x402:
networks:
eip155:8453:
rpcUrl: ${BASE_RPC_URL}
eip155:137:
rpcUrl: ${POLYGON_RPC_URL}
options:
- network: eip155:8453
payTo: ${MERCHANT_WALLET}
- network: eip155:137
payTo: ${MERCHANT_WALLET}- Config load rejects mixing
optionswith the direct fields, an empty list, an object in place of the list, a repeated (network, asset,payTo) combination, and offers in different deployment modes. - Each network needs its own RPC endpoint for health checks; URLs that parse to the same address, such as a bare host with and without a trailing slash, count as one.
- With default assets, price the resource in
USDor the offers' shared token symbol. Decimal precision cannot exceed the least precise offer. Offers charge the same amount in their own tokens, without currency conversion; token values can differ. - The proof's
acceptedecho selects the offer before verification. An AP2 mandate binds to the selected destination, network and asset, so a mandate for one offer cannot authorize another.
A resource's own offers
A paid resource's x402.accepts replaces the rail's offers for that resource, like the x402 SDK's route-level accepts. Entries take the options fields and each charges the resource's pricing. The rail's own offers stay required and serve every other resource.
resources:
market_report:
# backend, pricing and expose as above
payments: [x402]
x402:
accepts:
- network: eip155:137
payTo: ${POLYGON_MERCHANT_WALLET}Entries share the rail's networks, RPC endpoints, facilitator, mainnet acknowledgements and deployment mode. Config load rejects an empty list, repeated entries, and x402.accepts on a free resource, without x402 in payments, or without payments.x402.
ERC-8409 quotes
Experimental and off by default. With quote enabled, every x402 challenge whose input can be canonicalized carries one ERC-8409 quote per EVM offer, signed with EIP-712, so a buyer can check the terms and the signer before paying. The ERC is a draft; the gateway follows commit 0ee33230.
payments:
x402:
quote:
enabled: true
signerPrivateKey: ${X402_QUOTE_SIGNER_KEY} # dedicated; holds no funds- Use a dedicated key that signs nothing else and holds no funds. Never reuse a merchant's or buyer's key. Anyone holding the key can sign quotes as you; signing a quote does not transfer funds. The gateway keeps the key in memory and publishes its issuer address.
signerPrivateKeyis required whenenabledis true. Provider construction fails withCONFIG_INVALIDfor a malformed key, a well-known Anvil key outside local deployment mode, a key that controls an EVMpayTo, or a provider with no EVM offer.- Restart the gateway after rotating the key;
doctorcompares the configured key's address with the published issuer. - Quotes add roughly 0.9 KB of base64 per EVM offer and 1 KB of schema. A small challenge can grow from about 0.6 KB to 2.5 KB with one offer; metadata affects the size. Size your proxy's header buffers for your challenges; see Deploy to a server.
- Each cache miss signs once per EVM offer. Solana, NEAR and MPP challenges carry no quotes. Not implemented: payer-bound quotes, contract issuers, single-use enforcement or revocation, remote signers and per-resource toggles.
payments.mpp
Experimental. MPP accepts the USDC currency label on the local chain eip155:31337, Base Sepolia eip155:84532 and Base mainnet eip155:8453. It defaults to Base Sepolia, so a local config names eip155:31337. The gateway builds MPP's settlement provider from this block, so payments.x402 need not be enabled as a resource rail.
payments:
mpp:
enabled: true
network: eip155:84532 # the default; eip155:31337 locally, eip155:8453 on Base
rpcUrl: ${MPP_RPC_URL}
asset: ${MPP_ASSET} # token contract on the selected RPC
assetName: USDC # EIP-712 domain name; "USD Coin" on Base
assetVersion: '2'
recipient: ${MERCHANT_WALLET} # merchant-controlled; not gateway-owned
realm: api.example.com # the challenge realm, single line
challengeSecret: ${MPP_CHALLENGE_SECRET} # minimum length 32
challengeTtlSeconds: 300 # optional
paymentFlow: upfront # default; or authorization
facilitator:
mode: remote
url: ${MPP_FACILITATOR_URL}facilitator takes the same local and remote forms as x402. While MPP is enabled, every paid resource listing mpp must use the USDC currency label with at most 6 fractional digits. On eip155:8453 MPP is held to the same mainnet guardrails as x402.
paymentFlow takes the x402 values but defaults to upfront: MPP's charge intent forbids running the backend before the payment is verified, which for the EVM method includes the transfer. Under authorization, the backend must answer before the challenge expires, challengeTtlSeconds after it was issued (300 by default); otherwise settlement is refused and the response withheld. Terminate TLS in front of a deployed MPP endpoint: the core draft forbids issuing challenges over plain HTTP.
protocols.acp
protocols:
acp:
enabled: true
mountPath: /acp # default
auth:
type: bearer # the only supported scheme
token: ${ACP_BEARER_TOKEN}
idempotency: # optional
retentionHours: 24 # default, and the floor
merchantIdempotent: false # true only if the merchant deduplicates by Idempotency-Key
checkout:
operations: # all five, each on its own resource
createCheckoutSession: acp_checkout_create
updateCheckoutSession: acp_checkout_update
getCheckoutSession: acp_checkout_get
completeCheckoutSession: acp_checkout_complete
cancelCheckoutSession: acp_checkout_cancel
discovery: # optional; omitted from discovery when absent
documentationUrl: https://merchant.example.com/docs/acp
supportedCurrencies: [usd]
supportedLocales: [en-US]
interventionTypes: [3ds]Every mapped resource must exist, carry expose: [acp], and be free with no payments: ACP checkout carries the merchant's own purchase payment, and charging to invoke the operation would put two payment layers on one call. Each resource serves exactly one operation.
Each operation sends a fixed envelope: path carries { checkout_session_id } and body carries the ACP document, so bind them with backend.inputBindings. Config load rejects a schema that forbids a key the adapter may send, or requires one it does not always send — a closed cancel schema declares body without requiring it. Declare body as { type: object, additionalProperties: true }: the loader closes every object schema that omits additionalProperties, and refuses a body left with no keys to accept, or a closed path without checkout_session_id. A full example is in ACP checkout.
retentionHours bounds completed records only. A record whose merchant outcome was never learned is kept until an operator clears it. The gateway forwards a derived Idempotency-Key to your backend on every side-effecting call; store and replay results under it. Since 2.6.0, claims use their own table in the receipt database. Enabled ACP warns if the deprecated idempotency.path is set; see Deprecated store files.
Set merchantIdempotent: true only if your backend does that. The gateway then releases a claim after a merchant 5xx so the same key can be retried, as ACP requires. Without merchant deduplication, that retry could repeat the side effect, so by default the claim is held.
authorization.ap2
authorization:
ap2:
enabled: true
specVersion: "0.2.0" # the only supported value
mode: direct # the only supported mode
clockSkewSeconds: 60 # default; 300 is the ceiling
requireMandateAudience: false # default; AP2 makes aud optional
requireMandateExpiry: false # default; AP2 makes exp optional
trust:
mandateIssuers:
- issuer: https://surface.example
audience: merchant.example
keys:
- kid: mandate-2026-01
jwk: { kty: EC, crv: P-256, x: "...", y: "..." }
checkoutIssuers:
- issuer: https://merchant.example
audience: agent-commerce
keys:
- kid: checkout-2026-01
jwk: { kty: EC, crv: P-256, x: "...", y: "..." }Public keys only; nothing is fetched. Config load refuses requiring ap2 while this block is absent or disabled, requiring it on a free resource, and a clockSkewSeconds above 300. See AP2 mandates.
Since 2.6.0, mandate replay state uses the receipt database. Enabled AP2 warns if deprecated replay.path is set. Legacy paths must differ from the receipt database and each other, except :memory:. See Deprecated store files.
requireMandateAudience refuses a mandate token without aud; requireMandateExpiry refuses a mandate with no exp on either the token or its content. The checkout JWT requires both claims regardless. A token without iss is matched by kid alone, so give mandate issuers distinct kids.
JSON Schema support
The input validator supports type, properties, required, additionalProperties, primitives and enum. minLength, pattern, format and friends are silently ignored, and agent-commerce validate warns when a resource uses one.
{param} — minLength: 1 is not enforced. The gateway rejects empty, . and .. path parameters itself, before any payment is taken; validate everything else in your own API.Validation rules
agent-commerce validate runs the config-load checks the gateway runs at startup (provider construction then adds RPC, key and checksum checks) and fails on:
- unknown keys, missing required fields, unsupported
version; - unresolved
${VAR}, duplicate resource ids,pricing.type: dynamic; - a paid resource with no
payments, or none of whose listed rails is enabled; exposevalues outside[http, mcp, a2a, acp], or naming a disabled protocol;- A2A enabled with no resource exposed through
a2a; - a resource
paymentFlowwithoutfixedpricing andx402ormppin itspayments; - overlapping protocol mounts, or a mount on a public or ledger route such as
/healthor/api/receipts; - an ACP checkout mapping that is incomplete, names a missing or non-ACP resource, reuses one resource for two operations or maps a paid one, or a retention below 24 hours;
- an ACP resource whose
pathschema is closed withoutcheckout_session_id, or whosebodyschema accepts no keys; - an invalid or zero
payTo,assetor MPPrecipient, and every mainnet guardrail; - an unknown network, a redeclared built-in network, or the local facilitator on any network but
eip155:31337; - asset fields that contradict the network's USDC, another token for MPP, Solana or NEAR, or a price in neither
USDnorUSDCon an offer that settles in USDC; - x402
optionsthat are empty, repeated, mixed with the direct fields, in different deployment modes or sharing one RPC endpoint, and anx402.acceptslist that is empty or on a resource that cannot use it; facilitator.auth.type: cdpwith aurlother than CDP's, or anrpcUrlthat is nothttp://orhttps://;- an MPP
challengeSecretshorter than 32 characters, or a multi-linerealm.
Environment variables
${VAR} works in any string value, and ${VAR:-default} supplies a fallback. The demo uses these:
GATEWAY_PORT=8080
ADMIN_TOKEN=local-demo-admin-token
DASHBOARD_ORIGIN=http://localhost:5173
GATEWAY_PUBLIC_BASE_URL=http://localhost:8080
LOG_LEVEL=info
MERCHANT_API_PORT=3000
MERCHANT_API_BASE_URL=http://localhost:3000
RECEIPT_STORE_PATH=./data/receipts.sqlite
X402_NETWORK=eip155:31337
X402_RPC_URL=http://localhost:8545
# Filled from .deploy/local.json, which npm run chain:deploy writes
# X402_ASSET=
# X402_ASSET_NAME=MockUSDC
# X402_ASSET_VERSION=2
# X402_ASSET_DECIMALS=6
# MERCHANT_WALLET=0x70997970C51812dc3A010C7d01b50e0d17dc79C8
# X402_FACILITATOR_PRIVATE_KEY=
# Remote facilitator and MPP, unset for the local demo
# X402_FACILITATOR_URL=https://x402.org/facilitator
# X402_FACILITATOR_TOKEN=
# MPP_CHALLENGE_SECRET= # at least 32 characters: openssl rand -hex 32.deploy/local.json. The compose gateway's entrypoint exports them, and validate and doctor fill any that are unset. Setting one on the host, even to an empty value, overrides the manifest. The gateway process never reads the manifest. Before running npm run dev:gateway with config-demo.yaml, export X402_ASSET and X402_FACILITATOR_PRIVATE_KEY, the two placeholders with no fallback. With agent-commerce start, you can also load them using --env-file <path>.