← Blog
October 18, 2026 · 16 min

The price is in a header: can a browser pay an x402 seller?

We asked 2,233 x402 sellers for a price the way a web page would. 568 of them would let a correct browser client read the price and send the payment. The client the x402 SDK ships today gets through on 120. The difference comes from one header the client adds to its own retry.

x402 v2 puts the whole protocol in HTTP headers. The price travels in PAYMENT-REQUIRED, the payment in PAYMENT-SIGNATURE, the settlement result in PAYMENT-RESPONSE. The transport spec says it plainly: "All x402 protocol information is communicated through headers."

Most of the time that is a clean design. A server-side agent that calls a paid API with Node or Python never notices. A browser does notice. Browsers decide which headers a page may read and which it may send. Most HTTP clients also cap how large a header can be. And HTTP has methods, such as HEAD and OPTIONS, that a payment middleware has to handle on purpose.

Between October 7 and 15, a group of issues and pull requests in the x402 repository mapped those edges. This post reads them, reproduces each problem against the released SDK, and measures how often they show up across the Bazaar.

What we read and ran, all on 2026-10-18. The x402 repository at commit faa1c69: issues #3722, #3803, #3808, #3810 and #3657, the merged fix #3796, and the open PRs #3478, #3738, #3741, #3774, #3794, #3805, #3809 and #3813. The @x402/* packages at 2.28.0, the current npm release.

We ran four probes. The first is a local harness: Express 4.22.3 with @x402/express 2.28.0 behind four CORS setups, a mocked facilitator, and a page on a second origin driven by headless Chrome 144. The second is a header-size search for the largest PAYMENT-REQUIRED value that Node 22 fetch, aiohttp, requests, httpx and Chrome will accept. The third is a census of the CDP Bazaar: 42,058 resources across 2,447 hosts, with three unpaid requests per host (the advertised request, a CORS preflight and a HEAD), plus a second preflight pass. The fourth runs the same probes against our own gateway.

The short version. No x402 v2 server middleware in TypeScript, Python or Go sets a CORS header, and the spec never mentions browsers. Of the 875 Bazaar sellers whose 402 passes the browser's origin check, 240 hide the price from the page. The shipped @x402/fetch adds Access-Control-Expose-Headers to its paid retry. That is a response header, so sending it on a request forces a preflight that 448 of 568 otherwise payable sellers refuse. 126 sellers answer the preflight itself with a 402. In the released SDK, an unpaid HEAD runs a paid GET handler. The fix is merged but has not shipped. And 188 sellers send a price header that aiohttp cannot read at all. Our own gateway passes the preflight and then hides its price from the page too.

Three headers, two directions

The Fetch standard (last updated 6 October 2026) sets the rules. A cross-origin page can read only seven response headers by default: Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified and Pragma. Any other header is hidden unless the response names it in Access-Control-Expose-Headers.

So PAYMENT-REQUIRED and PAYMENT-RESPONSE are invisible to a page by default. The response still arrives. fetch() resolves with status 402. headers.get("PAYMENT-REQUIRED") returns null.

The request side has its own list. PAYMENT-SIGNATURE is not on it, and neither is Content-Type: application/json. Only the form and plain-text MIME types are safelisted. Before a page can send either header cross-origin, the browser sends an OPTIONS preflight. The preflight must come back with an ok status, which the standard defines as 200 to 299. It must also name each requested header in Access-Control-Allow-Headers.

Both lists accept *, but only on requests without credentials. On a request with credentials, such as cookies, * means a header literally named *. And Authorization is never covered by *.

x402's HTTP transport spec is 192 lines long. At faa1c69, a case-insensitive search for cors, cross-origin, browser or Access-Control finds nothing. Issue #3808 points this out. PR #3809 proposes a "Browser clients" subsection that covers both directions in one paragraph. The reasoning is that a missing expose list and a missing allow list look the same to the client. Both show up as a parse failure on an apparently empty challenge.

Four setups in a real browser

We started with the server side, since that is where issue #3722 starts. In the 2.28.0 packages, @x402/express and @x402/core do not contain the string Access-Control anywhere in their built code. The middleware writes PAYMENT-REQUIRED on the 402 and PAYMENT-RESPONSE on the settled response. It writes no CORS header of any kind. The issue reports the same for Hono, Next, Fastify, FastAPI, Flask, Gin, Echo and net/http.

We then built four Express servers on 127.0.0.1, each with the same paid GET /paid-get route at $0.01 on Base Sepolia. Only their CORS layer differed. A page on http://localhost:4100 called each one twice: once with plain fetch() to read the price, and once with @x402/fetch 2.28.0 and a throwaway key, against a mocked facilitator. No real payment was made.

// Chrome 144, page on http://localhost:4100, @x402/express 2.28.0
setup                         plain fetch           @x402/fetch 2.28.0
1 x402 only                   Failed to fetch       Failed to fetch
2 cors() defaults             402, price = null     "Failed to parse payment
                                                     requirements"
3 minimal CORS (#3808)        402, price readable   Failed to fetch
4 setup 3 + allow             402, price readable   200, paid,
  Access-Control-Expose-Headers                     PAYMENT-RESPONSE readable

Row one has no CORS at all. The browser blocks the response, so the page sees a network error.

Row two is the cors package with no options. Its defaults send Access-Control-Allow-Origin: * and expose nothing, as its README says: "If not specified, no custom headers are exposed." The page gets a 402 it can see but cannot price. The only visible headers were cache-control, content-length and content-type. @x402/fetch then reports "Invalid payment required response", which points nowhere near CORS.

Row three is the minimal configuration that #3808 recommends. It exposes the two payment headers and allows Content-Type and PAYMENT-SIGNATURE. The price is now readable. But the paid retry still fails, and this time the cause is the client.

The client adds a header the server must allow

The 2.28.0 build of @x402/fetch sets this on the retry that carries the payment, both on the first retry and on the recovery path:

clonedRequest.headers.set(
  "Access-Control-Expose-Headers",
  "PAYMENT-RESPONSE,X-PAYMENT-RESPONSE"
);

Access-Control-Expose-Headers is a response header. On a request it means nothing to the server. But it is not a safelisted request header, so the browser has to ask permission to send it. Our server logged the preflight exactly as Chrome sent it:

OPTIONS /paid-get
Access-Control-Request-Headers: access-control-expose-headers,payment-signature

The minimal server allows payment-signature but not the other name, so Chrome refuses to send the paid request. Add Access-Control-Expose-Headers to the allow list (row four) and the same client pays, gets a 200, and can read PAYMENT-RESPONSE.

This bug is not new. PR #724 proposed removing the same line from the v1 clients in December 2025. It was closed unmerged once the legacy packages were frozen. PR #3478 removes it from the v2 Fetch and Axios wrappers. It has been open since September 15. PR #3717 does the same for the Python client, where the header is inert because no browser is involved.

On the server side, PR #3774 (TypeScript) and PR #3794 (Python and Go) make every response that carries a payment header also expose both payment headers. Both merge with any expose list already present instead of replacing it, and neither touches Access-Control-Allow-Origin. #3794 also records a Flask trap: the payment middleware replaces app.wsgi_app, so the 402 goes out before flask-cors runs and carries no CORS headers at all.

The preflight that gets a 402

A route can be keyed with a method ("GET /paid") or without one ("/paid"). A route without a method compiles to the verb *. The 2.28.0 matcher is one line:

(route) => route.regex.test(candidate) && (route.verb === "*" || route.verb === upperMethod)

* matches OPTIONS too, so the payment middleware answers the browser's preflight with a 402. A 402 is not an ok status, so the preflight fails and the paid request is never sent. On our harness with no CORS layer, OPTIONS /paid-any returned 402. With a CORS middleware registered first, it returned 204, because the CORS layer ends the preflight before x402 sees it. The route keyed as GET let OPTIONS through to the app.

PR #3738 narrows the implicit wildcard in all three cores so that it no longer matches OPTIONS. A route explicitly keyed as "OPTIONS /path" still requires payment. The legacy v1 packages keep the old rule, because they are frozen.

HEAD: free first, then paid for nothing

RFC 9110 says HEAD "is identical to GET except that the server MUST NOT send content in the response." Express sends HEAD to the GET handler and drops the body.

In 2.28.0, a HEAD request does not match a route keyed as "GET /paid". The middleware lets it through, and the paid handler runs. On our harness, an unpaid HEAD /paid-get returned 200 OK, and the handler's call counter went from 0 to 1. The body was dropped, as HTTP requires. But the work behind it ran, and any header the handler sets still goes out. If the handler calls a paid upstream, such as a model, the seller pays for that call and the caller pays nothing.

PR #3796 fixes this in TypeScript, Python, Go and the legacy package. It was opened and merged by the same maintainer on October 13, 27 minutes apart. Its changeset reads: "Require payment for HEAD requests when the matching route is configured for GET." None of the three SDKs has released since September 29. Today the latest versions are @x402/core 2.28.0, x402 2.25.0 on PyPI and Go v2.28.0, so every new install of the released packages still has the bypass.

The fix opens a second problem. Issue #3810 notes that once HEAD matches a paid route, nothing stops a HEAD that carries a payment from settling. The buyer is charged, and the response has no body by definition. This can happen in practice: per issue #3657, the reference Python client retries the prepared request with the payment attached. A caller that discovered the price with HEAD then pays with HEAD. PR #3813 keeps the challenge on HEAD but refuses a paid HEAD before verification, with an error that names GET. PR #3741 goes one step further. It adds an optional method to ResourceInfo, inside the challenge every client already parses, so a seller can say which method actually pays.

How big can a price be?

Nothing in the spec bounds the size of PAYMENT-REQUIRED. It carries every accepts entry and every extension, including the Bazaar's input and output examples and their JSON schemas. Clients do have limits. We searched for the largest value each one would accept, on a response with only a few other small headers:

client                 largest PAYMENT-REQUIRED value   first failure
Node 22.22.2 fetch     16,170 bytes                     UND_ERR_HEADERS_OVERFLOW
aiohttp 3.11.13         8,190 bytes                     400 "Got more than 8190 bytes"
requests 2.34.2        65,516 bytes                     ConnectionError
httpx 0.28.1          148,188 bytes                     RemoteProtocolError
Chrome 144            261,866 bytes                     TypeError: Failed to fetch

Node's figure is the whole header block. Its documented default is 16 KiB, raised from 8 KiB in v13.13.0. Our response's other headers took the rest. aiohttp's limit is per header: max_field_size=8190 by default. In other words, the browser is not the tight constraint. The usual server-side agent runtimes are.

Issue #3803 found live sellers past both limits on October 14. In one case, the Bazaar extension was about 16 KB and accepts was 0.25 KB. PR #3805 makes the TypeScript server warn once per route above 8,190 bytes, and again above 16 KB. The PR also adds the 8,190-byte budget to the spec text. The header itself stays unchanged.

The Bazaar from a browser's point of view

We paged the full CDP discovery catalog on October 18: 42,058 resources on 2,447 hosts. For each host we took its most-called resource without a templated path, which left 2,373 hosts. Each got three unpaid requests from Origin: https://llm4agents.com. First came the advertised method with the Bazaar example input. Then came the preflight that @x402/fetch 2.28.0 would trigger, and, for GET resources, a HEAD. A second pass sent the preflight a correct client would send, without the extra header. We count * as valid in each list, because a default fetch() to another origin sends no credentials.

2,285 hosts answered 402, and 2,233 of those carried a v2 PAYMENT-REQUIRED header. Those 2,233 are the base for everything below.

// Census, 2026-10-18, 2,233 v2 sellers

From visible to payable

No CORS at all: 1,353 (60.6%). A page cannot see the response.

Origin allowed: 875 (39.2%), of which 819 use * and 56 reflect the origin. Another 5 hard-code a different origin.

Price readable: 635 (28.4%). The other 240 that allow the origin hide PAYMENT-REQUIRED: 213 send no expose list at all, and 13 expose the v1 X-PAYMENT-RESPONSE but not the v2 header.

Payable by a correct browser client: 568 (25.4%). Among readable sellers, 39 allow the preflight but not PAYMENT-SIGNATURE; 32 of those allow v1's X-PAYMENT instead.

Payable by @x402/fetch 2.28.0: 120 (5.4%).

The step from 568 to 120 is the client bug. 448 sellers that a correct client can pay would still refuse the shipped wrapper. The most common allow list among them, on 144 hosts, is the textbook one: Content-Type, PAYMENT-SIGNATURE and X-PAYMENT, in some order and case. Six of the 448 do not even answer the preflight with an ok status once the extra header is requested.

The other edges show up too. 126 sellers answer the preflight with 402. On the 1,118 sellers that advertise GET, an unpaid HEAD got a 402 on 584 and a 405 on 220. It got a 2xx on 242 (21.6%), which means the payment gate does not cover HEAD there. From the outside we cannot tell whether the paid handler ran. Header sizes: the median PAYMENT-REQUIRED value is 3,256 bytes, the 99th percentile is 11,996, and the largest is 22,492. 188 sellers (8.4%) are over aiohttp's 8,190 bytes. Eight of eight sampled failed live with aiohttp. Two sellers are over Node's 16 KiB, and both failed live with Node fetch.

These numbers are a snapshot, one resource per host, from one origin. Issue #3722's scan used different tooling and found the same shape: of 1,359 endpoints with a readable 402, 549 sent Access-Control-Allow-Origin and 192 of those did not expose the price. We could not test the paid response itself without paying. The figures assume a seller sends the same CORS headers on its 200 as on its 402.

CORS is not a spend control

One part of #3808 matters beyond the bug list. A seller may choose not to serve browser origins, and that is a fair choice. But CORS governs whether a page may read a response. It does not govern whether anyone spends. An exact payment is an EIP-3009 authorization signed by the payer's key. A page that can read a challenge still cannot sign. Whatever holds the key decides.

Refusing browser origins protects the seller from receiving a payment it did not expect. It does not protect the payer, because the same auto-signing client will pay any seller that does allow the origin. The real control lives in the signer's policy. That is the layer we audited in the $1 default, and it stays the right place for limits once agents run inside pages, as with WebMCP.

What it means for LLM4Agents

We ran the same probes on our gateway. The preflight to POST /v1/chat/completions returns 204 with Access-Control-Allow-Origin: *. It reflects whatever headers the browser asks for, with Vary: Access-Control-Request-Headers. That means even the 2.28.0 client's extra header passes. Our challenge is small: the PAYMENT-REQUIRED value is 552 bytes, far below every client limit in the table.

Then the 402 comes back with Access-Control-Allow-Origin: * and no Access-Control-Expose-Headers. A cross-origin page sees the 402 but cannot read our price. Nothing in our code sets an expose list, so after a payment the page could not read PAYMENT-RESPONSE either. Our Hono app registers cors() with its defaults, and Hono's documented default for exposeHeaders is an empty list. In the census's terms, we are one of the 240.

The impact is narrow, and it is a compatibility gap, not a payment gap. Agents in Node, Python or Go never see CORS, and the Bearer path is unaffected. Our paid routes are POST, so neither HEAD problem reaches them. What it blocks is a client that per-call stablecoin payment suits well: an agent running inside a browser tab or an extension, paying from a page with its own wallet. That is the client we wrote about in the buyer stack. About one Bazaar seller in four can be paid from such a client when it is built correctly, and about one in twenty with the client the SDK ships.

The census also says something about the seller stack we described in how a route becomes a paid endpoint. A curl test against that pipeline looks perfect. None of these failures shows up in curl. They show up only in a real browser, a strict HTTP client, or a method nobody planned for.

Staying on the frontier

The order below goes from cheapest and most certain to most strategic.

  1. Expose the payment headers. Change our cors() call to expose PAYMENT-REQUIRED, PAYMENT-RESPONSE and X-Request-Id on every response. It is one option on one middleware. Keep reflecting the requested headers until #3478 ships, because an explicit allow list would lock out the current client.
  2. Test from a browser, not from curl. Add the harness from this post to CI: a page on a second origin, headless Chrome, and both a plain read and a full @x402/fetch payment against a mocked facilitator. It would have caught each of the first three setups above.
  3. Set a header budget. Fail the build if our PAYMENT-REQUIRED value goes over 8,190 bytes. We are at 552 today. Bazaar metadata, signed offers or extra networks are what would push it up.
  4. Be explicit about methods. Keep paid routes method-keyed and POST-only. Refuse a payment on HEAD or OPTIONS with a clear error. Adopt resource.method from #3741 if it merges.
  5. Upgrade when the release lands, and check it. The next @x402/* release should carry #3796. Before trusting a changelog, unpack the published package and grep for the fix. Then rerun this census to see whether sellers follow.
  6. Ship a browser example with a spend cap. Publish a short page that pays our gateway from a browser wallet with a per-call and per-session limit in the signer. CORS lets the page see the price. Only the signer decides whether to pay it.

Pay per call, in stablecoins, over an OpenAI-compatible API

One 402, one signature, 345+ models. No subscription, no minimum.

Register your agent