An agent calling a pay-per-call endpoint gets rejected in one of three ways, and only one of them is a payment problem. Mistaking the other two for "I owe money" is the single most common way agents waste a transaction on a problem the transaction can't solve.
| What you sent | HTTP | MCP tools/call | What it actually means |
|---|---|---|---|
| No wallet, no signature | 402 challenge | 402 challenge | Anonymous access is off. Pay, or sign for a trial. |
| Wallet present, signature missing / invalid / expired | 400 + reason | -32602 (Invalid params) | Client-side signature problem. Re-sign, or drop the wallet param. |
| Wallet + valid signature, trials spent | 402 challenge | 402 challenge | You've used your free calls. Pay to continue. |
One trap in that table: the MCP -32602 is a JSON-RPC error inside an HTTP 200 response. A client that only checks the HTTP status sees a success. You have to read the JSON-RPC error object, not the transport code.
Read the last column carefully. A 400 (or -32602 over MCP) is not a 402. It is not "insufficient balance", it is not "this endpoint costs money you haven't sent". It is the server saying: the signature you attached does not prove you are the wallet you named.
The one-line mental model:
402 = a payment question. Pay and it resolves.
400 / -32602 = a cryptography question. Paying does nothing โ the signature is broken. Fix the signature.
A signed trial call carries exactly two headers. Naming them matters, because this is the page readers land on after a 400 — and for some of them, the error text below is the only place they will ever see the header name:
X-Wallet-Signature: <EIP-191 personal_sign of the message below>
X-Trial-Timestamp: <the same unix seconds you signed>
Attach a wallet without them and the gateway says exactly that, at HTTP 400, with the header name in the error:
HTTP 400
{"error":"missing X-Wallet-Signature header",
"hint":"re-sign the trial message with your wallet key and retry, or pay via x402 without a wallet param",
"serviceId":"x402-time","wallet":"0xf3707A293E1C7cD8C4667049C7aac66443aFA8c1"}
Attach a signature that doesn't verify and you get the reason plus the exact string to sign — this response is self-healing; you can copy the message straight out of it:
HTTP 400
{"error":"signature does not match wallet โ sign message: minia2a trial:0xf3707A293E1C7cD8C4667049C7aac66443aFA8c1:x402-time:1789215783",
"hint":"re-sign the trial message with your wallet key and retry, or pay via x402 without a wallet param",
"serviceId":"x402-time","wallet":"0xf3707A293E1C7cD8C4667049C7aac66443aFA8c1"}
An expired timestamp is not the same error. It has its own string, so you can tell the two apart without guessing:
HTTP 400
{"error":"X-Trial-Timestamp expired (must be within ยฑ5 minutes of server time)",
"hint":"re-sign the trial message with your wallet key and retry, or pay via x402 without a wallet param",
"serviceId":"x402-time","wallet":"0xf3707A293E1C7cD8C4667049C7aac66443aFA8c1"}
All three share the identical hint, so error is your discriminator — the hint is not. In every case the serviceId field hands you the catalog id you should have signed, so a wrong-id bug is diagnosable from the response alone.
Over MCP, the same failure surfaces as a JSON-RPC error with the standard "Invalid params" code — arriving at HTTP 200:
HTTP 200
{"jsonrpc":"2.0","id":2,
"error":{"code":-32602,
"message":"invalid wallet signature โ missing X-Wallet-Signature header",
"data":{"hint":"sign the trial message with your wallet key and retry, or omit the wallet param and pay via x402",
"serviceId":"x402-time","wallet":"0xf3707A293E1C7cD8C4667049C7aac66443aFA8c1"}}}
Trial access is EIP-191 personal_sign over this exact string:
minia2a trial:<wallet>:<serviceId>:<unixSeconds>
The wallet must be byte-for-byte the same string you pass in ?wallet= โ checksummed or lowercase, just be consistent. The serviceId is the full endpoint id (for /x402/time that is x402-time, not time). The timestamp is the same value you send in X-Trial-Timestamp.
1. The [object Promise] signature. viem's signMessage returns a Promise. Forgetting await makes the header literally the string [object Promise], which verifies against nothing and returns 400. This is a test-script bug, not a platform bug โ but it's the most common one I hit.
2. Wrong serviceId. Signing time when the id is x402-time does return the same signature does not match wallet 400 — verified against the live endpoint, byte-for-byte identical. But you don't have to guess which of the two you hit: the serviceId field, and the message inside error, both name the id you should have used.
3. Address case drift. The signed payload contains the address as a string. If it doesn't match what's in ?wallet= byte-for-byte, the signature is over a different message.
4. Expired timestamp. A stale X-Trial-Timestamp fails the freshness check and returns 400 — but with its own distinct error string (X-Trial-Timestamp expired (must be within ยฑ5 minutes of server time)), not the signature-mismatch one. Same status code, different diagnosis.
The whole flow in one file. This is the exact code that was run against the live endpoint before this page was published; it returns 200 with x-trial-remaining: 4 — the fourth of that wallet's five free calls.
import { generatePrivateKey, privateKeyToAccount } from "viem/accounts";
const BASE = "https://minia2a.uk";
const SLUG = "time"; // the URL path segment: /x402/time
const SERVICE_ID = "x402-time"; // the catalog id used in the signed message
const account = privateKeyToAccount(generatePrivateKey());
const ts = Math.floor(Date.now() / 1000);
const message = `minia2a trial:${account.address}:${SERVICE_ID}:${ts}`;
const signature = await account.signMessage({ message });
const res = await fetch(`${BASE}/x402/${SLUG}?wallet=${account.address}`, {
headers: {
"X-Wallet-Signature": signature,
"X-Trial-Timestamp": String(ts),
},
});
console.log("status:", res.status);
console.log("x-trial-remaining:", res.headers.get("x-trial-remaining"));
console.log("body:", await res.text());
Two details are where every 400 above comes from: the URL uses the short SLUG while the signature uses the catalog SERVICE_ID, and the number in X-Trial-Timestamp must be the same number that went into the message.
The failure mode an agent thinks it's in determines what it does next. An agent that sees 400 and concludes "I need to pay" will sign a payment and retry โ and get 400 again, because the signature is still broken. An agent that correctly reads 400 as "my signature is broken" re-signs in seconds and moves on.
For protocol design, the takeaway is the same one the x402 tax & accounting working group keeps circling back to: distinguish what the client controls from what the ledger records. A bad signature is a client problem; a spent trial balance is a payment problem. Collapsing both into "402" makes agents pay for their own signing bugs.
The quick fix, in order:
1. Did you attach a wallet? No โ it's a 402, pay or sign for a trial.
2. Yes, but the signature is missing/bad/expired โ re-sign the exact minia2a trial:<wallet>:<serviceId>:<unixSeconds> message, or drop ?wallet= to fall through to a clean 402.
3. Signature is valid and you still get 402 โ your 5 free trial calls are spent; pay to continue.