Skip to content

Latest commit

 

History

History
854 lines (727 loc) · 39.7 KB

configuration.md

File metadata and controls

854 lines (727 loc) · 39.7 KB

Configuration

The gateway validates one config.yaml before startup. The file is git-ignored and separate from the repository's config-demo.yaml. Start from config.example.yaml or generate it:

npm run agent-commerce -- init
npm run agent-commerce -- validate

Principles

  • Validated, not trusted. YAML is parsed, then validated with Zod. Unknown keys fail rather than being ignored silently.
  • Fail before startup. An invalid configuration stops the process with an actionable message naming the file, the path and what was expected.
  • Reference secrets from the environment. ${VAR} placeholders are resolved before validation. An unresolved variable names the variable but does not print resolved values.
  • Explicit version. version: 1. A future version is rejected, not guessed.

Top level

Key Required Purpose
version yes must be 1
merchant yes id, name, publicBaseUrl
server yes port, host
storage.receipts yes driver: sqlite, path
protocols yes which surfaces are enabled
resources yes the capabilities you expose
payments when a paid resource exists rail configuration
authorization no AP2 mandate verification

Resources

resources:
  market_report:
    name: Premium Market Report
    description: Latest premium market analysis.
    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: # reference secrets from the environment
        Authorization: Bearer ${BACKEND_TOKEN}
    pricing:
      type: fixed # free | fixed (dynamic is rejected)
      amount: "0.01" # decimal string, display units, never a float
      currency: USDC
    expose: [http, mcp]
    payments: [x402]

The map key (market_report) 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.

Each backend.headers entry must be a legal HTTP header name and value. Config load refuses one that fetch would reject, such as a value with an embedded newline, and the error names the header but never prints its value. A configured header wins over the same header that ACP forwards from the agent; see Headers sent to the merchant.

payments orders the rails a paid resource accepts. The gateway selects the first enabled rail in that order; it does not let each request choose a rail or fall back after a failure. Config load rejects a paid resource with no enabled listed rail. A resource can replace the x402 rail's offers with its own.

mimeType names what the backend returns, such as text/html or text/csv; header=present. Each x402 challenge for the resource declares it as resource.mimeType before the buyer pays, application/json when unset. An HTTP delivery then carries the backend's own Content-Type when it is text and fits the body (see security.md); this setting does not change it. Config load accepts mimeType only on a fixed-price resource that lists x402 in payments.

Enable idempotency.merchantKey: true to send an Idempotency-Key header with paid backend calls. This setting is experimental and disabled by default. The merchant decides how to handle repeated keys.

The generated key hashes merchant.publicBaseUrl, the resource ID, a payment identity and the canonical input digest with SHA-256. The identity defaults to the payment's replay identity. With redelivery, requests that have both a client retry key and a verified payer use the key's hash and payer instead. The retry claim prevents concurrent purchases under that key, and re-signed retries keep the same merchant key. With merchantKey alone, the client key is ignored; a different replay identity produces a different merchant key. Changing merchant.publicBaseUrl also changes generated keys.

An adapter's operation key takes precedence over the generated key. Either overrides an Idempotency-Key in backend.headers. Without a client retry key, input with no canonical JSON digest, such as a string containing a lone surrogate, receives no generated key and can still be delivered. Config load accepts idempotency only on a fixed-price resource.

For paid requests with a client retry key, idempotency.redelivery stores the response for reuse with matching input, without another charge. It is experimental and disabled by default. The x402 provider extracts the buyer's id from the echoed payment-identifier extension (see x402.md), and the HTTP invoke route reads MPP's Idempotency-Key header (see mpp.md). Direct pipeline requests can set CanonicalRequest.retryKey, which takes precedence over provider extraction. Custom providers can implement PaymentProvider.retryKey. With redelivery enabled, input without a canonical JSON digest fails with INPUT_INVALID when a retry key is used.

retentionHours is an integer from 1 to 8760 (one year), default 24. Config load rejects other values. The window starts at the initial retry check, before proof verification. Until it ends, a matching exact resend gets the stored response without verification, even after the proof or challenge expires. A verified fresh proof from the same payer can also retrieve it. After expiry, retries follow the normal purchase flow. Stored responses are unencrypted in the receipt database and its backups; expiry does not erase them immediately. See security.md. required defaults to false. Setting it to true requires x402 in payments and makes x402 retry-key extraction reject a missing id with INPUT_INVALID (HTTP 400). Retry after a lost response describes the rules.

At startup, the gateway marks in-flight payment retry keys unresolved, even if no resource still enables redelivery, and logs a nonzero count at WARN. Recovery includes requests that crashed before settlement. An interrupted request may have charged, so matching retries with redelivery enabled get 409 payment_identifier_unresolved until the key's retention deadline. After expiry, retries follow the normal purchase flow, as with completed keys. Reconciling or resolving the payment attempt does not change the key. doctor reports unexpired unresolved keys as INFO. Run one gateway process per receipt database.

Removing redelivery, renaming a resource or changing merchant.publicBaseUrl makes its stored responses and unresolved keys unavailable to new requests. Reusing a key can then start a new purchase.

url supports {param} templating from validated input; values are URL-encoded. Remaining input becomes query string for GET/DELETE and a JSON body otherwise - unless backend.inputBindings says otherwise, below.

Backend URLs are administrator configuration. They are never taken from request input, and redirects are not followed. See security.md.

backend.inputBindings

Optional. Names the top-level input properties carrying each part of the backend request:

    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: body

Without inputBindings, {param} values come from top-level input and the remaining fields become the query string (GET, DELETE) or entire JSON body (POST, PUT, PATCH). This preserves the original mapping behavior.

Present is explicit mode. Each group is sourced independently, so one operation can carry path parameters, query parameters and a JSON body at once - which the leftover rule cannot express, because on a body-capable method everything not consumed by the URL template becomes the body. Top-level input that no binding names is not forwarded to the backend at all.

The names are yours; path / query / body is only the convention the OpenAPI importer generates. Config load rejects, before the gateway starts:

  • a binding to a property the input schema does not declare (schemas are closed, so it could never be supplied);
  • path or query bound to something that is not an object schema;
  • two locations bound to one property;
  • a binding to _payment, which is reserved for payment proofs;
  • a body binding on GET or DELETE, which send none;
  • explicit bindings with no path binding while url is templated;
  • a path group that is not in the input's required, or a {param} not declared and required inside it, which would make calls unservable.

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 (see SECURITY.md).

Generated by agent-commerce import openapi; nothing about the field is OpenAPI-specific.

protocols.acp

Experimental, off by default, and absent from a config that predates it. The block is optional; when enabled is true the rest of it is required.

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  # set true only when 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 as well would put two unrelated payment layers on one call. Each resource serves exactly one operation; a resource mapped twice is refused.

Each operation sends a fixed canonical envelope built from path, carrying { checkout_session_id }, and body, carrying the ACP document; the operation table shows which of the two each one sends. A cancel carries body only when the caller sends one, such as an intent_trace. Bind it explicitly:

    input:
      type: object
      properties:
        path:
          type: object
          properties:
            checkout_session_id: { type: string }
          required: [checkout_session_id]
          additionalProperties: false
        body: { type: object, additionalProperties: true }
      required: [path]
      additionalProperties: false
    backend:
      type: http
      method: POST
      url: ${MERCHANT_API_BASE_URL}/checkout_sessions/{checkout_session_id}
      inputBindings:
        path: path
        body: body

Config checks that shape at load. It rejects a resource whose schema forbids a key the adapter may send, or requires one the operation does not always send, before it can fail at request time. A closed cancel schema must therefore declare body without requiring it, as the example above does. body sets additionalProperties: true because load closes any object schema that omits it; a body that accepts no keys is refused. A complete, validating configuration is in examples/acp-checkout.

retentionHours may not go below 24: a shorter window would let a replayed Idempotency-Key past an expired record and run a checkout twice. It bounds completed records only. One whose merchant outcome was never learned is kept until an operator clears it, because to the next retry, deleting it looks exactly like the operation never having happened. Idempotency records live in the receipt database, in a table of their own. idempotency.path is deprecated; see Deprecated store files.

Set merchantIdempotent: true only if the merchant stores and replays results under the Idempotency-Key the gateway derives for each checkout POST. With this setting, the gateway releases a claim after a 5xx so the same key can be retried, as ACP requires. Without merchant deduplication, that retry could repeat the side effect. See ACP idempotency.

The gateway forwards an Idempotency-Key header to the merchant on every side-effecting checkout call, derived so that it is stable across retries, a restart and a token rotation. See ACP for what the merchant should do with it.

See ACP for the wire contract.

authorization.ap2

Experimental, off by default, and absent from a config that predates it. Full reference, including the checkout profile and the trust model: 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:          # who may issue a Checkout Mandate
        - issuer: https://surface.example
          audience: merchant.example
          keys:
            - kid: mandate-2026-01
              jwk: { kty: EC, crv: P-256, x: "...", y: "..." }
      checkoutIssuers:         # who may sign the merchant checkout JWT
        - issuer: https://merchant.example
          audience: agent-commerce
          keys:
            - kid: checkout-2026-01
              jwk: { kty: EC, crv: P-256, x: "...", y: "..." }

Then require it on a paid resource:

resources:
  market_report:
    pricing: { type: fixed, amount: "0.01", currency: USDC }
    payments: [x402]
    authorization:
      required: [ap2]

Public keys only, written here by an operator. Nothing is fetched: no JWKS, no jku, no x5u, no issuer discovery. A JWK carrying private material is refused at load and names the key to rotate.

The two issuer lists are separate on purpose - signing the merchant's checkout documents must not confer the power to issue mandates - and audience is required per issuer rather than defaulted: a mandate that carries aud must name it, so one addressed to another merchant is refused.

requireMandateAudience rejects a token without aud. requireMandateExpiry rejects a mandate without exp on either the token or its content. Both default to false because AP2 makes these claims optional. The checkout JWT independently requires aud and exp.

A token without iss is matched by kid alone. Use distinct kids across mandate issuers; a shared kid makes an issuerless token ambiguous.

Refused at load: requiring ap2 while the block is absent or disabled; requiring it on a free resource, since authorization gates settlement and there would be none; and a clockSkewSeconds above the ceiling.

Spent mandates are held in the receipt database. replay.path is deprecated; see Deprecated store files.

Payments

payments:
  x402:
    enabled: true
    network: ${X402_NETWORK} # CAIP-2; eip155:31337 locally
    rpcUrl: ${X402_RPC_URL}
    payTo: ${MERCHANT_WALLET} # merchant-controlled. NEVER the gateway's.
    # Optional on a built-in network, which settles in its USDC. Set all four
    # for another token, such as the local MockUSDC.
    asset: ${X402_ASSET} # ERC-20 with EIP-3009, unless the option uses Permit2
    assetName: ${X402_ASSET_NAME} # EIP-712 domain name
    assetVersion: ${X402_ASSET_VERSION}
    assetDecimals: ${X402_ASSET_DECIMALS}
    maxTimeoutSeconds: 120
    paymentFlow: authorization # default; or upfront
    facilitator:
      mode: local # in-process signer; refused on mainnet
      signerPrivateKey: ${X402_FACILITATOR_PRIVATE_KEY}

paymentFlow sets the order of a payment and the backend call, with the same values under payments.x402 and payments.mpp. authorization, the x402 default, settles only after the backend succeeds. If the backend fails, no payment settles. upfront, the MPP default, settles first; a later backend failure leaves the payment settled. MPP defaults to upfront because its charge intent forbids running the backend before the payment is verified. A resource with fixed pricing that lists x402 or mpp can set its own paymentFlow, which applies to whichever rail serves it. Configuration rejects the field on other resources. See x402 for the flow details.

Startup rejects a payTo that is not a plausible address or is the zero address, and the effective destination is printed in a safe, visible form so a presenter can confirm where money goes. validate checks an EVM address's form (0x and 40 hex characters); gateway startup also checks the EIP-55 checksum of mixed-case addresses.

The settlement asset

Omit the four asset fields to use the built-in network's USDC address, EIP-712 domain and decimals. Resources using this default must be priced in USDC or USD. USD treats USDC at face value, without an exchange-rate lookup, and the payment records USDC (see payment-flow.md).

Built-in, local and declared EVM networks can use other tokens. For EIP-3009, set asset, assetName, assetVersion and assetDecimals; no USDC fields are inherited. Permit2 needs only asset and assetDecimals. Health fails if on-chain decimals differ from assetDecimals. The facilitator must support the token. Health checks its advertised x402 version, network and scheme, not token support. Solana and NEAR offers require their network's USDC.

Overrides for the default token must match its metadata. Naming the default token's address is the same as omitting it, so those resources are priced in USDC or USD too. Payments in another token use the resource's label.

Permit2

For an ERC-20 without transferWithAuthorization, set assetTransferMethod: permit2 to use Permit2 and the x402 exact Permit2 proxy. This EVM-only option defaults to eip3009. Permit2 uses its own EIP-712 domain, so assetName and assetVersion are unnecessary.

payments:
  x402:
    network: eip155:31337
    payTo: ${MERCHANT_WALLET}
    asset: ${TOKEN_ADDRESS}
    assetDecimals: 18
    assetTransferMethod: permit2

Before paying, the buyer approves the Permit2 contract, 0x000000000022D473030F116dDEE9F6B43aC78BA3, to spend the token. The gateway sends no approval and accepts no gas-sponsoring extension. Without that allowance it answers 412 Precondition Failed.

For other tokens, see the settlement asset rules. Health checks require code at Permit2 and the proxy, 0x402085c248EeA27D92E8b30b2C58ed07f9E20001. For a remote facilitator they check only that it advertises exact on the network, not that it settles Permit2.

Solana

Solana options settle the network's USDC through a remote facilitator that pays transaction fees. They require the optional peers @x402/svm and @solana/kit, loaded only for Solana options.

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}
  • payTo is the merchant's wallet; its 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, read from its /supported response. Without one, readiness fails and no challenge is built.
  • Health checks that the RPC's genesis hash matches the network id, that the mint has the configured decimals, and that the facilitator lists the network with a fee payer.
  • A transaction expires with its blockhash, about 60 to 90 seconds after the buyer signs. The default authorization flow settles after the backend, so use upfront for a handler that can take longer.

NEAR

NEAR options settle the network's NEP-141 USDC through a remote facilitator. The buyer signs a NEP-366 delegate action containing one ft_transfer; the facilitator's relayer submits it and pays gas. The buyer needs no NEAR for payment fees, but an implicit account must first receive NEAR to exist. The optional peer @x402/near loads only for NEAR options.

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}
  • payTo and asset are NEAR account IDs: named (merchant.near), subaccounts and implicit 64-hex accounts. The suffix does not select the network.
  • payTo must be registered with the token for storage (NEP-145 storage_deposit), or every transfer to it fails. Health fails until it is.
  • The buyer signs with a full-access key, ed25519 or secp256k1: ft_transfer needs one attached yoctoNEAR, which a function-call key cannot attach.
  • Health checks that the RPC's status names the network's chain, that the token has code and the configured decimals, and that the facilitator lists the network.
  • Verification adds relayer accounts from the facilitator's /supported to the payment's reconciliation metadata. Each provider reuses the response for 60 seconds. If the lookup fails, the gateway logs a warning and omits relayers. Verification can still succeed, but reconcile needs the recorded relayer IDs to look up the transaction by hash.
  • The signed delegate names no chain. The offer's network and the RPC the facilitator uses keep a proof on one network; use separate buyer keys for mainnet and testnet.
  • The gateway reserves the access key's nonce. The SDK reference client retries with the same nonce until a transaction uses it. A rejected attempt, from a backend failure or a settlement refused before submission, settled nothing, so the gateway permits that retry. A delegate that expires during a slow backend call is refused at settlement, and the client signs again; use upfront for handlers that can outlast maxTimeoutSeconds.

Several options

Use options to offer x402 resources on several networks or in several tokens; the buyer pays one offer. Entries use the fields above, and a one-entry list resolves like the direct form. Config load rejects mixed forms, empty lists, an object in place of the list, duplicate (network, asset, payTo) combinations, and mixed deployment modes (local, testnet, mainnet).

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}

Each network needs a distinct RPC endpoint for health checks. Set payments.x402.networks.<id>.rpcUrl, or use the shared payments.x402.rpcUrl as a fallback. RPC URLs must use http:// or https://. Equivalent URLs, such as those with and without a trailing slash, count as one endpoint. All offers use the facilitator under payments.x402; it must advertise their networks and scheme.

When options use default assets, price the resource in USD or their shared token symbol. Decimal precision cannot exceed the least precise option. Each option charges the resource's amount in its own token, without exchange-rate conversion.

The proof's accepted echo selects an offer before verification; zero or multiple matches are rejected. AP2 binds to the selected destination, network and asset, preventing a mandate for one offer from authorizing another.

A resource's own offers

A paid resource's x402.accepts replaces the rail's offers for that resource, matching the x402 SDK's route-level accepts. Rail options remain required and serve resources without overrides. Entries use the options fields; each charges the resource's pricing.

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. Price validation uses the resource's own tokens and decimals. Config load rejects empty lists, repeated entries, and x402.accepts on free resources, without x402 in payments, or without payments.x402.

ERC-8409 quotes

Experimental and disabled by default. Enable ERC-8409 quotes to sign EVM offers in x402 challenges.

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 with the key can sign quotes as the merchant; signing a quote does not transfer funds. The gateway keeps the key in memory and publishes the issuer address.

When enabled is true, signerPrivateKey is required. validate checks that it is a nonempty string; the provider checks the key during construction. Construction fails with CONFIG_INVALID for a malformed key, a well-known Anvil key outside the local chain, a key matching an EVM payTo, or a provider with no EVM offer. Restart the gateway after rotating the key. doctor compares the configured key's address with the published issuer to detect a gateway still using the old key.

Quotes increase header sizes. Each quoted offer adds about 0.9 KB of base64 to PAYMENT-REQUIRED, plus about 1 KB for the extension's schema. A small challenge grows from roughly 0.6 KB to 2.5 KB with one offer, or 4.9 KB with three; sizes vary with the offer and resource metadata.

Behind nginx, size proxy_buffer_size to fit the response headers. Its default is one 4 or 8 KB memory page; oversized headers cause nginx to reject the response. SDK clients echo quotes in PAYMENT-SIGNATURE, which can reach about 7.4 KB with six quoted offers. This approaches nginx's default 8 KB limit for a single request header field, set by large_client_header_buffers.

Each cache miss requires a signature per EVM offer. Rate-limit unpaid requests at the edge; see Denial of service.

payments.mpp

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; asset selects the token contract. The gateway builds its settlement provider from this block, so payments.x402 need not be enabled as a resource rail.

payments:
  mpp:
    enabled: true
    network: eip155:31337 # optional; defaults to eip155:84532 (Base Sepolia)
    rpcUrl: ${MPP_RPC_URL}
    asset: ${MPP_ASSET} # token contract on the selected RPC
    assetName: USDC # EIP-712 domain name
    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 accepts the same local and remote forms as x402. Config-level address, facilitator and mainnet errors use the payments.mpp path. Provider construction performs additional RPC/key safety checks as x402 provider and uses payTo for the MPP recipient in those messages. While payments.mpp.enabled is true, every paid resource that lists mpp must use the USDC currency label with at most 6 fractional digits.

On eip155:8453, payments.mpp uses the same mainnet guardrails as x402. It requires allowMainnet: true, a remote facilitator over HTTPS, canonical USDC with assetName: USD Coin and assetVersion: '2', a recipient that is not an Anvil development address, and either a facilitator credential or allowUnauthenticatedFacilitator: true.

Network and facilitator

network is a CAIP-2 identifier, either built in or declared:

network Network Kind Notes
eip155:31337 Local Anvil local Anvil's default chain id; no default asset
eip155:84532 Base Sepolia testnet
eip155:8453 Base mainnet
eip155:1 Ethereum mainnet
eip155:137 Polygon mainnet
eip155:42161 Arbitrum One mainnet
eip155:421614 Arbitrum Sepolia testnet
eip155:43114 Avalanche C-Chain mainnet
solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp Solana mainnet remote facilitator only
solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1 Solana Devnet testnet remote facilitator only
near:mainnet NEAR mainnet remote facilitator only
near:testnet NEAR Testnet testnet remote facilitator only

Every built-in EVM network is supported: the signing code is the same on each, and on 2026-10-10 each network's USDC signing domain matched the chain and Permit2 and its x402 proxy were deployed. Settlement has been demonstrated on Base Sepolia and Base, through both EIP-3009 and Permit2, and on Solana devnet and mainnet. NEAR has settled on testnet and mainnet through the SDK facilitator run for the test. Every network also needs a facilitator that lists it; health fails otherwise.

Declare another EVM network with its kind to select the mainnet guardrails. Custom networks have no default asset: set all four asset fields. Support for them is partial, since the gateway cannot check a chain and token you supply in advance. Built-in networks cannot be redeclared; their entry may set rpcUrl only.

payments:
  x402:
    networks:
      eip155:10: { kind: mainnet, displayName: OP Mainnet }

Unknown IDs raise CONFIG_INVALID at config load. The chain ID is part of the buyer's signed EIP-712 domain and must resolve explicitly.

facilitator decides who verifies and broadcasts:

facilitator:
  mode: local # in-process signer; eip155:31337 only
  signerPrivateKey: ${X402_FACILITATOR_PRIVATE_KEY}

Local mode requires Anvil on eip155:31337. Other networks require remote mode. A config that still pairs the local facilitator with eip155:84532, the id the local chain used before, is refused with a pointer to eip155:31337.

facilitator:
  mode: remote # HTTP; this gateway holds no facilitator signing key
  url: ${X402_FACILITATOR_URL}
  auth:
    type: none # bearer and cdp are also supported

Omitting auth is equivalent to type: none: the gateway sends no credential. Use it only with a facilitator that accepts anonymous requests. Supported types are none, bearer (a static token) and cdp (a fresh JWT per request). For cdp, apiKeySecret must be CDP's PEM EC P-256 private key or base64 Ed25519 key. JWTs bind requests to CDP's host and path, so config load rejects any url other than https://api.cdp.coinbase.com/platform/v2/x402. An unreadable secret stops start with an error naming apiKeySecret. PEM values may contain literal \n escapes copied from CDP's JSON key file.

Deployment mode - local, testnet or mainnet - comes from the network: eip155:31337 is local, and every other network is the testnet or mainnet it names. doctor, health() and /.well-known/agent-commerce report it. In local mode health() also requires the RPC to answer Anvil's anvil_nodeInfo, because an RPC can report any chain id.

Mainnet guardrails

A mainnet moves real money, so a config naming one must also say so. All of these are refused at config load, before the gateway starts:

Refused Because
allowMainnet absent or false mainnet is never a default
facilitator.mode: local it puts a funded gas key in the resource server
facilitator.auth.type: none without explicit acceptance an unauthenticated counterparty must be acknowledged
a non-HTTPS facilitator.url authorizations and settlement results would be unencrypted
a well-known Anvil payTo or MPP recipient its private key is public
an asset other than USDC for MPP, Solana or NEAR they settle only in USDC
the wrong USDC assetName or assetVersion the buyer signs that EIP-712 domain

The same rules apply to any non-local deployment where they make sense: plain HTTP is allowed only to a local/private host, and a development payTo is refused on testnet too.

agent-commerce validate applies the config-level guardrails. Provider construction repeats the shared guardrails and adds RPC/key checks. doctor reports the parsed configuration and, when reachable, the live provider health.

assetName is the EIP-712 domain, not the symbol

Base Sepolia USDC reports "USDC"; Base mainnet reports "USD Coin". The buyer signs this name in the EIP-712 domain, so a mismatch would reject every payment after signing. The network registry (src/payments/x402/networks.ts) pins the domain of each built-in network's USDC, and config validation rejects a mismatch before startup.

Running against a public network

Working configurations live in examples/base-sepolia/ and examples/base-mainnet/ (plus examples/base-mainnet-payai/, which uses an unauthenticated facilitator). Additional examples cover Arbitrum Sepolia (examples/arbitrum-sepolia/, not yet run), Solana devnet (examples/solana-devnet/) and NEAR testnet (examples/near-testnet/). examples/multi-network-mainnet/ combines Base and Solana, which have settled, with Polygon and Arbitrum One, which have not run. npm run test:testnet and npm run test:mainnet drive the whole flow against the real chains and read each settlement back from them. The testnet suite spends test USDC and the mainnet suite real USDC; both skip themselves without credentials and never run in CI. See smoke tests.

Unsupported JSON Schema keywords have a cost

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 schema uses one.

For a backend URL with a {param} template, minLength: 1 cannot reject an empty value because it is not enforced. The gateway separately rejects empty, . and .. path parameters before pricing, but values that only pattern would exclude can reach the backend.

Validate what matters in your own API, and do not rely on a keyword the warning told you is ignored.

Validation rules worth knowing

agent-commerce validate fails on: unknown keys · missing required fields · unsupported version · unresolved ${VAR} · duplicate resource ids · pricing.type: dynamic · a paid resource with no payments · a paid resource none of whose listed rails is enabled · expose values outside [http, mcp, a2a, acp] (UCP is planned, not supported) · expose: [mcp] while protocols.mcp.enabled is false (likewise a2a and acp) · enabled A2A with no resource exposed through a2a · two enabled protocol mounts that overlap · a mount that claims a route the gateway already serves, including the A2A Agent Card and ACP discovery paths · 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 · an ACP idempotency retention below 24 hours · an invalid or zero payTo/asset/recipient · an x402.accepts list that is empty or on a resource that cannot use it · a mimeType that is not a media type, or on a resource that is not fixed-price with x402 in payments · an idempotency block on a resource that is not fixed-price, or with neither merchantKey nor redelivery · a redelivery.retentionHours that is not an integer from 1 to 8760 · redelivery.required: true on a resource without x402 in payments · an MPP challengeSecret with length below 32 or a multi-line realm · a remote facilitator url with a query string or fragment.

Validation exits non-zero for these errors. It warns without failing if merchant.publicBaseUrl is not an http(s) URL, or if redelivery.required: true is set on a resource whose first enabled rail is not x402.

Environment

${VAR} works in any string value, and ${VAR:-default} supplies a fallback. agent-commerce start, validate and doctor also read variables from a file with --env-file <path>; a variable already set in the environment wins over the file. See .env.example for the variables the demo uses.