
x402 on Hedera: what a paid HTTP request is made of
A seven-cent API call settled through Blocky402: the 402 challenge, a transfer the buyer signs but cannot send, a facilitator that pays the gas, a replay guard, and a receipt anyone can read back.
TL;DR
I built Turnstile for ETHOnline 2026. Turnstile is a paid lane for onchain data agents: an agent asks about data from a Uniswap pool, gets 402 Payment Required, pays, and gets the answer. One rail settles on Hedera testnet through a public x402 facilitator, Blocky402.
This article follows one real request: transaction 0.0.7162784@1788791855.758948636, settled 2026-09-07, moving 0.84367844 HBAR ($0.07) from buyer to seller, with zero buyer gas and a receipt on HCS topic 0.0.10408013.
- A paid HTTP request is three headers and two facilitator calls.
PAYMENT-REQUIREDgoes out with the 402,PAYMENT-SIGNATUREcomes back, andPAYMENT-RESPONSEgoes out with the 200. In between, the server calls the facilitator's/verifybefore doing the work and/settleafter. - On Hedera, the "signature" is a transaction. On EVM chains, x402 uses an EIP-3009 authorization. Hedera has nothing like it, so the buyer builds a transfer, names the facilitator's account as fee payer, and signs only its own part. What it sends is a transaction it cannot submit. The facilitator adds the missing signature and submits it.
- Replay protection has to live on the seller too, or someone could buy one answer and walk away with two.
- The receipt goes on a public HCS topic, not in our database, so anyone can check settled volume without asking us.
- You can find the project repo on my GitHub: Turnstile.
Part 1 of 9 in the Turnstile series: building a paid lane for onchain data agents.
A status code nobody used
HTTP has had a status code for payments since the 1990s, but almost nobody has used it. MDN still describes 402 Payment Required as nonstandard, reserved for digital cash or micropayment systems, and notes that "no standard use convention exists". So for decades the answer to "how does a program pay for an API call" has been: sign up to a platform, get an API key, add a card, and trust that the provider's billing dashboard agrees with your logs.
That model breaks for AI agents. An agent that needs a data seller at runtime can't fill in a signup form, and the seller doesn't want a customer relationship for a seven-cent query. What both sides want is simple. The server names a price, the client pays in the next request, the server hands over the answer.
x402, an open standard now looked after by the x402 Foundation, turns that into a protocol. Turnstile needed it at its center, because the whole product is a seller agent selling an answer to a buyer agent. I built it over ETHOnline 2026.
x402 in one round trip
The x402 v2 HTTP transport spec is short. Three headers, each carrying base64-encoded JSON:
| Header | Direction | Carries |
|---|---|---|
PAYMENT-REQUIRED |
server to client, with the 402 | a PaymentRequired object: the resource, and an accepts[] array of ways to pay |
PAYMENT-SIGNATURE |
client to server, on the retry | a PaymentPayload: the accepts[] entry the client picked, plus a scheme-specific payload |
PAYMENT-RESPONSE |
server to client, with the 200 | the settlement result: success, transaction id, network, payer |
Each entry in accepts[] names a scheme (Turnstile only uses exact; the spec also defines upto, batch-settlement and auth-capture), a network as a CAIP-2 id, an asset, an amount in the asset's smallest unit, a payTo address, and an extra bag for anything specific to that chain.
The third party is the facilitator, a service with two REST endpoints: POST /verify, which checks a payment without moving anything, and POST /settle, which puts it on chain. The spec says /verify "MUST NOT commit payment state or write onchain state". The default ordering, called authorization, is:
- Verify: the facilitator checks that the signed payment is valid. Nothing moves.
- Resource: the server runs the handler and computes the answer. In the snippet below, that's
const body = await handler(req). - Settle: the facilitator puts the payment on chain.
- Respond: the server sends the answer back with
PAYMENT-RESPONSE.
In Turnstile's seller/service/x402.ts, it looks like this:
// verify -> do the work -> settle.
verified = await rail.verify(payload, { resource, offered: accepts });
if (!verified.valid) { sendChallenge(res, challenge); return; }
const body = await handler(req); // the work, before any value moves
receipt = await rail.settle(payload);
if (!receipt.success) {
// The work is done but unpaid. Withholding it is the only option that
// neither gives the answer away nor charges for one not delivered.
res.status(402).json({ error: `settlement failed on ${rail.id}: ...`, accepts });
return;
}
res.set('PAYMENT-RESPONSE', encodePaymentResponseHeader(receiptToSettleResponse(receipt)));
res.status(200).json(body);
Trimmed from seller/service/x402.ts.
One design choice matters for the rest of this article. Turnstile's seller offers several chains in the same 402 and lets the buyer pick, so the code that gates the route can't depend on any of them. Nothing under seller/service/ knows what a chain is.
Everything specific to Hedera travels in two separate pieces, extra on the way out and payload on the way back, and the service copies both without reading them. That way, adding a chain never means editing the code that takes money: adding Base later needed no changes on the payment path. How that seam works is in One payment interface, four chains. This article is about what goes inside on Hedera.
x402 on EVM
On regular EVM chains, x402's exact scheme defaults to EIP-3009, transferWithAuthorization. The payer signs an EIP-712 message saying "move X USDC from me to Y, valid between these times, with this random nonce". Anyone holding that signature can submit it, so the facilitator submits it and pays the gas. The payer never sends a transaction.
Turnstile's other rail, on Arc through Circle Gateway, does exactly that. I wrote it up in Gasless nanopayments on Arc, and the nonce that stays at 0.
This works because the token contract has a function that accepts someone else's signature. Hedera's native HBAR and HTS tokens have no such function. So what does "sign, but let someone else pay" look like when the thing you sign has to be a real transaction?
The Hedera version: a transaction you cannot send
First, Hedera isn't an EVM chain underneath. It's its own ledger with an EVM compatibility layer on top:
- The native layer runs hashgraph consensus, names accounts
0.0.xxxx, and treats HBAR and HTS tokens as part of the ledger rather than as contracts: you move them with a nativeTransferTransaction. - The EVM layer, the Hedera Smart Contract Service, runs Solidity on Besu's EVM behind a JSON-RPC relay, with chain id 295 on mainnet and 296 on testnet, and lets contracts call HTS tokens through an ERC-20-style interface.
x402's Hedera binding uses the native layer, under its own CAIP-2 namespace, hedera:.
Back to the question. The answer hangs on one native detail. Hedera's docs say a transaction id is "composed of the account ID of the account paying for the transaction and the transaction's valid start time", written accountID@validStartTime, and that "the account paying for the transaction must always sign". On Hedera, the fee payer is part of the transaction's identity.
The x402 exact scheme spec for Hedera builds on that. In order:
- The 402's
extramust include afeePayer, "the Hedera account ID (0.0.xxxx) of the identity that will pay the network fees". This is usually the facilitator. - The client builds a
TransferTransactionfrom itself topayToforamount, and "sets thetransactionId.accountIdtoPaymentRequirements.extra.feePayer". - The client signs it. That gives "a partially signed transaction (the fee payer's signature is still missing)".
- The client base64-encodes it and sends it as
payload.transaction. - The resource server passes it to
/verify, then/settle. On settle, the facilitator "adds its signature as thefeePayerand submits the now fully signed transaction".
The partially signed transaction can be tricky to understand at first. It has the full shape of a valid transaction, minus one required signature.
- The buyer can't broadcast it, because the fee payer hasn't signed.
- The seller can't change it, because editing the body would break the buyer's signature.
- The facilitator can't redirect the money either: the spec requires it to reject anything except a plain transfer that sends exactly
amounttopayTo, and forbids the fee payer from appearing as a net sender of funds. The facilitator can pay the fee or refuse. Nothing else.
Here's the 402 Turnstile sent for the request in this article:
{
"scheme": "exact",
"network": "hedera:testnet",
"asset": "0.0.0",
"amount": "84367844",
"payTo": "0.0.10403961",
"maxTimeoutSeconds": 300,
"extra": {
"feePayer": "0.0.7162784",
"facilitator": "https://api.testnet.blocky402.com",
"settlementModel": "facilitator-cosigned-transaction",
"decimals": 8,
"priceUsd": 0.07,
"usdPerUnit": 0.08297,
"rateSource": "hedera mirror node /api/v1/network/exchangerate (248910 cents per 30000 HBAR)",
"turnstileSettlement": "live"
}
}
The accepts[] entry, trimmed, from docs/payment-flow.md.
Four things in it are easy to get wrong.
networkishedera:testnet, noteip155:296. Blocky402 lists Hedera under its own CAIP-2 namespace.assetis0.0.0, native HBAR, as the spec reserves. Testnet USDC exists, but an HTS transfer needs both accounts associated with the token first.amountis in tinybars, with 8 decimals. One HBAR is 10⁸ tinybars. Using USDC's 6 decimals by habit would underpay by a factor of 100.- The price is set in dollars and converted at Hedera's own network exchange rate, read from the public mirror node: 248,910 cents per 30,000 HBAR is $0.08297 per HBAR, and $0.07 at that rate is 84,367,844 tinybars. In Turnstile the buyer reads the rate for itself and ignores the seller's
usdPerUnit. Otherwise a seller could name any price and pick a rate that makes it look like seven cents.
On the buyer side, the x402 Foundation's @x402/hedera package builds the transaction, because the facilitator rejects anything that doesn't match the shape it expects:
import { PrivateKey, createClientHederaSigner } from '@x402/hedera';
const inner = createClientHederaSigner(accountId, PrivateKey.fromStringECDSA(rawKey), { network });
// ...inside the RailSigner:
async sign(requirement) {
return { transaction: await inner.createPartiallySignedTransferTransaction(requirement) };
},
Trimmed from buyer/watchdog/hedera-signer.ts.
The seller side needs even less. @x402/hedera also ships a facilitator scheme, the code that adds the fee payer's signature and submits the transfer. It can only run where the fee payer's private key lives, and that key belongs to Blocky402. Our server never holds it; it only talks to Blocky402 over HTTP, so rails/hedera-x402/facilitator.ts is a plain fetch client making three calls:
GET /supported, to learn which account pays the fees, so the seller can put it in the challenge'sextra.feePayer. The answer is cached, since it changes only if Blocky402 rotates that account.POST /verify, before the work.POST /settle, after it.
Both POSTs send the same body: { x402Version, paymentPayload, paymentRequirements }.
Blocky402
Blocky402 is an open-source (MIT) x402 facilitator from BlockyDevs. The hosted version covers Polygon Amoy, Solana Devnet and Hedera Testnet, plus Hedera mainnet. On testnet it needed no signup and no API key. An unauthenticated GET /supported returned the Hedera entry along with its fee payer:
{"x402Version":2,"scheme":"exact","network":"hedera:testnet","extra":{"feePayer":"0.0.7162784"}}
The rail doesn't hardcode that account. challenge() reads /supported (cached for five minutes), so if Blocky402 rotates its fee payer, the seller answers 503 instead of issuing unpayable quotes. Hedera's own x402-inference-pay-per-request-poc also routes its mainnet payments through api.blocky402.com.
What landed on chain
Anyone can read the settled transaction from Hedera's public testnet mirror node, no key needed.
{
"name": "CRYPTOTRANSFER",
"result": "SUCCESS",
"transaction_id": "0.0.7162784-1788791855-758948636",
"charged_tx_fee": 241050,
"transfers": [
{ "account": "0.0.802", "amount": 241050 },
{ "account": "0.0.7162784", "amount": -241050 },
{ "account": "0.0.10403961", "amount": 84367844 },
{ "account": "0.0.10408012", "amount": -84367844 }
]
}
From GET https://testnet.mirrornode.hedera.com/api/v1/transactions/0.0.7162784-1788791855-758948636, trimmed.
Let's check the transaction id. It starts with 0.0.7162784, Blocky402's account, not the buyer's. That account paid the 241,050-tinybar fee. The buyer, 0.0.10408012, went down by exactly the price. The seller, 0.0.10403961, went up by exactly the price. That transfer list is the whole gasless claim.
The replay guard
The scheme spec says the transaction must "not have been previously submitted/observed", and facilitators "SHOULD perform idempotency / replay checks where possible". The network enforces this at its own level too: a frozen transaction has a unique id, and Hedera rejects a duplicate.
For a seller, that protection comes too late. Our ordering is verify, work, settle. If someone sends the same PAYMENT-SIGNATURE twice, both copies can pass /verify before either settles. The analyst runs twice and hands over two answers, and only then does the second settlement fail.
Hedera stops the double charge, but nothing stops the double delivery. So the seller keeps track of what it has already been paid with:
// The replay guard. A frozen Hedera transaction has a unique transaction
// id, so the network itself rejects a second submission with
// DUPLICATE_TRANSACTION, but only after we have already done the work and
// handed over the answer. Refusing here is what makes one payment buy
// exactly one answer.
if (book.isSpent(signedTransaction)) {
return { valid: false, reason: 'already_settled', payer: null,
detail: 'this transaction has already been settled by this seller' };
}
From verify() in rails/hedera-x402/index.ts. The key is the base64 of the bytes the payer signed.
The test script builds one payment by hand and sends the same header twice:
first send -> HTTP 200 0.0.7162784@1788791871.396045089
second send -> HTTP 402
payment rejected by hedera-x402: already_settled -
this transaction has already been settled by this seller
The normal @x402/fetch client can't demonstrate this failure, because it creates a fresh transaction for every call, which is the correct thing for a client to do. That's why the guard belongs on the seller.
Our implementation has two limits:
- The spent set is in memory (
SettlementBook"survives one process"), so it doesn't survive a restart or span several instances. That's fine for a one-host hackathon, not for production. - The resource binding is weaker than it looks.
verify()comparesextra.resource, but a Hedera transaction commits topayTo,amount,assetandfeePayer, not to a URL, so a payer can edit it. What stops a $0.07 payment buying the $0.35 tier is the amount check against a fresh quote, plus the replay guard.
A receipt anyone can read
After every settlement, the rail writes one message to a Hedera Consensus Service topic. Hedera describes HCS as a way to "create a verifiable timestamp and order of events for any application": an append-only log the network orders and timestamps, readable by anyone through a mirror node.
The reason is discovery. Turnstile ranks sellers by settled volume, and a seller keeping receipts in its own SQLite can write whatever it likes. So the receipt goes to the topic, and the database becomes a cache of something anyone can check.
Here's the message for this payment, decoded from the mirror node while writing this. It's sequence number 5 on topic 0.0.10408013:
{"v":1,"kind":"turnstile.settlement","railId":"hedera-x402","network":"hedera:testnet",
"transaction":"0.0.7162784@1788791855.758948636","payer":"0.0.10408012",
"payTo":"0.0.10403961","amount":"84367844","asset":"0.0.0","priceUsd":0.07,
"resource":"http://127.0.0.1:33871/analyze/0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640",
"settledAt":1788791866251}
Reading it back needs no key and no SDK, just the route anyone could use:
async read(limit = 100): Promise<HcsReceiptMessage[]> {
const url = `${this.mirrorNodeUrl}/api/v1/topics/${this.topicId}/messages?limit=${limit}&order=asc`;
const res = await this.doFetch(url, { signal: AbortSignal.timeout(20_000) });
const body = await res.json();
const out: HcsReceiptMessage[] = [];
for (const entry of body.messages ?? []) {
try {
// Mirror node returns the message base64-encoded.
const parsed = JSON.parse(Buffer.from(entry.message, 'base64').toString('utf8'));
// Someone else's message on our topic is possible if the topic has no
// submit key. Filtering on the marker is cheaper than trusting it.
if (parsed?.kind === 'turnstile.settlement') out.push(parsed);
} catch { /* not ours, not JSON: skip */ }
}
return out;
}
Trimmed from rails/hedera-x402/hcs.ts.
- The topic has no submit key. Anyone can post to it. That's why the reader filters on
kindand skips anything it can't parse. - The message records what was bought, never what was sold. The topic is public, so writing the analyst's verdict into it would give away the thing the buyer paid for.
- A failed HCS write doesn't fail the payment. The transfer already settled. Refusing to deliver the answer because of a bookkeeping failure would punish the buyer for an error on our side. The result goes in
Receipt.extra.hcs, so a missing receipt shows up instead of disappearing.
On trust: a message on an open topic proves someone said a payment happened. The receipt is worth something because its transaction field can be checked against the ledger on the mirror node. The ingestion job reads the topic, not our own settle() results.
Gotcha: two transaction id formats
Hedera's SDK and its docs write a transaction id as 0.0.7162784@1788791855.758948636. The mirror node REST API wants 0.0.7162784-1788791855-758948636 in the URL path. Its own OpenAPI spec gives the example 0.0.10-1234567890-000000000.
export function toMirrorNodeTransactionId(transactionId: string): string {
return transactionId.replace('@', '-').replace(/\.(\d+)$/, '-$1');
}
From rails/hedera-x402/config.ts.
The @ form, raw or URL-encoded, returns 400 with the message Invalid Transaction id. Please use "shard.realm.num-sss-nnn" format. A well-formed id that doesn't exist returns 404 Not found. So the server tells the two cases apart. Where they collapsed into one was in our own code: receipt() does if (!res.ok) return null, so a bad format and a missing transaction both come back as null. The fix is the same either way: convert the id before building the URL.
HashScan links use the @ form, but HashScan answers 404 to curl on every path, so the repo relies on the mirror node link.
What the protocol leaves to the seller
Followed down to the transfer list, an agent paying for an API call is four lines on the mirror node. The facilitator pays 241,050 tinybars in fees, the buyer loses 84,367,844 tinybars, and the seller gains them. The buyer's key signed one transfer to one account and had no gas for anything else (how that key gets its limit). x402 and Blocky402 got the money there.
They left two jobs to the seller:
- Stopping replays: the same payment header sent twice bought two answers until our seller kept a list of spent transactions.
- Public receipts: a seller's own database could claim any volume it wanted until the receipts went to a topic anyone can read.
At the start of this article, paying for an API meant an account, a key, a card, and trusting the provider's billing dashboard. Turnstile replaces that with x402 plus those two checks, and that's what a marketplace of data agents needs: an agent the seller has never met pays seven cents, gets exactly one answer, and leaves a receipt anyone can check against the ledger.
Sources
- x402.org: x402 is an open standard for internet-native payments built on HTTP 402, stewarded by the x402 Foundation.
- x402 v2 HTTP transport spec: the
PAYMENT-REQUIRED,PAYMENT-SIGNATUREandPAYMENT-RESPONSEheaders as base64-encoded JSON. - x402 v2 core specification: facilitator
/verify(read-only) and/settle, and the defaultauthorizationflow (verify, resource, settle). - x402
exactscheme on Hedera:extra.feePayer, the partially signedTransferTransaction,0.0.0for HBAR, tinybar amounts, facilitator verification rules and replay checks. - x402
exactscheme on EVM: EIP-3009 is the default asset transfer method, Permit2 the fallback. - coinbase/x402 on GitHub: the original home of the reference implementation (the same project is published under
x402-foundation/x402). @x402/hedera2.25.0 registry manifest: exact pins on@hiero-ledger/sdk2.85.0 and@hiero-ledger/proto2.31.0, and the lockstep note.- MDN: 402 Payment Required: nonstandard, reserved for payment systems, no standard use convention.
- EIP-712: Typed structured data hashing and signing: the message format the payer signs for an EIP-3009 authorization.
- ERC-3009: Transfer With Authorization: signed authorizations that a third party submits, with validity windows and random nonces (status: Draft).
- Hedera docs: Transaction Properties: the transaction id is the paying account plus valid start time, and the paying account must always sign.
- Hedera docs: Glossary: definitions of HCS (verifiable timestamps and ordering), HTS, and mirror nodes.
- Hedera testnet mirror node OpenAPI: transaction id path parameter in the
0.0.10-1234567890-000000000format. - Mirror node record for the transaction:
SUCCESS, fee charged to0.0.7162784, exact buyer debit and seller credit. - Mirror node messages for topic 0.0.10408013: the settlement receipt at sequence number 5, readable with no key.
- Blocky402: open-source (MIT) x402 facilitator by BlockyDevs, and the networks it covers.
- Blocky402 testnet
/supported:exactonhedera:testnetwith fee payer0.0.7162784, no authentication. - hedera-dev/x402-inference-pay-per-request-poc: Hedera's own x402 proof of concept, using
api.blocky402.comas its mainnet facilitator. - Turnstile repository: all code excerpts;
docs/payment-flow.mdanddocs/EVIDENCE.mdfor the run,CORRECTIONS.mdfor what was wrong,AI-USE.mdfor how it was built. - Turnstile live deployment: the hosted service.
Next in the series: Gasless nanopayments on Arc, and the nonce that stays at 0, part 2 of 9.
More articles


