x402 is getting a lot of attention right now. The protocol — open-sourced by Coinbase — revives HTTP 402 for native internet payments. Stripe, Cloudflare, and others have adopted it. But most of the content out there is protocol explainers and hello-world demos.
I've been building AgentStore, a marketplace for Claude Code plugins where publishers earn USDC for their agents. The entire payment system runs on x402. Here's what it's actually like to build on the protocol — the architecture decisions, the real code, and the things that surprised me.
What x402 Actually Does
x402 brings back HTTP 402 Payment Required. Your server returns a 402 with payment details. The client pays. The client retries with proof. Simple.
But the interesting part is how the payment happens. x402 uses EIP-3009 transferWithAuthorization — a standard that lets someone sign an off-chain message authorizing a USDC transfer. A relay wallet (the "facilitator") submits that authorization on-chain and pays the gas. The user never needs ETH.
Client Server Facilitator USDC Contract
│ │ │ │
│ GET /agent/access │ │ │
│───────────────────────────>│ │ │
│ │ │ │
│ 402 {amount, payTo, x402} │ │ │
│<───────────────────────────│ │ │
│ │ │ │
│ sign EIP-3009 auth │ │ │
│ (one signature, no ETH) │ │ │
│ │ │ │
│ POST /payments/submit {authorization} │ │
│───────────────────────────>│ │ │
│ │ /verify {authorization} │ │
│ │──────────────────────────>│ │
│ │ /settle {authorization} │ │
│ │──────────────────────────>│ │
│ │ │ transferWith │
│ │ │ Authorization() │
│ │ │────────────────────>│
│ │ │ │
│ │ {tx_hash, proof} │ │
│ │<──────────────────────────│ │
│ │ │ │
│ {entitlement, install} │ │ │
│<───────────────────────────│ │ │
That's the real flow. One user signature, zero gas.
The 402 Response
When someone tries to access a paid agent without paying, the API returns this:
function createPaymentRequired(params) {
return {
amount: params.amount.toFixed(2),
currency: 'USDC',
payTo: params.payTo,
resource: {
type: 'agent',
agent_id: params.agentId,
description: params.agentName,
},
x402: {
version: '1',
chain_id: 1, // Ethereum mainnet
token: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // USDC
facilitator: params.facilitatorEndpoint,
domain: {
name: 'USD Coin',
version: '2',
chainId: 1,
verifyingContract: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
},
},
nonce: generateNonce(),
expires_at: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
};
}
The x402.domain field is the EIP-712 domain for USDC's transferWithAuthorization. The client needs it to construct the typed data for signing. The nonce and expiry prevent replay attacks.
The response goes back as HTTP 402 with an X-Payment-Required header:
return NextResponse.json(
{ error: 'Payment Required', code: 'PAYMENT_REQUIRED', payment: paymentRequired },
{ status: 402, headers: { 'X-Payment-Required': JSON.stringify(paymentRequired) } }
);
The EIP-3009 Authorization
This is the core of x402's gasless UX. The user signs EIP-712 typed data that authorizes a USDC transfer — but doesn't execute it. The typed data looks like:
interface TransferAuthorizationMessage {
domain: {
name: 'USD Coin',
version: '2',
chainId: 1,
verifyingContract: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'
},
types: {
TransferWithAuthorization: [
{ name: 'from', type: 'address' },
{ name: 'to', type: 'address' },
{ name: 'value', type: 'uint256' },
{ name: 'validAfter', type: 'uint256' },
{ name: 'validBefore', type: 'uint256' },
{ name: 'nonce', type: 'bytes32' }
]
},
primaryType: 'TransferWithAuthorization',
message: {
from: buyerAddress,
to: publisherAddress,
value: '500000', // 0.50 USDC (6 decimals)
validAfter: '0',
validBefore: deadline,
nonce: randomBytes32
}
}
The user's wallet signs this. No transaction, no gas. Just a signature over structured data that says "I authorize moving 0.50 USDC from my address to the publisher's address."
Settlement
The signed authorization gets submitted to the marketplace API, which forwards it to the facilitator:
// Step 1: Verify the authorization is valid
const verifyResponse = await fetch(`${FACILITATOR_ENDPOINT}/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
authorization,
payment_required,
payer: wallet_address,
fee_split: feeSplit,
}),
});
// Step 2: Settle — facilitator submits to USDC on-chain
const settleResponse = await fetch(`${FACILITATOR_ENDPOINT}/settle`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
authorization,
payment_required,
payer: wallet_address,
fee_split: feeSplit,
}),
});
const proof = await settleResponse.json();
// proof: { tx_hash, block_number, status, confirmations }
The facilitator's relay wallet calls USDC's transferWithAuthorization(from, to, value, validAfter, validBefore, nonce, v, r, s) on-chain. The contract verifies the user's signature and moves USDC directly from buyer to seller. The relay pays gas but never touches the user's funds.
Fee Splitting
AgentStore takes a 20% platform fee. This is handled with integer microdollar arithmetic to avoid floating-point errors:
function calculateFeeSplit(amount: number) {
const totalMicro = Math.round(amount * 1_000_000);
const platformMicro = Math.round(totalMicro * PLATFORM_FEE_PERCENT / 100);
const publisherMicro = totalMicro - platformMicro;
return {
platformAmount: (platformMicro / 1_000_000).toFixed(6),
publisherAmount: (publisherMicro / 1_000_000).toFixed(6),
platformAmountNum: platformMicro / 1_000_000,
publisherAmountNum: publisherMicro / 1_000_000,
};
}
USDC has 6 decimal places. Doing math in microdollars (integers) and converting at the end avoids the classic 0.1 + 0.2 !== 0.3 problem.
What Happens After Payment
Once the facilitator returns a proof with a tx_hash, the marketplace:
- Creates an entitlement — a record that this wallet has access to this agent
- Records a transaction — for earnings tracking and replay protection
- Returns the agent's install instructions to the client
// Replay protection: reject if tx_hash already used
if (txError?.code === '23505') { // unique constraint violation
await supabase.from('entitlements').delete().eq('id', entitlement.id);
return NextResponse.json(
{ error: 'Transaction already used for a purchase' },
{ status: 409 }
);
}
The tx_hash uniqueness constraint is the replay protection — you can't use the same on-chain settlement to claim two entitlements.
Preconfirmations
One design decision: the facilitator can return a preconfirmed status before the transaction is fully confirmed on Ethereum. This means the user gets access in under 200ms instead of waiting 12+ seconds for a block.
We handle this with a verification deadline:
const isPreconfirmed = proof.status !== 'confirmed';
const verificationDeadline = isPreconfirmed
? new Date(Date.now() + PRECONF_VERIFICATION_DEADLINE_MS).toISOString()
: null;
A cron job checks unconfirmed transactions and revokes entitlements if they don't settle within the deadline. Optimistic access with a safety net.
What I Learned
1. Gasless UX is a genuine unlock. The entire payment is one EIP-712 signature. No "approve then transfer" two-step. No ETH in the wallet. No gas estimation. Users who have USDC can pay immediately. This removes the biggest friction in crypto payments.
2. The facilitator is the hard part. The protocol spec is straightforward. Building a reliable relay that handles gas pricing, nonce management, reorgs, and stuck transactions is the real engineering challenge. We use a self-hosted facilitator on Ethereum mainnet.
3. Integer arithmetic matters. USDC has 6 decimals. JavaScript floating point will eventually round wrong. We caught a bug where 0.50 * 0.20 = 0.1 was becoming 0.09999... in fee calculations. Converting to microdollars (multiply by 1,000,000, do integer math, convert back) fixed it.
4. Replay protection needs multiple layers. EIP-3009 nonces prevent on-chain replays, but you also need application-level deduplication. We use a unique constraint on tx_hash in the transactions table — if someone tries to claim a second entitlement with the same proof, it's rejected.
5. Preconfirmations change the UX model. With preconfirmations, you're granting access optimistically and verifying later. This means your system needs to handle revocations gracefully. We set a deadline and run a cron that checks — if the transaction doesn't confirm, the entitlement gets deactivated.
The Full Stack
AgentStore is open source (MIT):
- Code: github.com/techgangboss/agentstore
- Live: agentstore.tools
- API docs: api.agentstore.tools/api (plain text, LLM-readable)
-
CLI:
npm install -g agentstore
The x402 implementation lives in packages/common/src/x402.ts (types and helpers) and packages/api/src/app/api/payments/submit/route.ts (settlement flow).
If you're building on x402, feel free to use the code as a reference. Happy to answer questions.
Top comments (0)