Skip to content

A Paywall for AI Agents, Built on a Forgotten HTTP Code

Updated: at 09:37 AM

There is a line in the HTTP spec that has been sitting there, unused, since 1997. Status code 402 Payment Required. The spec literally says it is “reserved for future use.” For almost thirty years nobody had a good reason to wire it up, because the thing on the other end of an HTTP request was always a human, and humans pay with cards and checkout pages, not status codes.

That assumption just broke. The thing on the other end of the request is increasingly an AI agent, and an agent does not have a credit card or a billing relationship. It has a wallet. So I built the missing half: a paywall that an agent can walk up to, get told the price, pay on-chain, and walk through, all on its own, in a single retry loop.

A couple of weeks ago I wrote about keeping an agent inside hard limits, making a bad action impossible to sign rather than merely unlikely. That post took one thing for granted that it never actually explained: how an agent comes to hold and spend money at all. This is that missing half.

Table of contents

Open Table of contents

Why agents need their own payment rail

Picture an agent that needs a real-time token price, a web search, or a slice of premium market data. Today the only way it can pay for that API is for a human to have signed up in advance, pasted an API key into its environment, and attached a card to an account. The agent is not really transacting. A human transacted months ago and the agent is riding on that.

That does not scale to a world of millions of autonomous agents calling thousands of APIs they discover at runtime. What you actually want is for the agent to hit a URL it has never seen, be quoted a price machine-to-machine, and settle it instantly without a signup flow. Money becomes the authentication. If you paid the right amount to the right address, you are in. No API key, no OAuth, no account.

That is exactly what HTTP 402 is shaped for.

The handshake

The flow is almost embarrassingly simple, which is the point.

1. Agent:  GET /paid-data

2. Server: 402 Payment Required
           {
             "x402Version": 1,
             "accepts": [
               { "scheme": "exact", "network": "base",
                 "maxAmountRequired": "10000",   // raw units
                 "payTo": "0xMerchant...", "asset": "0xUSDC..." },
               { "scheme": "exact", "network": "supra-testnet",
                 "maxAmountRequired": "5000000", "payTo": "0x..." }
             ]
           }

3. Agent:  picks one option, pays on-chain → tx hash 0x123abc...

4. Agent:  GET /paid-data
           x-payment: base64({ invoiceId, txHash: "0x123abc...", network })

5. Server: verifies the tx on-chain
           recipient == merchant?  amount >= price?  not already spent?

6. Server: 200 OK + the data

The server quotes a price, optionally across several chains. The agent picks one, pays, and retries the same request with a proof header. The server checks the chain and lets it through. No invoice database is strictly required, no session, no token. The blockchain is the shared source of truth that lets two parties who have never met agree that payment happened.

Two pieces: the wallet and the gate

I ended up with two cooperating SDKs, because “let an agent hold money” and “let an API charge for access” are genuinely different problems.

The agent wallet (I built it on Supra, which gives sub-second finality) is the side that pays. Its whole job is to hold keys safely and enforce policy before it ever signs.

The tollgate is the side that charges. It is a neutral routing layer that sits in front of any API, speaks 402, and verifies payments across six chains (Ethereum, Polygon, Base, Arbitrum, BNB, and Solana).

The client side is deliberately dumb. Here is the core of the 402-aware fetch loop, lightly trimmed:

for (let attempt = 0; attempt <= maxRetries; attempt++) {
  const headers = { ...init.headers };

  // reuse a cached pass for this origin if we have one
  const pass = passCache.get(origin);
  if (pass && pass.expMs > Date.now()) headers[pass.header] = pass.token;

  // attach proof of the payment we just made
  if (lastPayment) headers["x-payment"] = lastPayment.proof;

  const res = await fetch(url, { ...init, headers });
  if (res.ok) return res;                       // through the gate

  if (res.status === 402) {
    const invoice = parseInvoice(res);
    if (!paymentClient.checkPolicy(invoice)) throw new Error("POLICY_REJECTED");
    const option  = paymentClient.selectOption(invoice);   // which chain/token
    const receipt = await paymentClient.pay(option);       // sign + submit on-chain
    lastPayment = { proof: base64({ invoiceId: invoice.id,
                                    txHash: receipt.txHash,
                                    network: option.network }) };
    continue;                                   // retry with proof
  }
  throw new Error(`${res.status}`);
}

See 402, check policy, pay, retry. That is the entire client. All the intelligence lives in checkPolicy and pay, which is where the next decision matters most.

Decision 1: never give an agent an extractable key

Handing an autonomous agent a raw private key is the kind of thing that ends up in a post-mortem. If the key is a hex string sitting in memory or a file, a prompt injection, a leaked log, or a poisoned dependency can walk away with it and your money.

So the agent’s private key is never a string. It is generated with WebCrypto as a non-extractable Ed25519 CryptoKey:

const keyPair = await crypto.subtle.generateKey("Ed25519", /* extractable */ false, ["sign"]);
// keyPair.privateKey can sign, but it cannot be serialized, logged, or exfiltrated.

The key can sign a transaction and nothing else. It cannot be read out, printed, or shipped off-box. The wallet file on disk only ever contains the address and the public key. Combined with a session that auto-expires (expiresIn: '24h'), a compromised agent wallet is a bounded, short-lived problem instead of a catastrophe.

Decision 2: the policy engine is the real product

A wallet that pays whatever it is asked is a footgun. The interesting part is the layer that decides whether to pay, before a signature is ever produced:

new StarkeyAgent({
  budget:   { USDC: 50 },          // hard ceiling on total spend
  perCall:  { USDC: 0.50 },        // max for any single payment
  expiresIn: "24h",                // wallet self-revokes
  allowedDomains:   ["supra.com"], // who it may pay
  requireApprovalAbove: { USDC: 10 },
  rateLimit: { calls: 100, windowMs: 60_000 },
  onPayment: (receipt) => log(receipt),
});

Every 402 runs this gauntlet first: is this domain allowed, is the per-call amount under the cap, would this blow the total budget, does it need human approval, are we over the rate limit? Only if all of that passes does the non-extractable key get asked to sign. This is what lets you hand an agent real money and still sleep. The money is fenced, not trusted.

Decision 3: verification with no database

The naive way to build the server is to store every invoice you issue and look it up when proof arrives. That works, but it means state, and state means you cannot run it on a stateless edge function. So the tollgate also has a stateless mode: it advertises a fixed price and a set of chains, and verifies a payment purely from the chain plus a time window.

The verifier is just JSON-RPC, no heavyweight libraries:

async function verifyEvmTx({ rpcUrl, txHash, expectedTo, expectedRawAmount }) {
  const [tx, receipt] = await Promise.all([
    jsonRpcCall(rpcUrl, "eth_getTransactionByHash", [txHash]),
    jsonRpcCall(rpcUrl, "eth_getTransactionReceipt", [txHash]),
  ]);
  if (!receipt || receipt.blockNumber === null) throw new Error("not_confirmed");
  if (norm(tx.to) !== norm(expectedTo))         throw new Error("recipient_mismatch");
  if (BigInt(tx.value) < expectedRawAmount)     throw new Error("amount_mismatch");
  // ERC-20? decode the Transfer event from receipt.logs instead of tx.value
}

The rule for accepting a payment becomes: the transaction is confirmed, it went to the right address, for at least the right amount, the block timestamp is inside the acceptance window, and we have not seen that transaction hash before. No invoice store. The same EVM logic works unchanged across Ethereum, Polygon, Base, Arbitrum, and BNB, because EVM is EVM. Solana gets its own verifier, but the shape is identical.

The whole core ships with zero hard dependencies. ethers and @solana/web3.js are optional peers you only pull in for the chains you actually use. That keeps the install tiny, dodges supply-chain risk, and means it runs anywhere: Node, Deno, Bun, Cloudflare Workers, a browser.

Decision 4: one payment, a thousand requests

On-chain settlement is great until you have an agent that calls the same API five hundred times an hour. You do not want five hundred transactions and five hundred gas fees. So there is a third option: time-period passes.

The agent pays once. The server hands back an HMAC-signed bearer token:

pass = base64url(payload) + "." + base64url(HMAC_SHA256(secret, payload))
// payload = { payer, expiry: now + 24h, jti: <unique> }

After that, the agent just attaches x-payment-pass: <token> and every request is validated by a local HMAC check. Zero RPC calls, zero gas, still safe to run on an edge function because verifying an HMAC is free. It is a bearer token, like an API key, except the merchant signed it and it expires on its own. One on-chain payment funds a whole day of chatty calls.

The detail that bites everyone: decimals

Micropayments live and die on exact arithmetic, and JavaScript floats will betray you:

0.05 * 1e8  // => 5000000.000000001   ← that drift is a bug in a payment system

So amounts are converted from human units to raw integer units by parsing the string, never by multiplying floats:

function humanSupraToRaw(amount) {           // 0.05 SUPRA -> 5000000n (8 decimals)
  const [intPart, frac = ""] = String(amount).trim().split(".");
  const frac8 = (frac + "00000000").slice(0, 8);
  return BigInt(intPart || "0") * 100_000_000n + BigInt(frac8.padEnd(8, "0"));
}

Everything downstream compares BigInt to BigInt. A single unit of drift would mean either rejecting a valid payment or accepting a short one, and both are unacceptable.

Guarding the gate

A payment endpoint is an attractive target, so the reference server is paranoid by default:

None of these are clever. All of them are necessary the moment real money is involved.

The wedge: every agent framework, for free

Here is the part that makes this more than a demo. The same gate plugs straight into the frameworks agents already run on. Exposed as a Model Context Protocol tool, a paid API looks like any other tool to Claude:

const server = paidMcpServer({
  client: new TollgateClient({ wallets: { evm: { privateKey: process.env.AGENT_KEY } } }),
  budget: { totalUsd: 5.00 },
  tools: [
    { name: "get_token_price", endpoint: "https://api.example.com/price/{ticker}",
      priceUsd: 0.01, params: { ticker: { type: "string", required: true } } },
  ],
});
await server.start();

When Claude calls get_token_price("BTC"), the MCP server fetches the endpoint, hits a 402, pays from the agent’s budget, gets the data, and returns it. Claude never knows it paid. There are equivalent one-liners for LangChain and the Vercel AI SDK. Any agent, in any of the major frameworks, can now call a paid API with no billing integration, no signup, and no human in the loop.

The SDK is the wedge. The neutral registry of paid endpoints is the moat. This is not about winning a blockchain war; it is about being the toll road that every agent drives on.

What I took away from it

  1. Old standards age into new uses. HTTP 402 was useless for thirty years because the payer was always human. The moment the payer became a program, a reserved status code turned into exactly the right primitive.
  2. Fence the money, do not trust the model. The safety of an agent that holds funds comes from non-extractable keys and a policy engine that runs before any signature, not from hoping the model behaves.
  3. Statelessness is a feature, not a compromise. Verifying payments from the chain plus a time window means the gate runs on a free edge function. Adding HMAC passes on top means chatty agents do not pay per call.
  4. In payments, the boring details are the product. Decimal-safe math, replay guards, and in-flight locks are not glamorous, but they are the difference between a demo and something you would point real money at.

All of this assumes something I have not described yet: the system that actually runs these agents, decides what each one is allowed to touch, and drives the loop that ends in a payment. That harness is the thing underneath everything, and it is what I want to write up next. More soon.