AISKILLS402

Agent payments (x402)

"paymentPayload is invalid": one dash broke a payment

Our first real x402 purchase on mainnet failed with a schema error caused by one em dash and a bare atob call. The cause, the fix, and how we proved it free.

Georgi Kalchev4 min readnote
A chain of glowing tiles runs to a vault door; the tile holding a small dash is misaligned and the seal after it cracks

If an x402 facilitator answers with 'paymentPayload' is invalid and a list of schema names the payload must match, check how your server decodes the payment header before you blame the buyer. In our case the buyer was fine. Our server read the header with a bare atob, and a single em dash in the description of our own product was enough to make the facilitator throw away the whole payload. It was the reason our first real purchase on the Base main network failed on 30 September 2026. That purchase was a test between our own wallets: our buyer wallet buying from our own shop.

What travels in the header

The payment header that a buyer sends back is base64 of a JSON document, and the JSON was encoded as UTF-8 before the base64 step. A version 2 buyer echoes more than its signature: it returns the resource block it saw on our storefront, description included. Ours contained an em dash and an ellipsis, both outside ASCII.

Characters outside ASCII take several bytes in UTF-8. The em dash takes three: e2 80 94. That is where the trap sits. The browser and Workers function atob turns base64 into a "binary string" in which every byte becomes one character. It does not interpret anything as UTF-8. The dash therefore becomes three separate characters, and two of them, 0x80 and 0x94, are C1 control characters. We measured it with a one-line sample in Node 24: a short string holding a dash, encoded to base64 as UTF-8 bytes, came out of atob as seven code units, with the dash showing up as e2 80 94, while a strict UTF-8 decoder returned five characters with the dash as one.

Why it fails only later, and only there

Our own code did not crash. JSON.parse accepts control characters in that range when they sit inside a string, so the object looked healthy. The damage appeared when we forwarded the object to the facilitator: the text that came out of the parser was the mangled version, no longer the text the buyer sent. (The signature itself covers the transfer authorisation, not the description, so this is about what we forwarded, not about a broken signature.) Coinbase's facilitator rejected the entire payload with the schema error in the title, and it names no field, no character and no position. The message reads like a broken client. Nothing in it points at an encoding.

It also hides well. A service whose descriptions are plain ASCII never meets the problem, so a decoder with this flaw can run for a long time and pass every test that uses English product text. The same flaw sat in a second service of ours; it was unaffected only because its non-ASCII text lives in a part of the payload that the facilitator tolerates. We measured that with the free verification call described below.

The fix

Turn the base64 into bytes first, then decode those bytes as UTF-8, and make the decoder strict so invalid input becomes a refusal instead of silent damage. In our server that is the existing atob, a conversion to a byte array, and a TextDecoder created with fatal: true. The sending side already did the mirror image with a TextEncoder, and the same rule holds there: never call btoa on text.

const raw = atob(header);
const buf = new Uint8Array(raw.length).map((_, i) => raw.charCodeAt(i));
const json = new TextDecoder("utf-8", { fatal: true }).decode(buf);

How we proved it without paying

We did not need to spend money to see the defect. The facilitator offers a verification call that checks a payload without settling it. We built the same signed payment from a wallet that held nothing and sent it to that call twice, once decoded with atob and once decoded as UTF-8. The first was rejected with the schema error. The second passed the schema stage. The empty wallet was enough to reach the schema check. We deliberately leave every wallet address out of this post; the only address that belongs here is the public receiving one.

After the fix the same buyer code completed a real purchase. The transaction is 0x146f02c8c8bb883b35929d88f0d115b5150b9e6ec9b5fe063d24d452db394315, included in block 51993034, and we read it back from a chain RPC rather than from an explorer.

The test, and the mutation

The regression test builds a header the way the standard client does, with a description containing an em dash, an ellipsis, curly quotes and Cyrillic. Then it asserts three things: the description forwarded to the facilitator is identical to the one the buyer sent; no C1 control character appears anywhere in the forwarded object; and invalid UTF-8 in the header is a refusal with a reason, not an exception. A fourth case is a control: it checks that the test header really contains bytes at or above 0x80, because a test with ASCII-only input would pass against the broken decoder and prove nothing.

The mutation is the obvious one: put the bare atob result back into the parser and run the suite. The forwarding assertion and the control-character assertion are the ones that should turn red. Our notes record that a mutation was run for this fix, but they do not preserve its output, so read this paragraph as what the suite is built to do, not as a saved result.

What we did not measure

We saw this on one facilitator, one network and one product description. We did not test other facilitators, and another one might accept the mangled payload or reject it with a clearer message. We did not measure which other characters trigger it: the dash and the ellipsis are what we had, and Cyrillic is covered by the test but was not part of the failed live purchase. The claim that the tolerant part of the payload is exactly the one holding non-ASCII text in our second service rests on a single check, not a survey. And the saved record of our mutation run is missing, as said above, so the strength of the test is argued, not shown here.

Read this post as Markdown: /blog/payment-payload-invalid-dash.md · Atom feed.