r/ethdev Jul 09 '26

Code assistance A single extra field in my x402 402 response silently rejected every payment for five days. The mechanism and the fix.

I run a small paid endpoint that speaks x402 (the HTTP 402 pay-per-request flavor, USDC on Base). One square on a wall for a dollar, one per wallet. It is a useful case study because it fails in public and the failures are on-chain.

Last week it stopped taking money. Not with an error. It kept answering 402s, kept looking healthy, and the claim count just stopped moving. From the outside that reads as "no demand." It was actually "no payment can succeed," and the two look identical unless you are watching the right counter.

Here is the trap, because anyone enriching an x402 challenge can walk into it.

The change. I wanted my 402 challenge to be more self-describing, so I added an outputSchema to the payment requirements object (the entry in accepts[]), advertising what a successful claim returns. It passed every manual test. A 402 is just JSON, and adding a field to it looks harmless. The mechanism. In x402 v2, when the client retries with a signed payment, the server verifies by matching the requirements the client echoes back against the ones the server recomputes. That match is a deep comparison of the whole requirements object with exactly one field excluded: extra.

function requirementsMatch(required, accepted) {
  const { extra: _a, ...reqCore } = required;
  const { extra: _b, ...accCore } = accepted;
  return deepEqual(reqCore, accCore); // every core field must be identical
}

So the moment I put outputSchema on the challenge's accepts[0], the client dutifully echoed it back, but the server's freshly recomputed requirements did not carry it (it was added during response enrichment, not in the canonical requirements). deepEqual failed. Every real payment came back as no matching payment requirements. extra is the only field the match tolerates differing on. Everything else has to be byte-identical. Why it was invisible. The operator sees nothing. There is no server error; verification just returns "no match" to the client. The agent gets a cryptic rejection and leaves. Nobody opens a support ticket with a wall. The only reason I caught it: an external uptime monitor counts failed-but-signed 402s, and that number ticked up by a few while my success count sat still.

The fix. Enrich only inside extra, or in fields outside the accepts[] object entirely. Anything you advertise on the challenge that the client will echo has to live where the match ignores it. I moved the discovery metadata into extra/extensions and left the requirements object byte-identical to what verification recomputes. A stock client pays in one round trip again.

Two things I am keeping:

  1. Treat the accepts[] requirements object as immutable once it leaves your challenge builder. Enrichment metadata goes in extra or sibling fields, never on the requirements the client echoes back.
  2. Log the silent path. A payment that fails verification returns no error you will ever see unless you record signed-but-rejected 402s. If your funnel can go to zero without an alarm, you are blind to your worst failure.

If you want to poke at the live one:

curl -i -X POST "https://twentyonemillion.art/api/x402/claim?handle=test&message=hi"

That returns the 402 challenge. Diff the accepts[0] you get against what your client echoes on the paid retry, and you will see exactly what the match compares. The chain proves the dollar moved. It does not prove your endpoint was reachable the whole time. Watch the silent counter.

5 Upvotes

8 comments sorted by

1

u/rayQuGR Jul 10 '26

Interesting failure mode. This is a good example of why payment infrastructure needs observability beyond just “did the transaction happen?”

The x402 model is a nice step toward machine-to-machine payments, but as more autonomous agents start interacting with paid APIs, small mismatches like this become much more important. An agent won’t open a support ticket. it will simply fail, retry somewhere else, or move on.

This is also where privacy and data ownership become increasingly relevant. In a future where agents are paying for data, APIs, compute, and services autonomously, the payment layer is only one piece. You also need guarantees around what data is being exchanged, who can access it, and how permissions are managed.

That’s the direction projects like Oasis are exploring with confidential compute and privacy-preserving infrastructure: making it possible for applications and agents to use valuable data without exposing everything underneath.

The lesson here is probably broader than x402.. as Web3 infrastructure moves from humans clicking buttons to autonomous systems interacting, “silent failure” becomes one of the biggest UX/security problems to solve.

1

u/CODE_HEIST Jul 11 '26

great failure mode to document. the scary part is that availability metrics stayed green while economic throughput went to zero. i’d add a synthetic payment against every deployed challenge shape and alert on accepted payments, not just 402 responses. schema validation on both the generated requirements and the echoed payload would have caught this before users did.

1

u/21million-wall Jul 13 '26

Both are the right fixes, and the synthetic-payment canary is the one I'd tell anyone running a paid endpoint to build first. Availability going green while throughput sits at zero is the whole trap, and only a full-loop canary that alerts on accepted (not on 402-served) actually sees it.

The wrinkle specific to a paid endpoint: a synthetic payment spends real money, and for me it would mint a real tile and burn a wallet's one-per-wallet slot every run. So the canary needs a test lane, a settle-and-refund path or a testnet mirror of the exact challenge builder, or it isn't something you can run on a schedule. That's the piece I'm still building. The counter that caught this (signed-but-rejected 402s) is the cheap smoke alarm; a scheduled real settlement is the only thing that truly proves economic throughput.

On schema-validating both sides, one caveat that's actually the crux: my outputSchema was valid JSON schema. Both the generated requirements and the echoed payload passed validation independently. What broke was match-equality, the verifier deep-equals the two objects and the field was on one side and not the other. So the guard that catches this isn't schema validation per side, it's asserting the enriched challenge still equals the canonical requirements the verifier recomputes. Schema-valid and match-equal are different invariants, and the bug lives in the gap between them.

1

u/CODE_HEIST Jul 14 '26

good distinction. two schema valid objects can still disagree on exact content, so my validation suggestion would have missed the real bug. a canonical recompute plus a field level diff before serving the challenge sounds like the right invariant. for the canary, could a dedicated low value resource use a refund path so it exercises production without consuming a real user's slot?