This is how you set your business up to take payments over x402, using x402 Trinity. Three things to do, and none of them need an account with us:
agent.md on your storefront. This is what makes you buyable — a buyer’s agent reads it to learn what you sell and how to pay for it. Without it, an agent told to “buy from this site” has to guess.Everything below is what you hand your coding agent to do all three. The money settles straight to your wallet, on-chain, per sale — 100% of what you charged.
Paste this into Claude Code, Cursor or any coding agent. It covers the wallet, the gate, the two files to publish, and a test to run before you go live.
Add an x402 payment gate to this project so a route sells something for USDC on Base, and
publish the two files that let a buyer's agent find it and pay for it.
WHAT THIS IS. A route that answers HTTP 402 with a price, takes a signed payment, and
returns the goods. No accounts, no checkout page, no sessions. The buyer is usually software.
INSTALL
npm install x402-trinity
ASK ME FOR EXACTLY TWO THINGS
STORE_VAULT_ADDRESS my wallet, receives 100% of each sale. Looks like 0x85c5…3428
PRICE what I charge per request
Put the address in .env and add .env to .gitignore.
I do NOT have a private key in this setup, and you must never ask me for one. A seller
only ever receives. If you think you need a seller private key, you have taken a wrong
turn — go back and re-read this.
I RECEIVE 100% OF THE PRICE. The payment is an EIP-3009 authorization naming my address
and my amount, signed by the buyer. Nobody in the path can skim it or redirect it — the
signature would stop being valid. Nothing is deducted. Do not build any fee handling.
IF I DO NOT HAVE A WALLET YET, tell me to install Coinbase Wallet or MetaMask, create an
account, write the recovery phrase down on paper, and give you the PUBLIC address. Nobody —
including you, or anyone claiming to be from x402trinity — ever needs that phrase.
Then, before writing code: the wallet must be on BASE, not Ethereum mainnet, and I should
send myself $1 of USDC and confirm it arrives. An address typo is silent — payments settle
successfully into a wallet nobody owns and cannot be recovered.
THE FACILITATOR — YOU SORT THIS OUT, DO NOT ASK ME.
When a buyer pays, they SIGN an authorization; they do not send a transaction. Something
has to put that signature on Base and pay the ETH gas for it. That something is a
facilitator, and the gate needs its URL.
I almost certainly do not know what a facilitator is or which one to use, so do not put
that question to me. Work it out: find a facilitator that settles on Base MAINNET, check
it is actually reachable, and use it. Then tell me in one line which one you picked and
why, so I have it on record.
It is not a fee and not a dependency on x402 Trinity — any facilitator that settles on
Base mainnet works, and I can change it later without touching anything else.
ONLY come back to me if you genuinely cannot find a working one. In that case say so
plainly rather than guessing, because the failure mode is silent (see below).
DO NOT default to the public facilitator at x402.org. It settles TESTNET ONLY. Pointing a
real store at it makes every sale verify and return a receipt, and then never land in my
wallet — it looks exactly like success until I check my balance days later. The library
refuses to start without a facilitator for this reason; do not work around that by
reaching for the first URL you find.
WRITE THIS MODULE
import { createX402Seller } from 'x402-trinity/seller';
import { createFileNonceStore } from 'x402-trinity/budget-file';
const seller = createX402Seller({
payTo: process.env.STORE_VAULT_ADDRESS,
price: '1500000', // atomic units. USDC has 6 decimals, so this is $1.50
network: 'base',
description: 'Proprietary Market Analysis (JSON)',
facilitator: FACILITATOR_URL, // whichever one I told you to use
// Replay guard. NOT optional. An authorization nonce redeems once on-chain, but nothing
// stops a buyer re-presenting a header they already used to take the goods a second
// time for free. The in-memory default forgets everything on restart.
nonceStore: createFileNonceStore('./.x402-nonces.json'),
});
export async function handleRequest(request) {
const gate = await seller.guard(request);
// Unpaid, malformed, replayed, or valid-but-unsettled. Return it VERBATIM.
// Do not turn a 503 into a 402: a 402 tells a correct agent it has not paid, so it
// mints a fresh payment and pays twice. 503 tells it to retry the same one.
if (gate.response) return gate.response;
// Paid and settled on-chain. Only now hand anything over.
return new Response(JSON.stringify(await fetchTheGoods()), {
status: 200,
headers: {
'content-type': 'application/json',
...seller.receiptHeader(gate.settlement),
},
});
}
MOUNT IT on the route that serves the paid resource. It speaks the standard Request/Response
pair, so it works in Node, Cloudflare Workers, Deno, Bun, Next route handlers, Hono, or
anything that hands you a Request.
THEN PUBLISH TWO THINGS — this part is not optional, it is what makes the gate reachable.
1. /agent.md on the storefront. A buyer's agent reads this to learn what is for sale and
how to pay for it. Without it, an agent told to "buy from this site" has to guess, and
usually gets it wrong or gives up. Write it from the template at
harnesses/02-seller-gate/storefront-agent.md, filling in my URL, price and description.
2. The "Accepts x402 · Powered by x402 Trinity" badge somewhere visible, from
harnesses/02-seller-gate/badge.html. Dark and light variants; pick the one that fits.
NOW PROVE IT, BEFORE ANYONE IS POINTED AT THE ROUTE
Create setup-test.mjs with the SETUP TEST code given below this block, exactly as written,
and run it:
node setup-test.mjs
It starts the gate and buys from it the way a real buyer's agent would, against a stub
that settles nothing — so my wallet stays empty and nothing is spent.
It checks the four things that cost real money when they are wrong: that an unpaid request
is refused and the goods do not leak, that the 402 quotes MY price and MY address, that
what gets signed pays me the full amount unsplit, and that a replayed payment buys
nothing. EVERY check must pass before I go live.
RULES, in priority order
1. Never call the delivery code before `gate.settlement` exists. Everything before that is
an unpaid request wearing a disguise.
2. Return `gate.response` unchanged. Do not rewrite its status.
3. Never log the payment header. It is a bearer instrument until it settles.
4. Price is atomic units as a STRING. 1500000 is $1.50. Never use floats.
5. On more than one server instance, replace the file nonce store with something every
instance can read, or two instances will each honour the same payment once.
HOW TO KNOW IT WORKS
Unpaid request -> 402, with a `payment-required` header naming price, asset, chain, payTo
Paid request -> 200, with a `payment-response` header naming the settling transaction
Same payment twice -> refused, and the resource does not leak
Then verify the transaction on basescan.org before telling me it works. A 200 from my own
server is not evidence that money moved.
WHAT NOT TO BUILD
No accounts, no sessions, no checkout page, no webhook listener, no payment status table,
no fee logic, no balance top-ups. The 402 exchange is the whole protocol. If you are
writing a database migration for this, stop and ask me.
/agent.md you publishThis is what makes your endpoint buyable. A buyer’s agent reads it to learn what you sell and how to pay for it — without it, an agent told to “buy from this site” has to guess. Fill in the bracketed values and serve it at /agent.md.
# Buying from [YOUR STORE NAME]
This endpoint sells data over HTTP 402. There is no account, no login, no API key and no
checkout page — an agent pays for the request and receives the response.
If you are an AI agent reading this on someone's behalf: everything you need is below. You
should not have to ask them anything except to fund the wallet.
## What is for sale
Resource [https://your-store.example/v1/report]
Price [$1.50] per request, USDC on Base
Returns [JSON — the full report]
## 1. Install the client
npm install x402-trinity
## 2. Make a wallet
Generate a fresh one. Do not ask your operator to paste in a key from a wallet they already
use — this wallet spends automatically, so it should hold only what it needs.
node -e "Promise.all([import('x402-trinity'),import('node:fs')]).then(([x,fs])=>{const b=crypto.getRandomValues(new Uint8Array(32));const k='0x'+[...b].map(v=>v.toString(16).padStart(2,'0')).join('');fs.mkdirSync('.x402',{recursive:true});fs.writeFileSync('.x402/wallet.key',k,{mode:0o600});console.log('fund this address on Base:',x.__internals.addressOf(x.__internals.toBig(x.fromHex(k))))})"
Then:
- Add `.x402/` to .gitignore. Never print the key or paste it into a chat.
- Tell your operator the address it printed, and that it needs **USDC on Base** — not
Ethereum mainnet. A wrong-network send is silent and unrecoverable.
- It needs **no ETH**. The client signs; a facilitator submits and pays the gas.
- Fund it with a little more than the price above. [$5.00] is a sensible start.
In a container or CI, read the key from the `BUYER_KEY` environment variable instead, so it
never touches disk. The code below checks the environment first.
## 3. Write this module
import { createX402Fetch } from 'x402-trinity';
import { createFileBudgetStore, createFileFeeStore } from 'x402-trinity/budget-file';
import { readFileSync, mkdirSync } from 'node:fs';
// Make the directory first. The store does NOT create it, and a missing directory
// surfaces as "budget lock timed out" rather than a missing-file error - which sends
// you looking for a stale lock that was never there.
mkdirSync('.x402', { recursive: true });
// Keep the store. It is both the durable limit and the only honest place to read the
// running total back from - stats().spent counts this process only.
const budget = createFileBudgetStore('.x402/budget.json');
const pay = createX402Fetch({
privateKey: process.env.BUYER_KEY ?? readFileSync('.x402/wallet.key', 'utf8').trim(),
// Checked before anything is signed. A refused call is never signed, so it cannot
// settle later.
policy: {
maxAmountPerRequest: '[2000000]', // your ceiling per call, atomic units
totalBudget: '[10000000]', // lifetime ceiling
allowHosts: ['[your-store.example]'], // only pay this host
allowNetworks: ['base'], // the 402 says eip155:8453; the client maps it
},
// Durable, and not optional. An in-memory budget resets when the process does, so a
// crashing loop can spend the "lifetime" budget once per restart. Without a durable
// store the library declines mainnet payments outright.
budgetStore: budget,
surcharge: { store: createFileFeeStore('.x402/fees.json') },
onDecline: (d) => console.warn('[x402] refused', d.url, '-', d.reason),
});
## 4. Buy
Use it exactly like `fetch`. The 402 is handled inside the call — it reads the price, checks
it against your limits, signs, and retries:
const res = await pay('[https://your-store.example/v1/report]');
if (res.ok) console.log(await res.json());
Anything that is not a 402 passes straight through untouched, so the same fetch works for
free and paid endpoints alike.
## How to know it worked
- `res.status` is 200 and the body is the data.
- `budget.spent()` went up by the price. This is the durable figure; use it, not
`stats().spent`, which resets with the process.
- A refusal is normal operation, not an error — log it and move on. Do not retry an
over-budget URL in a loop; the answer will not change.
## What not to write
Do not hand-roll the 402. Do not parse the challenge header, build the signature, set a
payment header, or re-issue the request yourself — the client above IS the retry. If you are
writing code that touches `payment-signature`, you have taken a wrong turn.
No accounts, no sessions, no checkout flow, no webhook listener.
## Notes
- Prices are atomic-unit STRINGS. USDC has 6 decimals, so '1500000' is $1.50. Never floats.
- `allowHosts` is not optional — without it, any 402 from anywhere is payable, including
one served by a redirect you did not expect.
- The 402 advertises the network as `eip155:8453`, while the policy takes the short name
`base`. That mismatch is expected — the client maps between them. Do not "fix" it by
putting the CAIP-2 form in `allowNetworks`.
Self-contained — no script, no external image, no tracking. Dark and light variants in the file.
<a href="https://x402trinity.com" target="_blank" rel="noopener"
style="display:inline-flex;align-items:center;gap:8px;padding:8px 14px;border-radius:10px;
border:1px solid rgba(255,255,255,.16);background:rgba(255,255,255,.04);
color:#F4F6FA;text-decoration:none;font:500 13px/1 ui-sans-serif,system-ui,sans-serif;
letter-spacing:.01em;white-space:nowrap;">
<svg width="15" height="15" viewBox="0 0 16 16" aria-hidden="true" style="display:block;flex:none;">
<path d="M8 1.5 13.5 8 8 14.5 2.5 8 8 1.5Z" fill="none" stroke="currentColor" stroke-width="1.4"
stroke-linejoin="round" opacity=".55"/>
<path d="M8 4.8 11.2 8 8 11.2 4.8 8 8 4.8Z" fill="currentColor"/>
</svg>
<span>Accepts <strong style="font-weight:600;">x402</strong></span>
<span style="opacity:.5;">·</span>
<span style="opacity:.72;">Powered by x402 Trinity</span>
</a>
Included in the brief above. It starts your gate and buys from it the way a real buyer’s agent would, against a stub that settles nothing — so your wallet can still be empty when you run it.
It covers the four things that cost real money when they’re wrong: an unpaid request is refused and the goods don’t leak, the 402 quotes your price and your address, what gets signed pays you the full amount unsplit, and a replayed payment buys nothing.
/**
* SETUP TEST — does my payment gate actually work?
*
* node setup-test.mjs
*
* Starts your gate, then buys from it the way a real buyer's agent would. NOTHING IS SPENT:
* the facilitator here is a stub that checks the payment and reports success without
* touching a chain, and the buyer is a throwaway key with no money in it.
*
* Run this before you point anyone at the real route. It is the difference between "the
* server starts" and "a stranger's agent can buy from me and I get paid".
*/
import { createServer } from 'node:http';
import { rmSync } from 'node:fs';
import { createX402Seller } from 'x402-trinity/seller';
import { createX402Fetch, __internals, fromHex, toHex } from 'x402-trinity';
import { createFileNonceStore, createFileBudgetStore, createFileFeeStore } from 'x402-trinity/budget-file';
const PORT = 8847, ORIGIN = `http://127.0.0.1:${PORT}`;
const PRICE = '1500000'; // $1.50 — whatever you charge
const MY_WALLET = '0xAbCdEf0000000000000000000000000000000042'; // <- your address here
rmSync('.selftest-nonces.json', { force: true });
rmSync('.selftest-budget.json', { force: true });
let pass = 0, fail = 0;
const ok = (n, c, d = '') => { c ? pass++ : fail++; console.log(` ${c ? 'PASS' : 'FAIL'} ${n}${d ? ' ' + d : ''}`); };
/* ---- YOUR GATE, exactly as it runs in production ----------------------------------------- */
const seller = createX402Seller({
payTo: MY_WALLET,
price: PRICE,
network: 'base',
description: 'Proprietary Market Analysis (JSON)',
facilitator: `${ORIGIN}/fac`, // the stub, for this test only
nonceStore: createFileNonceStore('.selftest-nonces.json'),
});
async function handleRequest(request) {
const gate = await seller.guard(request);
if (gate.response) return gate.response;
return new Response(JSON.stringify({ dataset: 'the goods' }), {
status: 200,
headers: { 'content-type': 'application/json', ...seller.receiptHeader(gate.settlement) },
});
}
/* ------------------------------------------------------------------------------------------ */
/** Every authorization the stub sees, so we can inspect what was actually signed. */
const signed = [];
const srv = createServer(async (req, res) => {
const url = new URL(req.url, ORIGIN);
const chunks = []; for await (const c of req) chunks.push(c);
if (url.pathname.startsWith('/fac')) {
const a = JSON.parse(Buffer.concat(chunks).toString() || '{}')?.paymentPayload?.payload?.authorization;
if (a && url.pathname.endsWith('/settle')) signed.push(a);
res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify(url.pathname.endsWith('/verify')
? { isValid: !!a, payer: a?.from }
: { success: true, transaction: '0xtest' + a.nonce.slice(2, 60), payer: a.from, network: 'eip155:8453' }));
}
const r = await handleRequest(new Request(new URL(req.url, ORIGIN), {
method: req.method, headers: req.headers,
}));
const t = await r.text();
res.writeHead(r.status, Object.fromEntries(r.headers));
res.end(t);
});
await new Promise((r) => srv.listen(PORT, '127.0.0.1', r));
console.log('\n SETUP TEST — nothing is spent\n');
console.log(` paying into ${MY_WALLET}`);
console.log(` price ${PRICE} atomic\n`);
/* ---- 1. an unpaid request is refused, and says what it costs ------------------------------ */
const unpaid = await fetch(`${ORIGIN}/v1/report`);
const ch = JSON.parse(unpaid.headers.get('payment-required') ?? '{}');
ok('an unpaid request is refused', unpaid.status === 402, `HTTP ${unpaid.status}`);
ok('...and the 402 quotes my price', ch.accepts?.[0]?.amount === PRICE, ch.accepts?.[0]?.amount);
ok('...and names MY wallet, not anyone else\'s',
ch.accepts?.[0]?.payTo?.toLowerCase() === MY_WALLET.toLowerCase());
ok('...and the goods did not leak', !(await unpaid.json().catch(() => ({}))).dataset);
/* ---- 2. a real buyer pays and gets in ----------------------------------------------------- */
/* A throwaway key. It never needs funding, because the stub settles nothing. */
const k = '0x' + [...crypto.getRandomValues(new Uint8Array(32))]
.map((b) => b.toString(16).padStart(2, '0')).join('');
const pay = createX402Fetch({
privateKey: k,
policy: {
maxAmountPerRequest: PRICE, totalBudget: '100000000',
allowHosts: ['127.0.0.1'], allowNetworks: ['base'],
},
budgetStore: createFileBudgetStore('.selftest-budget.json'),
surcharge: { store: createFileFeeStore('.selftest-fees.json') },
});
const paid = await pay(`${ORIGIN}/v1/report`);
ok('a paying buyer gets the goods', paid.status === 200, `HTTP ${paid.status}`);
ok('...with a receipt naming the transaction', !!paid.headers.get('payment-response'));
ok('...and the payload is delivered', !!(await paid.json()).dataset);
/* ---- 3. what was signed is what I get ----------------------------------------------------- */
ok('THE PAYMENT PAYS ME 100%, UNSPLIT',
signed.length > 0 && signed.every((a) => a.to.toLowerCase() === MY_WALLET.toLowerCase() && a.value === PRICE),
signed.map((a) => `${a.value}->${a.to.slice(0, 8)}…`).join(' '));
/* ---- 4. a reused payment buys nothing ----------------------------------------------------- */
/* The one failure that actually costs you money: a buyer replaying a header they already
spent. It is why the nonce store has to be durable. */
const replay = await fetch(`${ORIGIN}/v1/report`, {
headers: { 'payment-signature': btoa(JSON.stringify({
x402Version: 2, resource: ch.resource, accepted: ch.accepts[0],
payload: {
signature: '0x' + '11'.repeat(65),
authorization: { ...signed[0] },
},
extensions: {},
})) },
});
ok('a replayed payment is refused', replay.status !== 200, `HTTP ${replay.status}`);
ok('...and the goods did not leak', !(await replay.json().catch(() => ({}))).dataset);
console.log(`\n ${pass} passed, ${fail} failed\n`);
if (!fail) console.log(' The gate works. Put your real wallet in and go live.\n');
rmSync('.selftest-nonces.json', { force: true });
rmSync('.selftest-budget.json', { force: true });
rmSync('.selftest-fees.json', { force: true });
srv.close();
process.exitCode = fail ? 1 : 0;