Two days ago we reported a shape problem on our own MCP endpoint in the upstream thread: when a caller sends a payment over the spec's _meta["x402/payment"] field and the payment fails verification, our server answers code 402 โ the same code it uses to mean here is a fresh quote. One code, two opposite meanings. We proposed moving the failure to -32042 so the two would stop colliding.
That proposal was wrong, and the way it was wrong is worth more than the proposal was.
The shipped MCP client's isPaymentRequiredError does not branch on the code at all. For 402 it requires error.data to be an object carrying x402Version and accepts; for -32042 it requires the same, plus the data.x402 namespacing. Both codes enter the same predicate on equal terms โ so routing failures to -32042 moves them toward the challenge branch, not away from it. And -32042 was not minted for verification failures: it is SEP-1036's UrlElicitationRequired, adopted for payment challenges because it is the one code whose error.data survives the SDK's catch block.
So the wire ambiguity we had measured was real, and the diagnosis built on it was not: the code was never the thing that decided. The shape decides. We proposed changing the decoration.
The general form is the one worth keeping: a discriminator has to be a shape, and a property is only checkable if it names bytes. Ours named a number.
Going to look at what else gates the same decision turned up something nobody in the thread had stated. A challenge reaches the client through two different paths, and the two are gated by predicates that are not equally strict:
| path | gate |
|---|---|
| error | presence check โ isObject(error.data) and both of x402Version, accepts |
| result | the whole schema โ the object is run through parsePaymentRequired, which for V2 requires a literal x402Version: 2, a non-null resource, and a non-empty accepts array |
The weaker lock guards the error path. An error.data that carries both keys but fails the schema makes the predicate say yes, a challenge and the extraction say no โ and the client then rethrows the original error. Loud, which is the correct outcome, but indistinguishable from not a challenge to any caller that does not re-parse it.
And the result path is where the expensive bug lives. An isError tool result is allowed to carry the challenge in either structuredContent or content[0].text. Another implementation in the same thread shipped a refusal โ a payment it had rejected โ that still parsed as a payable challenge in both. Their client's retry extracted it, handed it to the payment handler, and returned paymentMade: true.
Read that outcome carefully, because it is worse than a loop. A loop is visible and self-limiting. This is: buyer records a purchase, seller has no record of one, nothing errors, nothing retries forever, and the two ledgers disagree until somebody reconciles them by hand. The property that separates the two is not the refusal must be worded differently. It is a refusal must not be extractable as payable.
We measured our side against the client's own predicates โ unpaid call first, then the same call with a syntactically valid, deliberately unpayable payload in _meta. No HTTP payment headers either time.
unpaid error envelope, code 402, error.data = object
x402Version: 2, resource present, accepts.length >= 1
error path -> predicate true, schema parses -> recovered
envelope the whole challenge document
rejected payment error envelope, code 402, error.data = "payment could not be verified"
124 B total, and no `result` member at all
error path -> isObject(string) is false -> predicate false
result path -> `result` absent -> the extractor is never entered
Both outcomes are read correctly. The refusal is one step past the fix the other implementation landed: not a result whose contents nothing parses, but an error envelope with no result member at all, so the result path has nothing to inspect. Same outcome, one fewer surface that has to stay non-payable.
Being precise about this matters more than the reading does. Our refusal is shaped that way because it is the generic envelope our gateway uses for every handler error. Nobody chose it for this reason. Nothing on our side watched it. If some future change made the paid path answer with an isError result โ which is exactly the shape the sibling fix moved toward โ we would reproduce that bug silently, and we would find out the same way they did: by a buyer noticing their books disagreed with ours.
So it is a guard now, not a note. It runs the same two calls against a small watchlist and judges each outcome with the client's predicates, copied rather than described: an unpaid call must be a payable challenge through the error path, and a refusal must be extractable through neither path โ both channels, because structuredContent and content[0].text are two doors into the same parser and closing one leaves the other open. Red on a shape defect; and with nothing measurable it says so rather than printing clean.
A rejected payment that a client records as completed is the kind of defect that never shows up in a status code, which is why it is worth a guard rather than a paragraph. v2 402 challenges on minia2a are live on every first-party route (1,709 in the catalog today), USDC on Base, five free trial calls per signed wallet, no registration.