@x402/evm shifts between versions and the client tooling is thin. Known limitations is the honest list.By the end of this page you'll have an HTTP endpoint that answers OpenAI-style chat requests and charges a fraction of a cent in USDC per message โ no API keys, no accounts, no invoices.
Who this is for
A backend developer comfortable with Node and TypeScript who already has (or can put together) an LLM endpoint. No prior x402 or crypto-payments experience assumed โ the one concept you need is explained below, and everything deeper is linked out rather than re-taught here.
What you'll need
- An OpenAI-compatible LLM to proxy โ Mistral, an OpenAI key, a local model, anything that speaks
/chat/completions. - Node + TypeScript, and the two SDK packages:
npm install @x402/core @x402/evm. The snippets below target v2.20 (check scw_js/package.json for what we actually run) โ batch-settlement is young and its APIs still move between minor versions, so pin what you test against. - An EVM wallet (two keys: one to receive funds, one off-chain signer โ explained in step 2).
- A place to store channel state โ Redis, or a file for a single instance. The SDK ships both.
- An x402 facilitator that supports batch-settlement (a public one, or your own).
- A scheduled job (cron) โ this is how you actually collect the money.
How the payment works
When someone calls your endpoint without paying, you reply 402 Payment Required plus a header describing how to pay. Their client pays in USDC (a dollar stablecoin) and retries. One blockchain transaction per chat message would be far too slow and expensive, so payment uses a channel: the user locks funds once in an on-chain escrow, each message is then just a tiny signed IOU (a voucher), and you redeem the accumulated vouchers on-chain later in a single batch. That scheme is called batch-settlement.
Batch-settlement payment flow
You don't implement the protocol yourself โ the @x402/evm SDK does that. New to x402? These are the canonical docs:
The API you expose
One route: POST /, in the OpenAI chat-completions format โ so it looks like any other LLM API and existing types just work.
Loading the live specโฆ
usage must be in your response because the charge is computed from it, and stream: true must be rejected (settlement needs the final token count, which needs the whole reply).Try it right now
Send an unpaid request to our live agent. You can run this verbatim โ it costs nothing, and the 402 it returns is exactly what your own endpoint has to produce:
curl -i -X POST https://llm-agent.fretchen.eu/ \
-H "Content-Type: application/json" \
-d '{"model":"mistral-large-latest","messages":[{"role":"user","content":"Hello"}]}'The interesting part of the response โ note receiverAuthorizer and withdrawDelay: those are the fields enhancePaymentRequirements injects for you in step 3.
HTTP/2 402
access-control-expose-headers: Payment-Required, X-Payment, PAYMENT-REQUIRED
payment-required: eyJ4NDAyVmVyc2lvbiI6MiwicmVzb3VyY2UiOnsidXJs... โ same JSON, base64
{
"x402Version": 2,
"resource": { "url": "/", "description": "AI Assistant chat message", "mimeType": "application/json" },
"accepts": [{
"scheme": "batch-settlement",
"network": "eip155:8453",
"amount": "3000", โ ceiling: 0.003 USDC
"asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913",
"payTo": "0xAAEBC1441323B8ad6Bdf6793A8428166b510239C",
"maxTimeoutSeconds": 120,
"extra": {
"name": "USD Coin", "version": "2",
"receiverAuthorizer": "0xF9B7...2c93", โ injected by the SDK
"withdrawDelay": 86400 โ injected by the SDK
}
}, { "network": "eip155:84532", "...": "the same, for Base Sepolia" }]
}You can't get past this point with curl โ the next request has to carry a signed payment, which means opening a channel. For a real, runnable paid round-trip, use the buyer notebook: it drives a server through deposit โ voucher โ verify โ settle over plain HTTP, on testnet by default (USE_MAINNET = false).
โ sc_llm_x402_buyer.ipynb (Deno notebook)
After payment your endpoint returns the ordinary OpenAI completion object shown in the response table above โ usage included. Errors use the OpenAI shape, { error: { message, type, code } }, with model_not_found for an unadvertised model and stream_unsupported for a streaming request.
Build it, step by step
Each step shows the requirement and the real code from our implementation (sc_llm_x402.ts and x402_server.ts), trimmed for readability and with our infrastructure swapped for portable equivalents. Each snippet ends with a line-anchored link to the original, so you can always diff against something that runs in production.
Snippets use a plain-object handler โ an event in, a { statusCode, headers, body } out. That's what serverless platforms hand you, and it maps to Express or Fetch handlers in a couple of lines.
Where everything goes
This is the whole thing, with a slot for each step. Read it once โ every later snippet fills exactly one of these slots, so you always know whether code belongs at module scope (runs once) or inside the handler (runs per request).
// โโโ server.ts โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ MODULE SCOPE (once)
import { x402ResourceServer, HTTPFacilitatorClient } from "@x402/core/server";
import { BatchSettlementEvmScheme } from "@x402/evm/batch-settlement/server";
import { RedisChannelStorage } from "@x402/evm/batch-settlement/server/redis-storage";
import { privateKeyToAccount } from "viem/accounts";
import { createClient } from "redis";
const MODELS = ["mistral-large-latest"]; // must match the enum you publish (step 6)
const NETWORKS = ["eip155:10", "eip155:8453"]; // Optimism + Base; add "eip155:84532" to test
const RESOURCE = { url: "https://your-agent.example/", description: "chat", mimeType: "application/json" };
// Your price ceiling per message, in USDC atomic units (6 decimals). Pick it as
// (tokens you expect per message) x (your OUTPUT rate) โ pricing the whole estimate at the
// dearer output rate guarantees the ceiling is never an underestimate. 3000 = 0.003 USDC.
const MAX_PRICE_PER_MESSAGE = "3000";
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
const USDC = { /* ... */ }; // โโ step 3
const { resourceServer, scheme } = setupX402(); // โโ step 2
export async function handler(event) {
// โโ preflight: browsers send OPTIONS before a paid POST (step 3)
if (event.httpMethod === "OPTIONS") return { statusCode: 204, headers: CORS, body: "" };
// โโ step 1: parse + validate the OpenAI body
// โโ step 3: no payment? advertise how to pay, return 402
// โโ step 4: verify โ run your model โ settle โ 200
}
// โโโ helpers (module scope) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// json() / CORS โโ step 1
// respond402() โโ step 3
// extractPaymentPayload(), settlementHeaders(), priceFromUsage() โโ step 4
// โโโ two more files โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// cron.ts โ claim your money on a schedule โโ step 5
// openapi.json โ discovery doc + the route that serves it โโ step 61Start from an OpenAI-shaped endpoint
If you already proxy an OpenAI-compatible model, you're done with this step โ just validate the input and reject streaming. Nothing here is x402-specific yet.
Show the request validation + the json/CORS helpers
// Every response needs these โ the assistant is a browser client, and
// Allow-Headers must cover PAYMENT-SIGNATURE or the preflight fails.
const CORS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "Content-Type, PAYMENT-SIGNATURE, X-PAYMENT",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Expose-Headers": "Payment-Required",
"Content-Type": "application/json",
};
function json(statusCode, payload, extraHeaders = {}) {
return { statusCode, headers: { ...CORS, ...extraHeaders }, body: JSON.stringify(payload) };
}
// โโ in the handler โโ
const body = JSON.parse(event.body);
if (body.stream === true) {
return json(400, {
error: { message: "Streaming is not supported.", type: "invalid_request_error", code: "stream_unsupported" },
});
}
if (!Array.isArray(body.messages) || body.messages.length === 0) {
return json(400, { error: { message: "'messages' must be a non-empty array.", type: "invalid_request_error" } });
}
if (!MODELS.includes(body.model)) {
return json(404, {
error: { message: `Unknown model '${body.model}'.`, type: "invalid_request_error", code: "model_not_found" },
});
}Ours: sc_llm_x402.ts:169-215 (validation), sc_llm_x402.ts:78-113 (CORS + error helpers).
2Wire up the x402 resource server
Create the resource server once at startup and register the batch-settlement scheme for each network you accept. Two keys are involved: the receiver address that funds go to, and a separate authorizer key that signs channel configuration off-chain (it never needs funding).
For FACILITATOR_URL, pick a facilitator that advertises batch-settlement on your network โ check its /supported endpoint. Public options are listed in the facilitator list (e.g. Solvador), or you can run your own.
Show the setup (fills the setupX402() slot)
function setupX402() {
const facilitator = new HTTPFacilitatorClient({ url: process.env.FACILITATOR_URL });
const authorizer = privateKeyToAccount(process.env.RECEIVER_AUTHORIZER_PRIVATE_KEY);
const resourceServer = new x402ResourceServer(facilitator);
const scheme = new BatchSettlementEvmScheme(process.env.RECEIVER_ADDRESS, {
storage: new RedisChannelStorage({ client: redis }), // or FileChannelStorage for one instance
receiverAuthorizerSigner: {
address: authorizer.address,
signTypedData: (params) => authorizer.signTypedData(params),
},
onchainStateTtlMs: 5_000, // keep low, or a user's first message after depositing can fail
withdrawDelay: 86_400, // must be >> your claim interval (step 5)
});
for (const network of NETWORKS) resourceServer.register(network, scheme);
return { resourceServer, scheme, facilitator };
}Ours: x402_server.ts:89-109 โ same thing, with S3 for storage.
3Answer unpaid requests with a 402
Build the payment requirements โ the "here's how to pay me" description โ and return them as a 402. The SDK verifies and settles for you, but the HTTP transport โ which headers, encoded how โ is yours to write. It's three ~15-line helpers, shown in this step and the next.
The USDC constants you'll need:
const USDC = {
"eip155:10": { address: "0x0b2C639c533813f4Aa9D7837CAf62653d097Ff85", name: "USD Coin", version: "2" }, // Optimism
"eip155:8453": { address: "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", name: "USD Coin", version: "2" }, // Base
"eip155:84532": { address: "0x036CbD53842c5426634e7929541eC2318f3dCF7e", name: "USDC", version: "2" }, // Base Sepolia
};"USD Coin" on mainnet but "USDC" on testnet. Mix them up and payment verification fails silently, with no useful error.enhancePaymentRequirements. It injects the receiverAuthorizer and withdrawDelay fields the client needs to build a deposit โ you saw both in the curl output above. The same enhancement has to be applied again at verify time (step 4); a bare, un-enhanced object gets every deposit rejected with receiver_authorizer_mismatch.Show the requirements builder + the 402 body
// One enhanced accepts[] entry for a single network. Step 4 calls this again for the
// network the client picked โ which is why it takes `network` and `amount` as arguments
// instead of hard-coding them.
async function requirementsFor(network, amount) {
const usdc = USDC[network]; // address + EIP-712 name/version
const base = {
scheme: "batch-settlement",
network,
amount, // USDC atomic units (6 decimals)
asset: usdc.address,
payTo: process.env.RECEIVER_ADDRESS,
maxTimeoutSeconds: 120, // must be identical in the 402 and at verify
extra: { name: usdc.name, version: usdc.version },
};
return scheme.enhancePaymentRequirements(
base,
{ x402Version: 2, scheme: "batch-settlement", network, extra: base.extra },
[], // x402 extensions โ none, unless you use them
);
}
// The 402 body advertises EVERY network you accept.
async function build402Body() {
const accepts = await Promise.all(
NETWORKS.map((network) => requirementsFor(network, MAX_PRICE_PER_MESSAGE)),
);
return { x402Version: 2, resource: RESOURCE, accepts };
}
// โโ in the handler โโ
const payment = extractPaymentPayload(event.headers);
if (!payment) return respond402(await build402Body());Ours: x402_server.ts:127-171 (the 402 body), sc_llm_x402.ts:244-258 (the handler branch).
{ x402Version, resource, accepts: [...] }. What you pass to verifyPayment/settlePayment is a single entry from that array, for the one network the client chose. Passing the whole 402 body there is the most common way to get this wrong.Show the 402 transport helper (respond402)
// The 402 body must ALSO go, base64-encoded, into the Payment-Required header โ
// browser clients read the header, not the body. CORS already exposes it (step 1),
// which is exactly what the checker at the bottom of this page verifies.
function respond402(body402) {
return json(402, body402, {
"Payment-Required": Buffer.from(JSON.stringify(body402)).toString("base64"),
});
}Ours: x402_server.ts:233-255.
4Verify, answer, settle
This is the step that turns a plain endpoint into a paid one. Verify the voucher, run your inference, then settle โ and note the trick: you verify against a ceiling but settle the amount actually used, so a short reply costs the user less.
Show the handler flow
// 0. Which network did the client choose? It's in the payload โ you can't build the
// verify requirements without it, and you must reject anything you don't serve.
const network = payment.accepted?.network;
if (!network || !NETWORKS.includes(network)) {
return json(402, { error: { message: "Unsupported network for this payment." } });
}
// 1. Rebuild the SAME enhanced requirements โ for that one network, at the ceiling price.
// (Not the 402 body: one accepts[] entry. See the note in step 3.)
const requirements = await requirementsFor(network, MAX_PRICE_PER_MESSAGE);
// 2. Verify the client's voucher.
const check = await resourceServer.verifyPayment(payment, requirements);
if (!check.isValid) {
// Re-emit through the SDK so it can attach corrective channel state the client needs to
// resync (passing the failed payload is what triggers that) โ a hand-rolled 402 body
// breaks the client's automatic retry.
const corrective = await resourceServer.createPaymentRequiredResponse(
[requirements],
RESOURCE,
check.invalidReason,
check.payer ? { payer: check.payer } : undefined,
undefined,
payment,
);
return respond402(corrective);
}
// 3. Do the actual work.
const completion = await callYourModel(body.messages);
// 4. Settle the amount actually used โ same requirements, smaller amount.
const settlement = await resourceServer.settlePayment(payment, {
...requirements,
amount: priceFromUsage(completion.usage),
});
if (!settlement.success) {
return json(402, { error: { message: `Settlement failed: ${settlement.errorReason ?? "unknown"}` } });
}
// 5. 200 + the settlement receipt (json() already merges CORS).
return json(200, completion, settlementHeaders(settlement));Ours: sc_llm_x402.ts:260-268 (network check), sc_llm_x402.ts:287-362 (verify + corrective 402), sc_llm_x402.ts:396-418 (settle + response).
Show the other two transport helpers (extract + settlement headers)
// The payment arrives base64-encoded in the PAYMENT-SIGNATURE header.
// Returns null when there's no payment โ that's the "send a 402" case in step 3.
function extractPaymentPayload(headers) {
const header = headers["payment-signature"] ?? headers["Payment-Signature"];
if (!header) return null;
try {
return JSON.parse(Buffer.from(header, "base64").toString("utf-8"));
} catch {
return null;
}
}
// The settlement receipt goes back base64-encoded in the Payment-Response header
// (merge these into your 200 response's headers).
function settlementHeaders(settlement) {
return { "Payment-Response": Buffer.from(JSON.stringify(settlement)).toString("base64") };
}Ours: x402_server.ts:257-282 and x402_server.ts:300-308.
Show priceFromUsage (tokens โ USDC atomic units)
// Rates are quoted per 1,000,000 tokens; USDC has 6 decimals โ the two 1e6
// factors cancel exactly, so no separate decimals conversion is needed.
// Keep rates as integer fractions (num/den) to stay exact in bigint math.
const INPUT_PER_M = { num: 1n, den: 2n }; // $0.50 per 1M prompt tokens
const OUTPUT_PER_M = { num: 3n, den: 2n }; // $1.50 per 1M completion tokens
function priceFromUsage(usage) {
const p = BigInt(usage.prompt_tokens);
const c = BigInt(usage.completion_tokens);
const cost =
(p * INPUT_PER_M.num * OUTPUT_PER_M.den + c * OUTPUT_PER_M.num * INPUT_PER_M.den) /
(INPUT_PER_M.den * OUTPUT_PER_M.den);
// Never settle above the ceiling you verified against in the 402.
const max = BigInt(MAX_PRICE_PER_MESSAGE);
return (cost > max ? max : cost).toString();
}Ours: llm_service.ts:215-231 (the rate maths) and sc_llm_x402.ts:72-76 (the ceiling clamp).
5Collect your money
Per-message settlements are bookkeeping only โ no funds move. A scheduled job redeems the accumulated vouchers on-chain. Skip this and you never get paid. It's genuinely this short:
Show the claim job (cron.ts โ a separate entry point)
// Same setupX402() as step 2 โ this runs in its own process, on a schedule
// (we use every 12h). Must run far more often than the withdrawDelay you set in
// step 2, or a channel can be withdrawn before you claim it.
const { scheme, facilitator } = setupX402();
for (const network of NETWORKS) {
// Pass the token explicitly: without it the SDK falls back to its own stablecoin
// registry, which has no Optimism entry and throws. See the challenges section.
const manager = scheme.createChannelManager(facilitator, network, USDC[network].address);
const { claims, settle } = await manager.claimAndSettle();
console.log({ network, claims: claims.length, settled: settle !== undefined });
}Ours: llm_x402_cron.ts:62-75 (a 12-hourly scheduled function).
6Publish discovery + allow the browser in
Finally, make yourself findable. Serve an OpenAPI document at GET /openapi.json containing "x-service-type": "llm/v1", your x-payment-info, and an ownership proof. Your CORS setup from step 1 already covers the browser side. One consistency rule: the model enum in your published schema must match the MODELS array your handler validates against (step 1) โ the spec is a promise, the validation enforces it.
Show the discovery doc + the route that serves it
// openapi.json (excerpt)
{
"openapi": "3.1.0",
"x-service-type": "llm/v1",
"servers": [{ "url": "https://your-agent.example" }],
"x-discovery": { "ownershipProofs": ["0x<signature>"] },
"paths": { "/": { "post": { "x-payment-info": {
"protocols": ["x402"],
"price": { "mode": "dynamic", "currency": "USD", "min": "0", "max": "0.003" }
} } } },
"components": { "schemas": {
"LLMChatRequest": { /* model enum must match MODELS from step 1 */ },
"LLMChatResponse": { /* ... */ }
} }
}And the route that serves it โ the first branch of your handler:
import openapiSpec from "./openapi.json" with { type: "json" };
// โโ in the handler, before the POST logic โโ
if (event.httpMethod === "GET" && (event.path ?? "").replace(/^\/+/, "") === "openapi.json") {
// Patch the live ceiling in, so the published price can't drift from what you charge.
// MAX_PRICE_PER_MESSAGE is atomic units ("3000"); the spec wants decimal USD ("0.003").
const spec = structuredClone(openapiSpec);
spec.paths["/"].post["x-payment-info"].price.max = (Number(MAX_PRICE_PER_MESSAGE) / 1e6).toString();
return json(200, spec);
}Ours: sc_llm_x402.ts:120-134 (with an exact bigint formatter, x402_server.ts:290-298, instead of the float division above).
Show how to sign the ownership proof
// One-off: sign your bare origin (scheme + host, no path, no trailing slash)
// and paste the signature into x-discovery.ownershipProofs.
import { privateKeyToAccount } from "viem/accounts";
const account = privateKeyToAccount(process.env.RECEIVER_PRIVATE_KEY);
const signature = await account.signMessage({ message: "https://your-agent.example" });
console.log(signature);Ours: sign_ownership_proof.ts.
Test it
Start on Base Sepolia (eip155:84532) โ same code path, no real money. Fund your test wallet from the Circle faucet, and use the testnet USDC constants from step 3.
While developing, the checker below can point straight at http://localhost:3000 โ browsers treat localhost as a secure context, so a page on https can still reach it (current Chrome and Firefox). Just remember your CORS headers apply locally too.
eip155:10 or eip155:8453. Either one is enough. Everything above it โ discovery, service type, the 402 challenge โ should already be green.The first real payment. Point the buyer notebook at your server and run it top to bottom. It opens a channel, sends a paid message, and prints the settlement โ the fastest way to see deposit โ voucher โ verify โ settle actually working, and it stays on testnet unless you set USE_MAINNET = true.
Then: pay yourself from a browser. Once the checker passes on mainnet, open the assistant, open Use a different agent, paste your URL, and send one real message. That exercises the whole path โ deposit, voucher, verify, settle โ from a real client. Being honest: this is currently the only ready-made batch-settlement client there is (see Known limitations).
Check your endpoint
Paste your URL. This runs exactly the checks the assistant runs before it will talk to an endpoint โ down to reading the base64 Payment-Required header from step 3 โ and tells you which ones fail.
Known limitations
Documented openly โ these are rough edges of a young ecosystem, not of your code.
exact scheme. A handful run batch-settlement on mainnet โ Solvador and this project โ so your options are limited today. The scheme is standard; its EVM wire binding is still defined by the @x402/evm code rather than a ratified spec, which is why adoption is thin.Authorization: Bearer request just hits the 402 and stops.eip155:10) or Base (eip155:8453). Optimism used to be impossible: it isn't in @x402/evm's stablecoin registry, and enhancePaymentRequirements() resolved the asset from that registry rather than from your requirements, so one Optimism entry threw and took the whole 402 down with it. Fixed in 2.20, which honours the asset you pass. The registry gap itself is still there, so anything that falls back to it needs the token spelled out โ notably createChannelManager(facilitator, network, token), whose third argument is optional but throws on Optimism when omitted.withdrawDelay must stay well above how often your claim job runs, or a channel can become withdrawable before you claim it โ losing you earned revenue. We use a 24h delay against a 12h job.Where this is going
The endpoint contract is stable; what's filling in is the ecosystem around it โ a drop-in client, more facilitators, more agents. The assistant already lets you point it at any compatible agent by URL; a curated picker only makes sense once there are enough of them to list.
Built one, or want to be listed when a picker ships? Reach out at fretchen.dev@proton.me or on GitHub. The full reference implementation lives in scw_js/README.md.
Feedback
Stuck on a step, or built one? Leave a note โ it helps the next builder as much as it helps us.