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:

  1. --config

    Pass --config to validate, start, doctor, reconcile, resolve or backup, or call loadConfig({ path }) in code.

  2. AGENT_COMMERCE_CONFIG

    The environment variable, resolved against the working directory.

  3. config.yaml

    The default, in the working directory.

Top level

KeyRequiredPurpose
versionyesmust be 1
merchantyesid, name, publicBaseUrl
serveryesport, host, adminToken, allowedOrigins
storage.receiptsyesdriver: sqlite, path
protocolsyeswhich surfaces are enabled
resourcesyesthe capabilities you expose
paymentsyesrail configuration; {} when every resource is free
authorizationnoAP2 mandate verification
config.yamlyaml
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: /acp

Resources

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).

yaml
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

namestringrequired
Human-readable name, shown to agents.
descriptionstring
Shown to agents; for a paid MCP tool the price is appended automatically.
inputJSON Schema
Closed by default at every level. A resource with no input gets an empty closed schema — declaring nothing means accepting nothing.
backend.methodGET | POST | PUT | PATCH | DELETErequired
backend.urlstringrequired
Supports {param} templating from validated input, URL-encoded. Administrator configuration only.
backend.timeoutMsnumberdefault: 10000
Timeout for the outbound call, in milliseconds.
backend.headersmap
Static headers, typically credentials via ${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.inputBindingsobject
Which top-level input properties carry the path, query and body. See below.
pricing.typefree | fixedrequired
pricing.amountdecimal string
Required when fixed. Display units, e.g. "0.01".
pricing.currencystring
Required when fixed. USDC or USD on an offer that settles in USDC; USDC for MPP.
expose(http | mcp | a2a | acp)[]required
Each protocol named here must also be enabled under protocols.
payments(x402 | mpp)[]
Required on a paid resource, e.g. [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[]
The resource's own x402 offers, replacing the rail's for this resource. See A resource's own offers.
paymentFlowauthorization | upfront
Overrides the rail's paymentFlow for this resource, on whichever rail serves it. Allowed only on a fixed resource whose payments lists x402 or mpp.
mimeTypestringdefault: application/json
What the backend returns, such as text/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[]
e.g. [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.

yaml
    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

Config load rejects, before the gateway starts:

  • a binding to a property the input schema does not declare;
  • path or query bound to something that is not an object schema;
  • two locations bound to one property, or a binding to the reserved _payment or _authorization;
  • a body binding on GET or DELETE;
  • explicit bindings with no path binding while url is 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

yaml
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-2required
A built-in network or one declared under networks. An unknown id is CONFIG_INVALID, never a guess.
rpcUrlhttp(s) URL
Fallback RPC for networks without a networks.<id>.rpcUrl. Health checks and reconcile use it; remote facilitators handle settlement.
payTostringrequired
Merchant-controlled destination, never the gateway's: an EVM address, a Solana wallet or a NEAR account. Zero and implausible addresses are refused. validate checks an EVM address's form; gateway startup also checks the EIP-55 checksum of a mixed-case one.
asset / assetName / assetVersion / assetDecimals
Omit all four on a public built-in network to settle in its USDC. On the local chain or a declared network, set all four together, or only asset and assetDecimals for Permit2; assetName is the EIP-712 domain name, not the symbol. See The settlement asset.
assetTransferMethodeip3009 | permit2default: eip3009
EVM only. permit2 settles a token without transferWithAuthorization; see Permit2.
maxTimeoutSecondsnumberrequired
How long a payment challenge stays valid, a positive whole number. Under the authorization flow, backend time counts toward it: the buyer's authorization must still be valid at settlement.
paymentFlowauthorization | upfrontdefault: authorization
The order of settlement and the backend call. authorization 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.
facilitatorobjectrequired
mode: 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.
networksmap
Per-network settings by CAIP-2 id: rpcUrl for a built-in network, or kind, displayName and rpcUrl to declare another EVM network.
optionsoffer[]
Several offers instead of the direct fields; see Several options.
allowMainnetboolean
Required to be true on any mainnet. Never a default.
allowUnauthenticatedFacilitatorboolean
Required on mainnet when facilitator.auth.type is none.
quote{ enabled, signerPrivateKey }
Experimental signed quotes on EVM offers; see ERC-8409 quotes.

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 USDC or USD; USD is 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 (asset and assetDecimals for 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 USDC or USD too. 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.

yaml
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 answers 412 Precondition Failed.
  • Health checks require code at Permit2 and at the proxy, 0x402085c248EeA27D92E8b30b2C58ed07f9E20001. For a remote facilitator they check only that it lists exact on 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.

yaml
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 /supported response. 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 upfront for 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.

yaml
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, subaccounts or implicit 64-hex accounts. The suffix does not select the network.
  • payTo must hold a storage deposit with the token (NEP-145 storage_deposit), or every transfer to it fails. Health fails until it does.
  • The buyer signs with a full-access key: ft_transfer needs 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 upfront for handlers that can outlast maxTimeoutSeconds.
  • Verification saves relayer accounts from the facilitator's /supported response, cached for 60 seconds, for reconcile. If the lookup fails or takes too long, the gateway logs a warning and omits the relayers. An otherwise valid payment still passes verification, but reconcile then 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.

yaml
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 options with 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 USD or 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 accepted echo 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.

yaml
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.

yaml
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.
  • signerPrivateKey is required when enabled is true. Provider construction fails with CONFIG_INVALID for a malformed key, a well-known Anvil key outside local deployment mode, a key that controls 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.
  • 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.

yaml
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

yaml
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

yaml
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.

You have no configuration-level way to reject an empty string for a {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;
  • expose values outside [http, mcp, a2a, acp], or naming a disabled protocol;
  • A2A enabled with no resource exposed through a2a;
  • a resource paymentFlow without fixed pricing and x402 or mpp in its payments;
  • overlapping protocol mounts, or a mount on a public or ledger route such as /health or /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 path schema is closed without checkout_session_id, or whose body schema accepts no keys;
  • an invalid or zero payTo, asset or MPP recipient, 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 USD nor USDC on an offer that settles in USDC;
  • x402 options that are empty, repeated, mixed with the direct fields, in different deployment modes or sharing one RPC endpoint, and an x402.accepts list that is empty or on a resource that cannot use it;
  • facilitator.auth.type: cdp with a url other than CDP's, or an rpcUrl that is not http:// or https://;
  • an MPP challengeSecret shorter than 32 characters, or a multi-line realm.

Environment variables

${VAR} works in any string value, and ${VAR:-default} supplies a fallback. The demo uses these:

.env.example.env
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
The six variables in the first commented group come from .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>.