HTTP 402 has been "reserved for future use" since 1997. The future turned out to be AI agents that hold a wallet, and the spec that woke it up is x402: a payment challenge carried in the response body, settled in stablecoin, with no account, no API key and no signup on either side.
I shipped it on a real endpoint. You can hit it right now — the challenge is public, the paid call costs one cent, and the data behind it is a free CC BY 4.0 dataset. Everything below is a copy-pasteable curl against a live service.
The 30-second version
curl -s "https://piratefly.com/v1/x402/analisi?origine=Stockholm&destinazione=Rome"
You get an HTTP 402 Payment Required with a machine-readable body:
{
"x402Version": 1,
"error": "payment required",
"accepts": [{
"scheme": "exact",
"network": "base",
"maxAmountRequired": "10000",
"resource": "https://piratefly.com/v1/x402/analisi?origine=Stockholm&destinazione=Rome",
"description": "Booking-timing verdict for one flight route: cheap/normal/expensive vs that route's own price history, best months to fly, how far ahead to book.",
"mimeType": "application/json",
"payTo": "0xAC13DFB06F5c2Db996C881367E19387473Cf6480",
"maxTimeoutSeconds": 60,
"asset": "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
}]
}
maxAmountRequired: "10000" is 10,000 units of USDC on Base — six decimals, so $0.01. No key to request, no plan to pick, no email. An agent reads accepts, signs an EIP-712 authorization for exactly that amount, retries with an X-PAYMENT header, and gets the JSON.
That is the whole protocol. It fits in a paragraph because it has to: the client is a program that has never seen your docs.
Why 402 and not an API key
Machine buyers break the assumptions API keys are built on.
An API key is a relationship: someone signs up, agrees to terms, puts a card on file, and the key is the durable token of that relationship. That model assumes the buyer will come back, and that the cost of onboarding is amortized over many calls.
An agent buying one answer, once, has none of that. It found your endpoint 40 milliseconds ago, it wants one route analysed, and it will likely never call you again. Onboarding costs more than the sale. Under an API-key model the transaction simply does not happen — not because the buyer won't pay, but because there is no way to pay that fits inside one request.
402 collapses the relationship into the request itself. The price is in the response. The authorization is in the retry. There is no account to create, so there is nothing to abandon.
The other half matters just as much for a small operator: no chargebacks and no merchant identity. Settlement is a stablecoin transfer on Base, verified by a facilitator, final when it lands. You do not need a company, a KYC file, or a payment processor's approval to accept a cent. For anyone shipping from outside the usual jurisdictions, that difference is the difference between having a business model and not having one.
Implementing it: the part that actually bites
The protocol is small. Most of the implementation is about 40 lines of Hono. Here is the shape:
const RESOURCE_PATH = '/v1/x402/analisi';
app.get(RESOURCE_PATH, async (c) => {
const origine = c.req.query('origine');
const destinazione = c.req.query('destinazione');
if (!origine || !destinazione) {
return c.json({ errore: 'required params: origine, destinazione' }, 400);
}
const resourceUrl =
`${baseUrl}${RESOURCE_PATH}?origine=${encodeURIComponent(origine)}` +
`&destinazione=${encodeURIComponent(destinazione)}`;
const paymentRequirements = requisiti(wallet.address, resourceUrl);
const header = c.req.header('x-payment');
if (!header) {
return c.json({ x402Version: 1, error: 'payment required', accepts: [paymentRequirements] }, 402);
}
const payload = JSON.parse(Buffer.from(header, 'base64').toString('utf8'));
const verify = await facilitator('verify', payload, paymentRequirements);
if (!verify.isValid) {
return c.json({ x402Version: 1, error: verify.invalidReason, accepts: [paymentRequirements] }, 402);
}
const body = await analizza(origine, destinazione); // the actual work
const settle = await facilitator('settle', payload, paymentRequirements);
if (!settle.success) {
return c.json({ x402Version: 1, error: settle.errorReason, accepts: [paymentRequirements] }, 402);
}
return c.json(body);
});
Two things in there cost me real debugging time, and neither is in the spec's happy path.
1. The resource URL must be the URL the buyer actually typed
paymentRequirements.resource is signed over. A strict client compares it against the URL it requested, and rejects the challenge if they differ. That sounds trivial until your service runs behind a reverse proxy.
My API sits behind Traefik with a path-strip rule. Inside the container, Host and X-Forwarded-Host describe the internal origin, not the public one buyers reach. Building resource from request headers produced a URL that was correct from the server's point of view and wrong from every buyer's — and it failed silently, as a payment the client declined to attempt.
So the public base URL is passed in explicitly at mount time, never inferred:
montaX402(app, sorgente, { baseUrl: 'https://piratefly.com' });
If you take one thing from this post: do not derive a signed value from a header a proxy is allowed to rewrite.
2. Verify before you work, settle after
verify is cheap and tells you the authorization is valid. settle is the on-chain move and can fail for reasons that have nothing to do with the buyer's intent. Put the work between them and you get the ordering you want: you never compute for a buyer who can't pay, and you never charge a buyer whose answer you failed to produce. If settle fails after the work is done, you eat the compute — which, for a database read, is the right side to lose on.
What the endpoint actually sells
A price is only honest if there's something behind it. The paid call returns a booking-timing verdict for one route, computed against that route's own observed price history: is today's fare cheap, normal or expensive; which months are actually cheaper; how far ahead to book.
The history is real and public. As of this writing:
| price observations | 728,194 |
| routes tracked | 48,731 |
| routes published (≥5 observations) | 24,283 |
| route-months published | 37,688 |
| observation window | 2026-06-14 → 2026-09-20 |
| median fare | €114 |
It is mirrored daily to two places, CC BY 4.0, no account:
-
Hugging Face —
DeusHorizon/flight-fare-history -
GitHub —
DeusAcc/flight-fare-history
import pandas as pd
url = "https://huggingface.co/datasets/DeusHorizon/flight-fare-history/resolve/main/routes.csv"
df = pd.read_csv(url)
print(df.nlargest(5, "observations")[["origin_city", "destination_city", "median_eur", "min_eur"]])
Publishing the data the paid endpoint reads from sounds like giving away the product. It isn't. The CSV is the history; the endpoint is the verdict — current fare percentile against that history, computed on a daily-refreshed table you'd otherwise have to rebuild yourself. Anyone willing to do the work can do it from the free file. Charging a cent is the price of not doing the work.
Free JSON is also available without any payment at GET /v1/analisi (same shape, OpenAPI at /v1/openapi.json), and there's a remote MCP server at https://piratefly.com/mcp if you'd rather let a model call it as a tool.
Is anyone actually paying?
Honest answer: the endpoint is live, registered in the public x402 resource directories, and has not yet earned a cent. I'm writing this at the stage where the mechanism works and the demand is unproven — which is, I think, the only interesting time to write about it, before the survivorship bias sets in.
What I can say with confidence is that the failure mode changed. Before, the reason an agent couldn't buy from me was structural: no signup flow a program can complete, no way to price one call. Now the reason would be that nobody wants the answer. That is a much better problem, and a measurable one.
If you're building something an agent might want to buy — a lookup, a verdict, a computation over data you maintain — the implementation cost is genuinely about an afternoon. The spec is at x402.org, the facilitator does the chain work, and your endpoint changes by one branch at the top.
Hit the 402 yourself:
curl -s "https://piratefly.com/v1/x402/analisi?origine=Stockholm&destinazione=Rome" | jq .accepts[0]
The service this runs on is PirateFly — flight fare history, free API, and the 402 endpoint above.
Happy to answer implementation questions in the comments — especially from anyone who has taken a 402 endpoint further than I have.
Top comments (0)