Autonomous x402 Refunds
Recently the x402 took the crypto twitter by storm, surging over +10,000% in 24 hour volume past week. The reason behind the hype is that the x402 protocol enables micropayments for HTTP requests via crypto payments. When someone or some agent access a paywalled endpoint, the server responds with 402, you attach a payment authorization in your next request, and the server grants access.
However, as with any early protocol, there are several gaps. One of them is Refunds.
In traditional payment systems, chargebacks and dispute resolution handle such scenarios. But with x402, the payment is already settled onchain before the request is fulfilled, making reversal hard. What if the API goes down, the computation fails, or the response is malformed? In traditional REST APIs, you simply retry or contact support. In a x402 its too late and it becomes a friction point. The buyer has paid but received no value. The seller might be honest and offer refunds, but implementing this requires trust and coordination.
The buyer/agent knows they received an error, but how can they prove it to a smart contract in a trustless way? The server could lie about what response was sent. The buyer could lie about what they received. We need a cryptographic mechanism that binds the actual HTTP response to an on-chain claim.
So I've been exploring some solutions and attempted to solve the refund problem leveraging zero-knowledge proofs.
System Overview
The goal is to enable autonomous refund claiming. The system requires sellers (merchants) to deposit a bond into a smart contract so that buyers can claim refunds by submitting cryptographically signed receipts when services fail. The architecture involves three main components working together: a bond pool contract that holds seller funds, a resource server that processes payments and signs failure receipts, and a client that collects these receipts and submits them onchain.
When a request fails, the server generates a signed receipt authorizing a specific refund amount for that specific request. The client can then submit this receipt to the smart contract, which verifies the signature and issues the refund from the bond pool.
Flow Diagram

Initial Handshake
Before making any paid requests, the client should hit the GET /escrow endpoint. Every server with a bonded escrow contract must expose this endpoint that returns the escrow address in the response.
Using this address, the client can verify that the escrow has sufficient minimum balance for the refunds. If the client is not able to validate, they can choose a different server.

The Bonded Escrow Contract
The bond contract serves as an escrow mechanism that holds seller funds and processes refund claims. Each seller should expose their bond pool contract address to gain trust. The contract must balance security against fraudulent claims while remaining efficient enough that gas costs don't exceed refund amounts.
The claim refund method looks something like this:
function claimRefund(
bytes32 requestCommitment,
uint256 amount,
bytes calldata signature
) external {
if (commitmentSettled[requestCommitment]) revert AlreadySettled();
bytes32 structHash = keccak256(abi.encode(
REFUND_CLAIM_TYPEHASH,
requestCommitment,
amount
));
bytes32 digest = _hashTypedDataV4(structHash);
address recovered = ECDSA.recover(digest, signature);
require(recovered == sellerAddress, InvalidSignature());
commitmentSettled[requestCommitment] = true;
token.safeTransfer(msg.sender, amount);
emit RefundClaimed(requestCommitment, msg.sender, amount);
}
What is Request Commitment?
A request commitment is a cryptographic fingerprint that uniquely identifies a single HTTP transaction:
requestCommitment = keccak256(
method, // "GET"
url, // endpoint URL
xpayment, // payment header as bytes
window // validity window e.g. "60"
)This helps prevent replay attacks. Without this binding, an attacker could reuse the same signed receipt multiple times to drain the bond pool. The commitment cryptographically locks the refund to exact request parameters.
Both the server and client compute this same commitment. The server signs it as part of the refund authorization, and the contract verifies that each commitment can only be refunded once using the settledByCommitment mapping.
Server-Side Changes
When a paid request fails, the server must generate a cryptographic receipt (EIP-712) authorizing the refund. This involves signing a message that binds together the bond pool address, request commitment, and refund amount.
async function signRefund(
requestCommitment: string,
amount: bigint
): Promise<string> {
const domain = {
name: "BondedEscrow",
version: "1",
chainId: CHAIN_ID,
verifyingContract: BOND_ESCROW_ADDRESS,
};
const types = {
RefundClaim: [
{ name: "requestCommitment", type: "bytes32" },
{ name: "amount", type: "uint256" },
],
};
const value = {
requestCommitment: requestCommitment,
amount: amount,
};
return await sellerWallet.signTypedData(domain, types, value);
}
app.post("/api/endpoint", async (req, res) => {
const xpay = req.header("x-payment");
// Decode payment to get amount
const decoded = exact.evm.decodePayment(xpay);
const amountAtomic = BigInt(decoded.payload.authorization.value);
// Compute request commitment
const requestCommitment = keccak256(
encodeAbiParameters(
parseAbiParameters("string method, string url, bytes xpay, string window"),
["POST", fullUrl, `0x${Buffer.from(xpay).toString("hex")}`, "60"]
)
);
try {
// Process request...
const result = await processRequest(req);
return res.json({ ok: true, data: result });
} catch (error) {
// Generate signed refund receipt
const signature = await signRefund(requestCommitment, amountAtomic);
return res.status(400).json({
ok: false,
error: error.message,
refund: {
requestCommitment,
amount: amountAtomic.toString(),
signature
}
});
}
});Notes:
- The server
ok: falsein the body with the request commitment and signature. - The refund amount must match exactly what was paid (extracted from X-Payment header)
- The signature proves the seller authorized this specific refund
- Each signature is unique to one request commitment
Client-Side Changes
The client must handle the refund flow manually rather than using automatic middleware. When a paid request fails, the client captures the signed receipt and submits it to the bond pool contract to instantly claim the refunds without requiring any manual intervention
async function attemptRefundableRequest() {
// Step 1: Get payment requirements
const probe = await axios.get(url, { validateStatus: () => true });
const { accepts } = probe.data;
const bondPool = probe.headers["x-bond-pool"];
// Step 2: Create payment header
const payment = await createPaymentHeader(signer, accepts);
const decoded = decodePayment(payment);
const amount = BigInt(decoded.payload.authorization.value);
// Step 3: Compute request commitment
const commitment = keccak256(
encodeAbiParameters(
parseAbiParameters("string method, string url, bytes xpay, string window"),
["GET", url, toBytes(payment), "60"]
)
);
// Step 4: Make paid request
const response = await axios.get(url, {
headers: { "x-payment": payment }
});
// Step 5: Handle response
if (response.data.ok) {
console.log("Success:", response.data.data);
} else {
// Request failed - save refund receipt
const { refund } = response.data;
// Optional: Verify signature client-side
const message = ethers.solidityPackedKeccak256(
["address", "bytes32", "uint256"],
[bondPool, commitment, refund.amount]
);
const recovered = ethers.verifyMessage(
ethers.getBytes(message),
refund.signature
);
console.log("Signature valid:", recovered === sellerAddress);
// Submit to contract
await bondPoolContract.claimRefund(
commitment,
refund.amount,
refund.signature
);
console.log(`Refunded ${refund.amount}`);
}
}The client flow is straightforward:
- Make payment and compute commitment
- Send request with payment header
- If successful, use the data
- If failed, extract signed receipt and submit to contract
- Contract verifies signature and issues refund
The Trust Model
This approach fundamentally relies on trusting the seller.
What the system cryptographically prevents:- Forged refunds: Only holder of seller's private key can authorize refunds
- Replay attacks: Each request commitment can only be refunded once
- Amount tampering: Signature becomes invalid if amount is changed
- Cross-pool attacks: Signature includes bond pool address
- Server lying about whether payment was received
- Server signing inflated refund amounts
- Server returning stale/incorrect data but calling it "success"
Alternative Approaches Considered
When designing this refund flow I was also exploring other design patterns for autonomous refunds but they had their own drawbacks.
1. Facilitator Attestations
Instead of trusting the seller, trust the facilitator. The facilitator already coordinates payment settlement and knows the actual paid amount. They could sign an attestation:
struct PaymentAttestation {
bytes32 requestCommitment;
uint256 amountPaid;
address payer;
bytes facilitatorSignature;
}
function claimRefund(
PaymentAttestation calldata attestation,
bytes calldata sellerRefundAuth
) external {
// Verify facilitator confirms payment happened
require(verifyFacilitator(attestation), "no payment proof");
// Verify seller authorized refund
require(verifySeller(sellerRefundAuth), "unauthorized");
// Refund...
}Though it helps us roves payment actually occurred, prevents seller from signing fake refunds, it requires a decentralized facilitator network or trusted facilitator registry that adds additional complexities
2. On-Chain Payment Registry
Post all payments onchain via Merkle tree roots:
// Facilitator posts daily batch
mapping(uint256 => bytes32) public dailyPaymentRoots;
function claimRefund(
bytes32 requestCommitment,
uint256 amount,
bytes32[] calldata merkleProof,
bytes calldata sellerSignature
) external {
// Verify payment exists in Merkle tree
bytes32 leaf = keccak256(abi.encode(requestCommitment, amount));
require(verifyMerkleProof(leaf, merkleProof, dailyPaymentRoots[today]));
// Verify seller auth
// ...
// Refund...
}This method seemed to be quite robust as it provides direct onchain verification but introduces a significant delay (must wait for batch) with higher gas costs.
3. Zero-Knowledge Proofs
Use zkTLS/zkFetch to prove the HTTP response contained a failure:
Benefits: Cryptographically proves the interaction happened, proves response content.
Drawbacks: Cannot extract success response bodies (only failures), high complexity, proof generation time, still doesn't solve payment verification problem.
Why I Chose Server Signatures
After evaluating alternatives, server-signed receipts offer the best pragmatic balance:
| Criteria | Server Signatures | Facilitator Attestations | Merkle Proofs | ZK Proofs |
|---|---|---|---|---|
| Complexity | Low | Medium | High | Very High |
| Latency | Instant | Instant | Hours-Days | Minutes |
| Gas Cost | Low | Medium | High | Very High |
| Trust Assumption | Seller honesty | Facilitator honesty | Facilitator honesty | Seller honesty |
IMHO we're willing to trust that they return accurate data, trusting them to sign accurate refunds isn't a significant additional assumption. The signatures prevent unauthorized refunds and external attacks, which is the primary concern.
Conclusion
The x402 protocol is exciting but incomplete. Refunds are a critical missing piece for mainstream adoption. The current approach prevents unauthorized refunds while accepting that we trust sellers for honest operation—the same trust we already place in them for data quality. For most use cases (API marketplaces, known sellers, small payments), this is sufficient.
In the future we could explore:
- Decentralized facilitator networks for payment attestations
- Reputation systems to penalize dishonest refund behavior
This was just a weekend experiment and there are lots of room for improvement. If you're building something similar or have questions, feel free to reach out!