Merchant setup

Deploy to a server

Run the published package on a server with systemd or pm2, nginx, and TLS.

Run the published package on an Ubuntu 24.04 server with systemd or pm2, and put nginx in front for TLS. This example binds the gateway to 127.0.0.1 and uses gateway.example.com — you don't need a repository clone or build step.

One process per set of data filesKeep one gateway process per set of SQLite files — don't use pm2 cluster mode or start another instance on the same data/. If ACP is enabled, opening a second idempotency store can mark active checkouts unresolved.

Prepare the server

The package requires Node.js 22 or newer; Ubuntu 24.04 provides Node 18. Install Node 22 from NodeSource, then create a service user with no login shell:

bash
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs
node --version            # v22 or newer

sudo useradd --system --create-home --home-dir /srv/agent-commerce \
  --shell /usr/sbin/nologin agent-commerce

Install the gateway

Work in a shell as the service user, so it owns everything you create:

bash
sudo -u agent-commerce -H bash
cd ~                      # /srv/agent-commerce
mkdir -m 700 data
npm install @devlab.group/agent-commerce @x402/core @x402/evm viem @modelcontextprotocol/sdk

Install the x402 and MCP peers with the package so npm can resolve its pinned peer versions. Omit @modelcontextprotocol/sdk if MCP is disabled. For MPP or AP2, add the peers listed in Installation. Keep package-lock.json so npm ci can reproduce the install.

Configure it

/srv/agent-commerce/config.yamlyaml
version: 1

merchant:
  id: my-store
  name: My Store
  publicBaseUrl: ${GATEWAY_PUBLIC_BASE_URL}

server:
  port: 8080
  host: 127.0.0.1                 # only nginx, on the same host, reaches it
  adminToken: ${ADMIN_TOKEN}
  allowedOrigins: []

storage:
  receipts:
    driver: sqlite
    path: /srv/agent-commerce/data/receipts.sqlite

protocols:
  http:
    enabled: true
  mcp:
    enabled: true
    mountPath: /mcp

resources:
  premium_report:
    name: Premium Report
    description: A paid endpoint on your existing backend.
    input:
      type: object
      properties: {}
      additionalProperties: false
    backend:
      type: http
      method: GET
      url: ${MERCHANT_API_BASE_URL}/api/report
      timeoutMs: 10000
    pricing:
      type: fixed
      amount: "0.01"
      currency: USDC
    expose: [http, mcp]
    payments: [x402]

payments:
  x402:
    enabled: true
    network: eip155:84532
    rpcUrl: https://base-sepolia-rpc.publicnode.com   # health checks only
    asset: "0x036CbD53842c5426634e7929541eC2318f3dCF7e"   # Circle USDC
    assetName: USDC
    assetVersion: "2"
    assetDecimals: 6
    payTo: ${MERCHANT_WALLET}
    maxTimeoutSeconds: 300
    facilitator:
      mode: remote
      url: https://x402.org/facilitator
      auth: { type: none }

This binds the gateway to loopback, stores receipts in data/, and uses the public facilitator for Base Sepolia. For mainnet, change the payments block as Networks & facilitators shows. Put any other SQLite databases you enable — ACP idempotency or AP2 replay — under data/ too.

Secrets go in an environment file next to it:

bash
umask 077    # the files you create next are readable by their owner only
cat > .env <<EOF
NODE_ENV=production
LOG_LEVEL=info
GATEWAY_PUBLIC_BASE_URL=https://gateway.example.com
ADMIN_TOKEN=$(openssl rand -hex 32)
MERCHANT_API_BASE_URL=https://api.example.com
MERCHANT_WALLET=0xYourWalletAddress
EOF

NODE_ENV=production switches the logs to one JSON object per line. Replace the example wallet address and backend URL before validating. Keep the file to plain KEY=value lines so systemd and Node can both read it.

Save the start script as gateway.mjs, then validate and try a first run by hand:

/srv/agent-commerce/gateway.mjsjavascript
import { createGateway, loadConfig, receipts } from '@devlab.group/agent-commerce';
import { mcp } from '@devlab.group/agent-commerce/mcp';
import { x402 } from '@devlab.group/agent-commerce/x402';

const config = await loadConfig(); // AGENT_COMMERCE_CONFIG, else ./config.yaml

const store = receipts({ path: config.storage.receipts.path });
await store.init(); // an incompatible schema fails here, at startup

const rail = config.payments.x402;
const paymentProviders = rail?.enabled
  ? [
      x402({
        network: rail.network,
        rpcUrl: rail.rpcUrl,
        asset: rail.asset,
        assetName: rail.assetName,
        assetVersion: rail.assetVersion,
        assetDecimals: rail.assetDecimals,
        payTo: rail.payTo,
        maxTimeoutSeconds: rail.maxTimeoutSeconds,
        facilitator: rail.facilitator,
        allowMainnet: rail.allowMainnet,
        allowUnauthenticatedFacilitator: rail.allowUnauthenticatedFacilitator,
      }),
    ]
  : [];

const gateway = await createGateway({
  config,
  store,
  paymentProviders,
  protocolAdapters: config.protocols.mcp.enabled
    ? [mcp({ mountPath: config.protocols.mcp.mountPath })]
    : [],
});

const { url } = await gateway.listen();
console.log(`gateway listening at ${url}`);

for (const signal of ['SIGTERM', 'SIGINT']) {
  process.once(signal, async () => {
    await gateway.close();
    await store.close();
    process.exit(0);
  });
}
bash
set -a; . ./.env; set +a              # export the secrets into this shell
npx @devlab.group/agent-commerce validate --config config.yaml
node --env-file=.env gateway.mjs      # Ctrl+C to stop
exit                                  # back to your own user

Run it as a service

systemd starts the gateway at boot and restarts it after a failure. These settings restrict writes to data/ and a private temporary directory.

/etc/systemd/system/agent-commerce.servicetext
[Unit]
Description=Agent Commerce Gateway
After=network-online.target
Wants=network-online.target

[Service]
User=agent-commerce
Group=agent-commerce
WorkingDirectory=/srv/agent-commerce
EnvironmentFile=/srv/agent-commerce/.env
ExecStart=/usr/bin/node gateway.mjs
Restart=on-failure
RestartSec=5
TimeoutStopSec=30

# Writes are limited to data/ and a private temporary directory
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
PrivateDevices=true
ReadWritePaths=/srv/agent-commerce/data

[Install]
WantedBy=multi-user.target
bash
sudo systemctl daemon-reload
sudo systemctl enable --now agent-commerce
systemctl status agent-commerce
journalctl -u agent-commerce -f       # logs

systemd loads the variables from .env. On stop or restart, the script receives SIGTERM and closes the server and receipt store. Check that NodeSource installed Node at /usr/bin/node with which node.

Put nginx and TLS in front

Point the hostname's DNS record at the server first, so certbot can issue the certificate.

/etc/nginx/sites-available/agent-commercetext
limit_req_zone $binary_remote_addr zone=agent_commerce:10m rate=10r/s;

server {
  listen 80;
  listen [::]:80;
  server_name gateway.example.com;

  client_max_body_size 256k;            # match the gateway request limit

  location / {
    limit_req zone=agent_commerce burst=20 nodelay;
    limit_req_status 429;

    proxy_pass http://127.0.0.1:8080;
    proxy_http_version 1.1;
    proxy_set_header Host $host;        # keep it: the gateway checks Host
    proxy_buffering off;                # MCP responses may stream events
  }
}
bash
sudo apt-get install -y nginx certbot python3-certbot-nginx
sudo ln -s /etc/nginx/sites-available/agent-commerce /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d gateway.example.com --redirect
sudo ufw allow OpenSSH && sudo ufw allow 'Nginx Full' && sudo ufw enable
Keep proxy_set_header Host $hostThe gateway accepts the publicBaseUrl hostname and loopback names. Without this line, nginx forwards Host: 127.0.0.1:8080. Every proxied request then looks local, bypassing the gateway's DNS rebinding check.

The gateway has no built-in rate limit. Tune nginx's rate and burst for your traffic. ACP checkout also needs TLS to protect its bearer token in transit.

Check it

bash
curl -s https://gateway.example.com/health
curl -s https://gateway.example.com/ready
curl -s https://gateway.example.com/.well-known/agent-commerce

Then on the server:

bash
sudo -u agent-commerce -H bash
cd ~ && set -a && . ./.env && set +a
npx @devlab.group/agent-commerce doctor --config config.yaml --gateway http://127.0.0.1:8080

doctor exits non-zero if a check fails, so you can use it in a deploy script. Review the go-live checklist before sending real traffic to the server.

Operate it

Tasksystemdpm2
logsjournalctl -u agent-commercepm2 logs agent-commerce
apply a config or secret changesudo systemctl restart agent-commercepm2 restart agent-commerce
stopsudo systemctl stop agent-commercepm2 stop agent-commerce

Run the pm2 commands as the service user. The configuration is read once at startup, so every change needs a restart; LOG_LEVEL=debug adds detail while you investigate.

  • Upgrade: as the service user, rerun the install command with @devlab.group/agent-commerce@latest. Check dependency versions with npx @devlab.group/agent-commerce version, then restart. If the receipt schema is incompatible, the gateway fails at startup.
  • Back up: use SQLite's online backup while the gateway runs, then copy the backup off the server. Back up ACP and AP2 databases separately. Keep AP2 replay rows except those marked released; deleting a spent row could allow the mandate to be used again.
bash
sudo apt-get install -y sqlite3
sudo -u agent-commerce bash -c 'umask 077; sqlite3 /srv/agent-commerce/data/receipts.sqlite ".backup /srv/agent-commerce/receipts-backup.sqlite"'

The backup belongs to the service user and is readable only by that user. Copy it off the server and remove the local copy when finished.