
One payment interface, four chains
A four-method PaymentRail interface, a registry that turns rails into accepts[], a test that fails the build when a chain name leaks into the service, two Base interop surprises, and selling through a gateway that collects the payment for you.
TL;DR
In September 2026 I built Turnstile, a paid lane for onchain data agents. Its seller answers a Uniswap pool question for $0.07, or $0.35 for the premium tier, over x402. By the end, one 402 advertised four ways to pay: HBAR on Hedera testnet, USDC on Arc testnet, USDC on Base Sepolia and USDC on Base mainnet, each through a different facilitator. The code that gates the route knows none of that.
- One interface, four methods:
challenge,verify,settle,receipt. The service works in dollars and passes chain identifiers along without reading them; the rail speaks chains. - A registry builds
accepts[]from every rail and routes the payer's choice back by(scheme, network). - My own middleware, not the SDK's, so each rail implements four methods, and a payment is checked against a fresh quote for the URL requested, with room for HBAR's moving price.
- Verify, then work, then settle. If settlement fails, the answer is withheld.
- A test fails the build if a file on the payment path names a chain, so adding Base never touched the code that takes money.
Adding Base brought two interop rules: x402 v2's resource must be an object (PayAI enforces it), and Circle's USDC is "USD Coin" in its EIP-712 domain on mainnet but "USDC" on testnet, which surfaces as an invalid signature.
Bazantic resells API calls: the buyer pays the gateway, and the gateway forwards the call to my seller without paying it. So the seller needs a third answer besides paid and unpaid: a trusted key for calls already paid at the gateway.
The whole codebase can be found on Github.
Part 3 of 9 in the Turnstile series: building a paid lane for onchain data agents.
What the buyer sees
x402 revives HTTP 402 Payment Required. A buyer agent needs no account or API key to learn the price. It just needs to ask the API.
1. Ask without paying. The agent calls the endpoint like any API:
GET https://turnstile.moveseventyeight.com/analyze/0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640
Finding that endpoint in the first place is discovery, through ERC-8004 registrations and ENS records, which Part 4 and Part 5 cover.
2. The seller answers 402 with its options. The response carries a PaymentRequired object, base64-encoded in the PAYMENT-REQUIRED header, and Turnstile also sends it as the body. Its accepts[] has one entry per rail.
{
"x402Version": 2,
"resource": { "url": "https://turnstile.moveseventyeight.com/analyze/0x88e6…5640", "serviceName": "liquidity.turnstile.eth" },
"accepts": [
{ "scheme": "exact", "network": "hedera:testnet", "asset": "0.0.0", "amount": "80897110", "payTo": "0.0.10403961",
"extra": { "symbol": "HBAR", "feePayer": "0.0.7162784" } },
{ "scheme": "exact", "network": "eip155:5042002", "asset": "0x3600…0000", "amount": "70000", "payTo": "0x0Adc…f5F2",
"extra": { "name": "GatewayWalletBatched", "verifyingContract": "0x0077…19b9" } },
{ "scheme": "exact", "network": "eip155:84532", "asset": "0x036C…CF7e", "amount": "70000", "payTo": "0x0Adc…f5F2",
"extra": { "name": "USDC", "version": "2" } },
{ "scheme": "exact", "network": "eip155:8453", "asset": "0x8335…2913", "amount": "70000", "payTo": "0x0Adc…f5F2",
"extra": { "name": "USD Coin", "version": "2" } }
]
}
All four entries ask for the same $0.07. On Hedera that's 80,897,110 tinybars, 0.809 HBAR at the rate the rail read from Hedera's mirror node. On the other three it's 70,000 units of USDC. Each extra carries what a payer needs to sign for that chain.
3. The agent picks one and signs it. It selects the entries it has a signer for and that its spending rules allow, then picks by preference and price. Part 7 covers the rules Turnstile's buyer uses. It retries the same request with a PAYMENT-SIGNATURE header, whose payload holds the signature and accepted, a copy of the entry it chose.
4. The seller routes, verifies, works and settles. The scheme and network in accepted tell the seller (our server) which rail to hand the payment to. The answer comes back as a 200, with the receipt in a PAYMENT-RESPONSE header.
The rest of this article is the seller's side of steps 2 and 4: how one service builds that list from four chains, and routes each payment back without knowing which chain it was.
One seller, four settlement stacks
The v2 spec splits the protocol into three layers:
- The types (
PaymentRequired,PaymentPayload, the settlement response) are the same everywhere. - The logic depends on two things: the scheme, which says what kind of payment it is (
exactmeans "pay this precise amount") and the network, which says where it settles, named by a CAIP-2 id such aseip155:8453for Base. - The transport is how the types travel; over HTTP, they ride in the headers above.
The seller rarely touches a chain itself. It hands the signed payment to a facilitator, "a service that handles payment verification and blockchain settlement". A facilitator exposes three endpoints: /verify checks a payment without moving money, /settle submits it onchain, and /supported lists the scheme and network pairs it handles.
That sounds like chain-agnosticism for free. Below the message format, our four rails share almost nothing:
| Rail | Network | How a payment works | Facilitator |
|---|---|---|---|
hedera-x402 |
hedera:testnet |
Payer part-signs a transfer naming the facilitator as fee payer; facilitator co-signs | Blocky402 |
arc-usdc |
eip155:5042002 |
EIP-3009 authorization against Circle's GatewayWallet, spending a pre-deposited balance; Circle batches settlement | Circle Gateway |
base-usdc |
eip155:84532 |
EIP-3009 authorization against the USDC contract; the facilitator submits one transferWithAuthorization per payment |
x402.org |
base-usdc-mainnet |
eip155:8453 |
Same code as base-usdc, with mainnet's chain id, USDC address and EIP-712 name |
PayAI |
Hedera needs an FX rate, Arc credits a Gateway balance, Base needs the token's EIP-712 domain in the challenge.
The route handler is the code that issues the 402, checks the payment and decides whether the answer goes out. If it handled the implementation details for payments itself, with an if hedera here and an if base there, every new chain would mean editing the code that takes money, and risking a bug in the payments that already work. So each chain's details live in its own rail, a module under rails/, and the handler talks to every rail through the same four methods.
(The first two rails have their own write-ups: x402 on Hedera with Blocky402 and gasless nanopayments on Arc.)
Four methods between the service and the chain
The rule at the top of rails/PaymentRail.ts: the service speaks US dollars, the rail speaks chains. The service asks for a price in dollars. The rail converts it to the asset's smallest unit (tinybars on Hedera, at the current HBAR rate; USDC's six-decimal units on Arc and Base, so $0.07 is 70000), names its payout account in its own format, and puts everything chain-specific into extra.
The service builds the 402 response. It copies each rail's network id, payout address and extra into it without reading them. Routing a payment is a lookup: the registry keys each rail by the scheme and network it declares, and finds the one the payer signed for. Nothing in the service tests for a particular chain.
export interface ChallengeRequest {
resource: string;
description: string;
priceUsd: number;
maxTimeoutSeconds?: number;
}
export interface PaymentRequirement {
scheme: string;
network: NetworkId;
asset: string;
amount: string;
payTo: string;
maxTimeoutSeconds: number;
extra: Record<string, unknown>;
}
export interface PaymentRail {
readonly id: string;
readonly info: RailInfo;
challenge(req: ChallengeRequest): Promise<PaymentRequirement>;
verify(payload: PaymentPayload, context?: VerifyContext): Promise<VerifyResult>;
settle(payload: PaymentPayload): Promise<Receipt>;
receipt(id: string): Promise<Receipt | null>;
}
rails/PaymentRail.ts, trimmed, comments removed
Three choices in that interface. The types are structurally x402 v2's but declared locally, so an SDK upgrade can't silently change the interface; a test validates them against the SDK's schema. All four methods are async, because Hedera needs a rate and Base checks /supported before quoting. And verify returns a reason code (insufficient_funds, unsupported_rail, expired...) rather than a boolean, because the buyer does something different for each. A bad payment becomes a 402; a rail that can't answer throws PaymentRailError and becomes a 503.
The registry: rails in, accepts[] out
constructor(rails: readonly PaymentRail[]) {
if (rails.length === 0) throw new Error('a seller with no payment rails can never be paid; pass at least one');
for (const rail of rails) {
const k = key(rail.info.scheme, rail.info.network);
const clash = this.byKey.get(k);
if (clash) {
throw new Error(
`rails '${clash.id}' and '${rail.id}' both claim scheme ${rail.info.scheme} on ${rail.info.network}; ` +
'a payment naming that pair would be unroutable',
);
}
// ...
}
}
async challengeAll(req: ChallengeRequest) {
const settled = await Promise.allSettled(this.rails.map(rail => rail.challenge(req)));
const accepts: PaymentRequirement[] = [];
const failed: { railId: string; error: string }[] = [];
settled.forEach((result, i) => {
const rail = this.rails[i]!;
if (result.status === 'fulfilled') accepts.push(result.value);
else failed.push({ railId: rail.id, error: result.reason instanceof Error ? result.reason.message : String(result.reason) });
});
return { accepts, failed };
}
rails/registry.ts, trimmed
Routing is by (scheme, network) because that is what each signed payment names. The payer picks the rail by choosing which entry to sign. If two rails declared the same scheme and network, say Base mainnet through two different facilitators, a signed payment couldn't say which one it was for. So the registry refuses to start when two rails share the same network and schema.
A rail that can't quote is dropped instead of failing the request; only when everything fails does the service return 503. The composition root, server.ts, is the one place that names concrete rails: new RailRegistry([createHederaRail(), createArcRail(), createBaseRail(), createBaseMainnetRail()]).
The live 402 lists hedera:testnet, eip155:5042002, eip155:84532 and eip155:8453.
Why not the SDK's resource-server middleware
The payment headers are encoded and validated with @x402/core, the official SDK, so any x402 client can read them. The middleware around them, about 120 lines, is ours.
The SDK's middleware checks a payment against the route it's paying for, so it would stop a $0.07 payment buying the $0.35 tier too. We wrote our own for two other reasons.
First, the SDK's middleware drives its own plug-ins: a scheme server per network that prices each challenge (parsePrice, enhancePaymentRequirements and several optional hooks), and a facilitator client it calls to verify and settle. Each rail would have been split across those two, in the SDK's order, instead of being one object with four methods.
Second, it requires the payment's amount to equal a fresh quote exactly. The Hedera rail prices in HBAR at the current rate, so if the rate moves between the 402 and the payment, the fresh quote changes, and an honest payment would be refused.
So on a paid request, x402.ts re-issues the challenge for the URL requested and checks the payer's chosen entry against it: same (scheme, network), asset and payTo, and amount >= quoted. The >= tolerates HBAR moving between quote and payment, and is safe in the direction that matters:
test('a payment issued for the cheap tier cannot buy the premium one', async () => {
await withServer(app(), async baseUrl => {
const { body: cheap } = await challengeFor(baseUrl, `/analyze/${POOL}`);
const res = await fetch(`${baseUrl}/analyze/${POOL}/attested`, {
headers: { 'PAYMENT-SIGNATURE': sign(cheap.accepts[0]!) },
});
assert.equal(res.status, 402);
const body = await res.json() as PaymentRequiredBody;
assert.match(body.error ?? '', /below the .* quoted for this resource/);
assert.ok(BigInt(body.accepts[0]!.amount) > BigInt(cheap.accepts[0]!.amount));
});
});
seller/service/x402.test.ts, comments removed
One caveat: rails also write extra.resource and compare it in verify(), but that is advisory. An EIP-3009 authorization commits to from, to, value, the validity window and nonce, not to a URL, so a payer can edit it. What holds is the service's fresh amount check plus each rail's replay guard (keyed on from:nonce on EVM).
The risk of custom middleware is speaking an x402 dialect only our client understands. x402-interop.test.ts covers it: the 402 must pass the SDK's validatePaymentRequired, and an unmodified @x402/fetch client must complete the flow and get a 200.
Verify, then work, then settle, and who takes the loss
if (!verified.valid) {
challenge.error = `payment rejected by ${rail.id}: ${verified.reason ?? 'unknown'}`;
sendChallenge(res, challenge);
return;
}
const body = await handler(req);
let receipt: Receipt;
try {
receipt = await rail.settle(payload);
} catch (cause) {
res.status(402).json({
error: `settlement failed on ${rail.id}: ${cause instanceof Error ? cause.message : String(cause)}`,
x402Version: X402_VERSION,
accepts,
});
return;
}
if (!receipt.success) {
res.status(402).json({
error: `settlement failed on ${rail.id}: ${receipt.error ?? 'unknown'}`,
x402Version: X402_VERSION,
accepts,
});
return;
}
res.set('PAYMENT-RESPONSE', encodePaymentResponseHeader(receiptToSettleResponse(receipt) as never));
res.status(200).json(body);
seller/service/x402.ts, trimmed (rejection detail and cache header omitted)
If the process fails, then who pays for the cost?
- Bad payment: refused before any work. Nobody loses.
- Handler throws (subgraph down, bad pool address): no value moved, so the payer isn't charged. We lose the compute.
- Settlement fails after the work: the answer is withheld and the buyer gets a 402 and can retry. We lose the cost of compute, rather than giving the answer away.
This is what the 402 v2 spec calls the default authorization flow, "verify → resource → settle → respond", where "funds move only after it completes successfully". The spec also defines upfront and escrow flows that settle before the resource runs; which one applies is a property of the scheme. Rails report a refused settlement as { success: false, error } rather than throwing. Not getting paid is an expected outcome with a reason, such as the payer's balance having moved since verify, and the service passes that reason to the buyer in the 402. A thrown error gets the same 402, for failures a rail didn't anticipate.
A test that keeps chains out of the payment code
The seller is three folders.
seller/analyst/does the work: it queries the Uniswap subgraph and scores the pool.rails/holds one folder per chain.seller/service/sits between them: it serves the HTTP routes, sets the tier prices, runs the x402 middleware, and calls the analyst only once a payment checks out.
seller/service/ is the code that takes money, and it's chain-agnostic. Adding a chain means a new folder under rails/ and one line in server.ts that adds the rail to the list. Keeping it that way makes easier to extend the system without turning the code into an endless stream of if-else statements. For example, Hedera and Arc were built at the same time, and each had a shortcut available: a small if in the service, a Hedera-shaped field on the challenge, a special case for how Arc reports a payer. Each looks harmless. Together they put chain logic back in the payment code, and the next chain would mean editing it again.
So the rule is a test, and a change that breaks it fails the build instead of relying on someone to spot it in review. The test reads every TypeScript file in seller/service/, strips the comments, and fails if the code names a chain, a facilitator, an asset or a signature format:
const FORBIDDEN: [RegExp, string][] = [
[term('hedera'), 'a chain'],
[term('blocky402'), 'a facilitator'],
[term('arc'), 'a chain'],
[term('usdc'), 'an asset'],
[term('eip155'), 'a chain namespace'],
[term('viem'), 'a chain library'],
[term('chainId'), 'a chain identifier'],
[term('privateKey'), 'a chain key'],
[/\beip-?712\b/i, 'a signature format'],
[/\beip-?3009\b/i, 'a transfer authorization format'],
// ...
];
seller/service/no-chain-code.test.ts, trimmed
term() matches a name inside camelCase too, so payload.hederaTransaction fails the test, while arcane and circles pass. The test also checks imports. Only server.ts, which wires the rails together at startup, may import a concrete rail, and x402.ts may import only the interface and the registry from rails/. A few files are exempt by name, such as server.ts and the discovery module, whose job is reading a multi-chain registry. Any new file is checked by default.
The test matches names, so chain logic written in neutral words would get through. It was enough: when Base landed, two chains touched rails/base-usdc/ and one line of server.ts, and nothing on the payment path.
Adding Base: two rails, one implementation
Base is where x402 started, since Coinbase created the protocol, so it gets two rails: Sepolia for testing and mainnet for real money.
On EVM, x402's exact scheme defaults to EIP-3009: the payer signs TransferWithAuthorization against the token's EIP-712 domain, and the facilitator calls transferWithAuthorization. EIP-3009 lets users "delegate the gas payment to someone else", so the payer needs only USDC. The scheme spec makes extra.name and extra.version required in the challenge.
Every difference between the chains was a constant, so mainnet just needs to get a second instance:
export const MAINNET_NETWORK = 'eip155:8453';
export const MAINNET_CHAIN_ID = 8453;
export const MAINNET_USDC_ASSET = '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913';
export const MAINNET_EIP712_NAME = 'USD Coin';
export const MAINNET_FACILITATOR_URL = 'https://facilitator.payai.network';
export function createBaseMainnetRail(options: BaseRailOptions = {}): PaymentRail {
return createBaseRail({
id: 'base-usdc-mainnet',
chainLabel: 'Base',
network: MAINNET_NETWORK,
asset: MAINNET_USDC_ASSET,
eip712Name: MAINNET_EIP712_NAME,
facilitatorUrl: MAINNET_FACILITATOR_URL,
rpcUrl: MAINNET_RPC_URL,
...options,
});
}
rails/base-usdc/config.ts and rails/base-usdc/index.ts, trimmed
The x402.org facilitator only lists Base Sepolia, so mainnet goes through PayAI, whose /supported lists exact on eip155:8453 and needs no API key. Two details matter when you sign for Base.
resource is an object. In x402 v2, a 402 describes what's for sale in a resource object. It has a required url and optional fields that catalogues use for discovery: description, mimeType, serviceName, tags and iconUrl. Turnstile's 402 sends the URL, a one-line description, application/json, and liquidity.turnstile.eth as the service name. The payer can echo the object back in its payment, and PayAI checks its shape, so a plain URL string there is rejected.
"USD Coin" is not "USDC". A payer signs TransferWithAuthorization against the token's EIP-712 domain, four fields that tie a signature to one contract on one chain. For Circle's USDC on Base:
| Base mainnet | Base Sepolia | |
|---|---|---|
name |
"USD Coin" |
"USDC" |
version |
"2" |
"2" |
chainId |
8453 |
84532 |
verifyingContract |
0x8335…2913 |
0x036C…CF7e |
The payer takes them from the 402: name and version from the entry's extra, chainId from network, and the contract from asset. USDC doesn't expose its domain through EIP-5267's eip712Domain(), so each Base rail sets these in its config, as read from the contract's name() and version(). Hashing the four fields reproduces the contract's DOMAIN_SEPARATOR() on both chains.
Get one field wrong and the signature is valid, but over the wrong domain. The facilitator answers invalid_exact_evm_signature, the same error as a bad key. A test pins extra.name === 'USD Coin' and chainId === 8453 for mainnet.
Selling through a gateway that collects the payment
Turnstile also sells through Bazantic, which we used as a distribution channel. Bazantic is a marketplace for paid APIs: a provider lists an API behind Bazantic's gateway, and agents find it in the catalogue and pay per call with the baz CLI or through the gateway's MCP endpoint, from a balance they already hold with Bazantic. Those buyers never see Turnstile's 402 or pick a rail.
Bazantic's gateway "sits in front, handles x402/MPP settlement, and forwards each call to the backend", and pays out to a receiving address set on the listing. Toward the backend it authenticates with an API key, basic auth or nothing, plus any extra headers. It never pays the backend's own 402.
Without anything else, every call through Bazantic would hit two paywalls. The buyer pays Bazantic, Bazantic forwards the call with no payment attached, and Turnstile answers it with its own 402. So the seller (our server) has to recognise a call that Bazantic has already been paid for. The listing sends a shared secret in a header, and the seller checks it before asking any rail for a quote:
const gateway = gateways.identify(req.header(GATEWAY_KEY_HEADER));
if (gateway) {
const body = await handler(req);
res.set('X-Turnstile-Settled-By', gateway);
res.set('Cache-Control', 'no-store');
res.status(200).json(body);
return;
}
seller/service/x402.ts, trimmed (logging omitted); key parsing in seller/service/gateway.ts
The secret rides in x-turnstile-gateway-key, set as an extra header on the listing. gateway.ts refuses to boot on a malformed pair or a key under 32 characters, so a typo closes the seller instead of opening it, and compares SHA-256 digests with timingSafeEqual. A wrong or missing key gets the normal 402. The key is the trust you give any reseller. Bazantic, or anyone who got the key from it, gets answers without paying Turnstile. The key lives only in Turnstile's environment and on the listing, and changing it in both places revokes it. Payment for those calls shows up as Bazantic's payout to the listing's address, not as x402 receipts at the seller.
The architecture point: x402 composes when each hop settles its own payment, and breaks when one party settles on another's behalf. A service that never knows how it was paid still needs a third answer besides "paid here" and "unpaid": "paid somewhere I trust".
Sources
- x402 specification v2: layers, facilitator
/verifyand/settle,ResourceInfoobjects, and theauthorization/upfront/escrowflows. - x402 HTTP transport v2: the
PAYMENT-REQUIRED,PAYMENT-SIGNATUREandPAYMENT-RESPONSEheaders. - x402
exactscheme on EVM: EIP-3009 by default,extra.nameandextra.versionrequired. - x402.org: "an open, neutral standard for internet-native payments".
- EIP-712:
EIP712Domainfields and the domain separator's purpose. - EIP-3009:
transferWithAuthorization, random nonces, gas delegation. - Circle USDC contract addresses: Base and Base Sepolia USDC, matching
config.ts. - PayAI
/supported: listsexactoneip155:8453. - PayAI facilitator pricing: free tier and per-settlement pricing as PayAI states it.
- Bazantic: Deploy a gateway and llms.txt: the authentication-method wording; production gateways settle USDC on Base.
- Base tx 0xf1088b77…919f: the $0.07 gateway payment.
- Turnstile repository
Next in the series: 197 ERC-8004 agents, and where their prices live, part 4 of 9.
More articles


