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.
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:
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-commerceInstall the gateway
Work in a shell as the service user, so it owns everything you create:
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/sdkInstall 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
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:
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
EOFNODE_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:
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);
});
}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 userRun 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.
[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.targetsudo systemctl daemon-reload
sudo systemctl enable --now agent-commerce
systemctl status agent-commerce
journalctl -u agent-commerce -f # logssystemd 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.
If your server already uses pm2, run it as the service user. Its startup unit restores the saved process list after a reboot. This setup does not apply the systemd tab's write restrictions.
module.exports = {
apps: [
{
name: 'agent-commerce',
cwd: '/srv/agent-commerce',
script: 'gateway.mjs',
node_args: ['--env-file=/srv/agent-commerce/.env'],
env: { NODE_ENV: 'production' },
exec_mode: 'fork', // never cluster: one process per set of data files
instances: 1,
autorestart: true,
kill_timeout: 30000, // time for a graceful shutdown before SIGKILL
},
],
};sudo npm install -g pm2
sudo -u agent-commerce -H pm2 start /srv/agent-commerce/ecosystem.config.cjs
sudo -u agent-commerce -H pm2 save
sudo pm2 startup systemd -u agent-commerce --hp /srv/agent-commerce
sudo -u agent-commerce -H pm2 logs agent-commercenode_args hands --env-file to Node, which loads .env before the script runs. Existing environment variables take precedence, so the ecosystem file also sets NODE_ENV. pm2 sends SIGINT on stop and waits for kill_timeout before forcing a shutdown. The example raises that limit from 1.6 to 30 seconds.
Put nginx and TLS in front
Point the hostname's DNS record at the server first, so certbot can issue the certificate.
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
}
}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 enablepublicBaseUrl 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
curl -s https://gateway.example.com/health
curl -s https://gateway.example.com/ready
curl -s https://gateway.example.com/.well-known/agent-commerceThen on the server:
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:8080doctor 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
| Task | systemd | pm2 |
|---|---|---|
| logs | journalctl -u agent-commerce | pm2 logs agent-commerce |
| apply a config or secret change | sudo systemctl restart agent-commerce | pm2 restart agent-commerce |
| stop | sudo systemctl stop agent-commerce | pm2 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 withnpx @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.
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.