Neuronto PaymentsAccept payments

Signed offers and receipts in x402: what they prove, and the three details implementations get wrong

A blockchain transaction proves that value moved. It does not prove what the value was for, what the seller promised before taking it, or whether anything was delivered. The x402 Offer and Receipt extension closes that gap with two server-side signatures: a signed offer, which commits the resource server to the terms it quoted on its 402 response, and a signed receipt, which the server returns on success only. Both are EIP-712 typed data. Three details decide whether your implementation can be verified by anyone else: the EIP-712 domain uses the constant chainId 1 rather than the payment network, the canonical types are normative and are never sent on the wire, and a valid signature is not the same thing as an authorized signer.

What the base protocol leaves open

x402 revives a status code that HTTP itself never defined. RFC 9110 still says, in full, "The 402 (Payment Required) status code is reserved for future use." The protocol fills that in: the server answers an unpaid request with machine-readable terms, the client signs a stablecoin transfer, and retries.

What the base protocol does not do is make the server say anything in its own voice. The terms in accepts[] are unsigned. The success response is unsigned. If a server quotes one price and later insists it quoted another, or takes a payment and denies the request ever succeeded, the client has a transaction hash and nothing else. A transaction hash shows an amount moving between two addresses. It carries no resource URL, no terms, and no statement that anyone delivered anything.

The Offer and Receipt extension adds exactly two signed artifacts to fix that, and deliberately nothing more.

The two artifacts

A signed offer is placed on the 402 response, at extensions["offer-receipt"].info.offers[]. It covers the resource URL, the scheme, the network, the asset, the recipient address, the amount, and an optional expiry. It is the server committing to a quote.

A signed receipt is placed on the success response, at extensions["offer-receipt"].info.receipt. The specification is precise about when it may appear: "A receipt is a signed statement returned by the resource server only on success, confirming that payment was received and service was delivered."

That last clause is the part worth pausing on. The receipt is signed by the resource server, not by the facilitator. The facilitator can see a payment settle; only the server can state that it also delivered. The two claims are different, and the extension assigns the second to the only party able to make it.

The receipt is also deliberately thin. "The receipt is privacy-minimal by default and intentionally omits transaction references to reduce correlation risk." The transaction field is optional. Including it makes the receipt independently checkable against the chain; omitting it keeps a payer's calls from being linked to one another through a public ledger. That is a real trade and the specification refuses to make it for you.

What each artifact proves in a paid HTTP call Four stages of a paid request. The 402 challenge carries a signed offer from the server. The client signs a payment. The chain records a transfer. The success response carries a signed receipt from the server. The offer proves the quoted terms, the transaction proves value moved, and the receipt proves payment arrived and the resource was delivered. What each artifact proves 402 challenge signed OFFER the terms quoted client signature payment payload what the payer agreed on-chain transfer transaction hash that value moved 200 response signed RECEIPT paid and delivered Without the two signatures A transaction hash shows an amount between two addresses. It carries no resource, no terms and no statement that anything was delivered. Signed by the resource server, in EIP-712 typed data. The offer commits the quote; the receipt is returned on success only. Neuronto Payments implementation, September 2026.

Detail one: the EIP-712 chainId is the constant 1

This is the one that catches careful engineers, because it looks like a bug. The payment settles on Base, chain 8453, so a reviewer reaches for 8453 in the EIP-712 domain. The specification says otherwise: "The chainId is hardcoded to 1 (Ethereum mainnet) for all EIP-712 signatures in this extension."

The reasoning is sound once you see it. EIP-712 is being used here purely as an off-chain signing format, not to authorize an on-chain transaction. The payment network already travels inside the signed payload, in the network field. Pinning the domain to a constant means one signature shape works identically on every network, including non-EVM networks like Solana where 8453 would be meaningless.

The consequence is unforgiving. The domain separator is the hash of the domain's name, version and chainId. Change the chainId and every signature you produce becomes unverifiable by every other implementation, while remaining perfectly verifiable by your own. Your tests pass. Nobody else can read your receipts.

The defence is to pin the domain separator itself in a test, against a fixed vector, so a well-meant correction fails loudly:

text
offer domain separator   5dc92544087ab60aa57c539b9543f9282416b0dcf2c2ae0d4d0051052a9e61b6
receipt domain separator fd7f8c81cdd24b25c99ba22e191cee41d8809abbc12e8fdbd42fddfa5bb9e406

Those two hashes are what {name: "x402 offer" | "x402 receipt", version: "1", chainId: 1} hashes to. If your implementation produces different ones, it is not interoperable, whatever your own verifier says.

Detail two: the canonical types are never transmitted

The extension states it plainly: "The canonical types and primaryType definitions MUST NOT be included in transmitted x402 messages (offers/receipts)."

This surprises people who have worked with EIP-712 in wallets, where the full typed-data object including types is normally passed around. Here both sides take the schema from the specification instead. The artifact on the wire carries only format, payload, signature, and optionally acceptIndex.

The security reason is the important one. EIP-712 hashes the schema into the signature: "Because EIP-712 hashes the schema into the signature, any change to the canonical types or primaryType constitutes a breaking change and MUST be accompanied by explicit versioning." If a verifier accepted a schema supplied by the sender, the sender could choose a schema under which a different message produces the expected hash. Taking the schema only from the specification removes that freedom entirely.

A related trap sits in the optional fields. A fixed EIP-712 schema has no way to express absence, so the extension mandates specific empty values: "For the optional validUntil field, implementations MUST set unused fields to 0." A receipt with no transaction signs transaction as the empty string. Omit the field instead and your struct hash differs from everyone else's.

Detail three: a valid signature is not an authorized signer

This is the one with teeth, and the extension devotes a section to it: "Verifiers MUST distinguish between signature validity and signer authorization."

The attack is trivial to run. "Without authorization verification, an attacker can generate a valid key pair, sign an offer or receipt for any resourceUrl, and present it as legitimate, the signature will verify, but the key has no relationship to the service." Anyone can mint a keypair in a second and sign a beautiful, cryptographically valid receipt claiming that some well-known API delivered something. Signature verification alone returns true.

So a verifier has to answer two questions and keep them apart: which key signed this, and is that key allowed to speak for the host in resourceUrl. The extension does not mandate one binding mechanism. It lists several: signing with the payTo key, a did:web document, a DNS TXT record under _controllers.<domain>, or an external key registry.

Signing with the payTo address is the simplest and the extension warns about it in the same breath, noting that "coupling payment receipt and signing into a single key increases risk if the key is compromised". A dedicated signing key that holds no funds is the safer arrangement: if it leaks, you rotate an issuer rather than lose money.

A verifier should therefore report two fields, not one. Here is a live offer being checked, signature and authorization separately:

text
POST https://pay.neuronto.com/offer-receipt/verify
{"kind": "offer", "artifact": { ... the offer from the 402 ... }}

{"kind":"offer","valid":true,
 "signer":"0xF58f57c8a687A4b3FA42789328538C80AA1ffe09",
 "authorizedForThisHost":true,
 "payload":{"version":1,"resourceUrl":"https://pay.neuronto.com/echo",
            "scheme":"exact","network":"eip155:8453","amount":"1000", ...}}

A stranger's key signing the same payload returns "valid": true with "authorizedForThisHost": false. That is the correct answer, and collapsing it into a single boolean is how implementations get fooled.

What acceptIndex is and is not

An offer may carry acceptIndex, pointing at the entry in accepts[] it corresponds to. It is a convenience and it is not signed. The specification is explicit that "Clients MUST NOT treat acceptIndex as authoritative", and that clients should match offers to accepts[] entries by comparing the payload fields.

The practical rule: read the amount, asset and recipient from the signed payload, and check they match the accepts[] entry you are about to pay. If they disagree, the unsigned half is the one to distrust.

How to check an implementation is really conformant

Four checks, in the order they catch problems:

  • Compute the domain separator for both artifacts and compare against the two hashes above. This catches the chainId mistake and any drift in the canonical types at once.
  • Confirm the artifact on the wire carries no types, primaryType or domain key.
  • Alter one field of the payload at a time and confirm each alteration breaks verification. A receipt whose amount can be changed without breaking the signature is worse than no receipt, because it reads as evidence.
  • Sign the same payload with an unrelated key and confirm the verifier reports a valid signature and an unauthorized signer, rather than simply rejecting it or simply accepting it.

How much of the ecosystem actually signs

Measured on 20 September 2026 by walking the public x402 discovery catalogue in full, 14,884 resources against a reported total of 14,885:

count
resources in the catalogue14,884
declaring the offer-receipt extension369, or 2.5%
signed offers carried in those entries835
in eip712 format707, or 84.7%
in jws format128, or 15.3%

So the extension is deployed but far from universal, and where it is deployed the EIP-712 format outnumbers JWS by roughly five to one.

One number from that walk is worth more than the adoption figure. Group all 707 EIP-712 offers by the host that published them, recover the signer from each, and count how many distinct signers each host produces. The answer is one, for every one of the 19 hosts.

That matters because ECDSA recovery never fails for a well-formed 65-byte signature. It always returns some address. A verifier computing a digest that differed from the signer's by a single byte would therefore recover a different arbitrary address for every offer, and the count per host would be as high as the offer count. Nineteen independent deployments each collapsing to exactly one signer is only possible if the domain separator, the type hash and the field encoding agree byte for byte across all of them.

A caution on the adoption figure, because it caught us: an earlier partial walk of 3,100 entries gave 4.1%. The 2.5% is from the complete traversal. A published total is not a reachable total, and a truncated walk reports whatever the first pages happened to contain.

One incidental finding with practical weight for anyone writing a verifier: zero of those 707 offers were signed by the payTo address. Every observed deployment uses a separate signing key, which is what the extension recommends. A verifier that takes the shortcut of matching the recovered signer against payTo would reject the entire observed population.

Method and limits

Everything above is drawn from the published extension and from implementing it. Three limits are worth stating.

The extension itself says its serialization is provisional: "Wire shape and field placement are not considered stable" and may change as x402 standardizes its extension architecture, while the behavioural requirements are stable. Build against the behaviour and expect field placement to move.

The signatures prove statements, not truth. A signed receipt is the server asserting that it delivered. It is excellent evidence in a dispute and it is not proof of delivery, because the server is the one making the claim. Nothing in this extension gives a buyer recourse when a seller signs a receipt for work it did not do. That is a different problem, and x402 addresses it in a separate auth-capture scheme, where a payment can be held and then captured, voided or refunded: "Where exact moves a fixed amount once and offers no way to give it back, auth-capture is for payments whose final amount is not known when the client authorizes, or which may later need to be undone."

Finally, key binding is only as current as the source you check it against. A did:web document or a DNS record shows today's state. If you are verifying an artifact from six months ago, today's document does not tell you who was authorized then.

Frequently asked questions

What is a signed x402 receipt? A statement signed by the resource server, returned on a successful paid request only, saying that payment was received and the service was delivered. It is EIP-712 typed data covering the network, resource URL, payer, issue time and optionally the transaction hash, and it travels in the response at extensions["offer-receipt"].info.receipt.

How do I verify a payment receipt from an API? Recover the signer from the EIP-712 signature using the canonical types from the specification, never types supplied alongside the artifact, then separately confirm that the recovered key is authorized to sign for the host in resourceUrl, for example through that host's did:web document. Treat those two results as two answers.

Why is the EIP-712 chainId 1 when my payment is on Base? Because the extension signs off-chain and pins the domain deliberately, so one signature shape works on every network. The payment network is carried in the payload's network field instead. Setting it to the payment chain makes your artifacts unverifiable by other implementations.

Is a signed receipt proof that the service was delivered? No. It is the server's own signed assertion that it delivered, which is strong evidence in a dispute and is not independent proof. A buyer wanting recourse before the money is final needs a holdable payment, which is what the separate auth-capture scheme exists for.

Does the receipt reveal the transaction hash? Only if the server chooses to include it. The field is optional and the extension omits it by default to reduce correlation risk, at the cost of making the receipt harder to check against the chain independently.

Can I use JWS instead of EIP-712? The extension defines both formats. With JWS the payload is omitted because the compact string already contains it, and the header must carry alg and a kid that resolves to a public key. The signed fields and the verification rules are otherwise the same.

Sources