A drop-in fetch that silently pays x402 payment challenges. Wrap a wallet once, then call any paid x402 endpoint as if it were free — on a 402 Payment Required the wrapper parses the challenge, signs a USDC-on-Base EIP-3009 authorization, and retries with the proof, all before your await resolves.
The x402 protocol lets an HTTP server respond 402 Payment Required with a machine-readable challenge instead of an API key. @three-ws/x402-fetch is the client half: it is a function with the exact same signature as fetch, but when it receives a 402 it pays the challenge with USDC on Base and transparently retries. Your application code never sees the 402 — it just gets the 200 and the data.
It is built for autonomous agents, scripts, and apps that need to pay per request without a checkout flow, an SDK login, or a stored API key.
- Zero production dependencies. The secp256k1 / keccak256 / EIP-712 signing stack is inlined — no
viem,ethers, or@wagmi/corepulled into your bundle. - Drop-in
fetchsignature.withX402(wallet)returns something you can use anywherefetchworks. Non-402 responses pass through untouched. - Works in the browser and Node. MetaMask / any EIP-1193 provider, a viem account, or a raw private key — all accepted.
- Safe by default. A
maxPaymentUsdguard refuses to auto-pay more than you authorize, so a misconfigured or hostile server can never silently overcharge you. - Standards-correct signatures. Signs EIP-3009
transferWithAuthorizationtyped data — byte-for-byte what MetaMask'seth_signTypedData_v4and viem'ssignTypedDataemit, so the facilitator'secrecoverlands on your address. - Upstream-compatible. Ships a
wrapFetchWithPayment(fetch, wallet, options)alias matching the upstreamx402-fetchpackage, so it drops into existing x402 code. - Descriptive failures. Every error path throws a single, actionable
Error(over-limit, unsupported network, user-rejected, still-gated) instead of failing silently.
npm install @three-ws/x402-fetchRequires Node.js ≥ 18 (for global fetch and Web Crypto) or any modern browser. No native modules, no post-install scripts.
From zero to a paid request in under a minute. In Node, with a funded Base wallet:
import { withX402 } from '@three-ws/x402-fetch';
import { privateKeyToWallet } from '@three-ws/x402-fetch/wallet';
// 1. Wrap your wallet. This is the only setup step.
const pay = withX402(privateKeyToWallet(process.env.WALLET_PRIVATE_KEY), {
maxPaymentUsd: 0.10, // never auto-pay more than 10¢ per request
});
// 2. Call a paid endpoint exactly like fetch. The 402 is handled for you.
const res = await pay('https://api.example.com/paid', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ prompt: 'hello' }),
});
// 3. Use the unlocked response.
console.log(await res.json());In the browser, pass the injected provider instead:
import { withX402 } from '@three-ws/x402-fetch';
const pay = withX402(window.ethereum, { maxPaymentUsd: 0.10 });
const res = await pay('https://api.example.com/paid');That's the whole API surface for the common case. Everything below is reference detail.
When you call the wrapped fetch, it issues your request normally. If the response is not a 402, it is returned untouched. If it is a 402:
- The wrapper reads the challenge envelope — first from the base64
PAYMENT-REQUIREDresponse header (survives a consumed body and non-JSON content types), falling back to the JSON body. - It selects a payment requirement it can satisfy from
accepts[]— anexact-scheme, EIP-3009 USDC requirement on an EVM network, preferring Base mainnet. - It checks the price against
maxPaymentUsd. Over the limit, it throws and never signs. - It signs an EIP-3009
transferWithAuthorizationover the wallet, base64-encodes the x402 v2PaymentPayload, and retries the original request with that value in theX-PAYMENTheader. - The retried (
200) response is returned. If the server still answers 402, it throws.
sequenceDiagram
participant App
participant pay as withX402(fetch)
participant Server
participant Wallet
App->>pay: pay(url, init)
pay->>Server: fetch(url, init)
Server-->>pay: 402 + accepts[]
pay->>pay: select requirement, check maxPaymentUsd
pay->>Wallet: signTypedData(EIP-3009 auth)
Wallet-->>pay: signature
pay->>Server: fetch(url, init + X-PAYMENT)
Server-->>pay: 200 + data
pay-->>App: Response (200)
The full, exhaustive reference lives in docs/api.md. The summary:
The primary export (also the default export). Wraps a wallet into a fetch-compatible function that auto-pays x402 challenges.
const pay = withX402(wallet, options);It accepts three call conventions so both wallet-first and the upstream fetch-first shape work:
| Call | Meaning |
|---|---|
withX402(wallet, options?) |
Wallet-first (canonical). Uses the platform's global fetch. |
withX402(fetch, wallet) |
Fetch-first. Matches the upstream wrapFetchWithPayment(fetch, wallet) shape. |
withX402(fetch, { wallet, ...options }) |
Fetch-first with options bundled alongside the wallet. |
Parameters
| Name | Type | Default | Description |
|---|---|---|---|
wallet |
string | EIP1193Provider | { address, signTypedData } |
— | Required. The signer. See Wallet guide. |
options |
X402Options |
{} |
See Configuration. |
Returns — a typeof fetch function: (input, init?) => Promise<Response>. Call it exactly like fetch.
Throws — synchronously if no fetch implementation is available (no global fetch and none passed) or if wallet is null/undefined. The returned function rejects on the payment errors documented under Error handling.
Upstream-compatible alias. Always fetch-first. Equivalent to withX402(fetch, { wallet, ...options }).
| Name | Type | Default | Description |
|---|---|---|---|
fetch |
typeof fetch |
— | Required. The base fetch implementation to wrap. |
wallet |
Wallet |
— | Required. See Wallet guide. |
options |
X402Options |
undefined |
See Configuration. |
import { wrapFetchWithPayment } from '@three-ws/x402-fetch';
const pay = wrapFetchWithPayment(fetch, wallet, { maxPaymentUsd: 0.25 });Builds a Node signer from a raw private key using the inlined secp256k1 stack — no external wallet library. Available from the root export and the @three-ws/x402-fetch/wallet subpath (import the subpath if you only need the signer, e.g. in a worker).
| Name | Type | Default | Description |
|---|---|---|---|
pk |
string | Uint8Array |
— | 32-byte private key, as 0x-hex or raw bytes. |
Returns { address: string, signTypedData(typedData) => Promise<string> }. address is EIP-55 checksummed. signTypedData returns a 0x-prefixed 65-byte (r‖s‖v) signature.
Throws — x402: private key must be 32 bytes or x402: private key out of range for an invalid key.
import { privateKeyToWallet } from '@three-ws/x402-fetch/wallet';
const wallet = privateKeyToWallet('0x...');
console.log(wallet.address); // 0xAbC… (checksummed)withX402 accepts three wallet shapes. Each is normalized internally to { getAddress(), signTypedData(typedData) }.
A 0x-hex string (or Uint8Array). Signed locally with the inlined secp256k1 — nothing leaves the process. Best for servers, agents, and CI.
import { withX402 } from '@three-ws/x402-fetch';
import { privateKeyToWallet } from '@three-ws/x402-fetch/wallet';
const pay = withX402(privateKeyToWallet(process.env.WALLET_PRIVATE_KEY));You can also pass the raw key string straight to
withX402(process.env.WALLET_PRIVATE_KEY); it is converted to a signer for you. Wrapping it withprivateKeyToWalletfirst lets you read.addressup front.
Any object with a request() method — window.ethereum, MetaMask, Coinbase Wallet, WalletConnect, etc. The wrapper calls eth_requestAccounts to resolve the address and eth_signTypedData_v4 to sign; the user approves the signature in their wallet.
import { withX402 } from '@three-ws/x402-fetch';
const pay = withX402(window.ethereum);Any object exposing address (or account.address) and a signTypedData(typedData) method that returns a hex signature. A viem LocalAccount satisfies this directly.
import { withX402 } from '@three-ws/x402-fetch';
import { privateKeyToAccount } from 'viem/accounts';
const account = privateKeyToAccount(process.env.WALLET_PRIVATE_KEY);
const pay = withX402(account);Or hand-roll the shape against any KMS / HSM / remote signer:
const signer = {
address: '0xYourAddress',
async signTypedData(typedData) {
// typedData is { domain, types, primaryType, message } — sign it however you like
return await myRemoteSigner.signEip712(typedData);
},
};
const pay = withX402(signer);The
typedDatapassed to a custom signer is the standardeth_signTypedData_v4object:{ domain, types: { EIP712Domain, TransferWithAuthorization }, primaryType: 'TransferWithAuthorization', message }.
Options for withX402(wallet, options) / wrapFetchWithPayment(fetch, wallet, options):
| Option | Type | Default | Description |
|---|---|---|---|
maxPaymentUsd |
number |
0.10 |
Hard ceiling per request, in USD. A challenge above this throws before any signature is produced. |
onPayment |
(info) => void |
— | Called immediately before signing each payment. Receives { amount: number (USD), to: string (payTo), requestUrl: string }. Use it to log or audit spend. |
timeout |
number |
15000 |
Milliseconds to wait for the signature step before aborting with a timeout error. Set 0 to disable. |
network |
string |
— | Preferred CAIP-2 network id from accepts[] (e.g. "eip155:8453"). Also accepted as preferNetwork. When the server offers multiple payable requirements, this one wins if present; otherwise Base mainnet is preferred. |
There are no environment variables read by the library itself. WALLET_PRIVATE_KEY in the examples is your own convention — you read it and pass the value in. Nothing is read from process.env implicitly.
This wrapper signs locally for USDC via EIP-3009 transferWithAuthorization on EVM networks, and prefers Base mainnet.
| Network | CAIP-2 id | Chain id |
|---|---|---|
| Base (preferred) | eip155:8453 |
8453 |
| Base Sepolia (testnet) | eip155:84532 |
84532 |
| Arbitrum One | eip155:42161 |
42161 |
| Ethereum mainnet | eip155:1 |
1 |
| Optimism | eip155:10 |
10 |
Base USDC asset address: 0x833589fcd6edb6e08f4c7c32d4f71b54bda02913.
From the server's accepts[], the wrapper selects an exact-scheme requirement whose extra.assetTransferMethod is eip3009 (or unset). Permit2 (extra.assetTransferMethod === 'permit2'), Solana, and other non-EIP-3009 schemes are skipped — if accepts[] contains only those, the request throws (see below). Auth-hint placeholders (amount: "0" or extra.authRequired) are never treated as payable.
The library throws plain Error objects with a stable x402: message prefix. Catch them around the wrapped fetch call and branch on err.message.
| Message contains | When | What to do |
|---|---|---|
no supported network/asset was found in accepts[] |
The server's accepts[] had no EIP-3009 USDC (EVM) requirement. |
The endpoint wants a network/asset this wrapper can't sign (e.g. Solana, Permit2). Use a compatible facilitator. |
exceeds maxPaymentUsd limit |
The challenge price is above your maxPaymentUsd. |
Raise maxPaymentUsd if the price is acceptable. Nothing was signed. |
user rejected payment |
An EIP-1193 user cancelled the signature prompt (code 4001 / "user denied"). | Expected when the user declines. Surface a retry affordance. |
payment submitted but server still returned 402 |
The retry was still gated. | Usually an amount/recipient mismatch or an expired authorization; verify the facilitator and offer. |
payment authorization timed out after Nms |
Signing exceeded timeout. |
Raise timeout, or check the signer/provider. |
server returned 402 but no parseable payment challenge |
A 402 with no readable envelope. | The server isn't emitting a valid x402 challenge. |
a wallet is required / wallet object must expose an 'address' |
Misconfigured wallet argument. | Pass a valid wallet (see the Wallet guide). |
private key must be 32 bytes / private key out of range |
Bad key passed to privateKeyToWallet. |
Fix the key. |
try {
const res = await pay('https://api.example.com/paid');
const data = await res.json();
} catch (err) {
if (err.message.includes('exceeds maxPaymentUsd')) {
console.error('Too expensive — declined automatically.');
} else if (err.message.includes('user rejected payment')) {
console.error('User cancelled the signature.');
} else if (err.message.includes('no supported network/asset')) {
console.error('Endpoint wants an unsupported chain/asset.');
} else {
throw err; // re-throw anything unexpected
}
}All thrown messages begin with x402:. There are no custom error classes — branch on err.message.
- Your private key never leaves the process.
privateKeyToWalletsigns locally with the inlined secp256k1; nothing is sent over the network except the resulting signature and authorization inside theX-PAYMENTheader. - Always set
maxPaymentUsd. It defaults to$0.10, but set it explicitly to the most you'd ever pay for a single call. It is a hard pre-signature gate — an over-limit challenge throws and signs nothing, so a hostile or buggy server cannot drain the wallet one request at a time. - EIP-3009 authorizations are scoped. Each signature authorizes exactly the
value, recipient (payTo), and avalidBeforewindow (the challenge'smaxTimeoutSeconds, default 600s) with a fresh randomnonce— it is not an open-ended approval. - Keep keys out of source. Pass keys via environment variables or a secrets manager. Never commit them. For browsers, prefer an EIP-1193 provider so the user holds the key, not your page.
- Audit with
onPayment. Log every{ amount, to, requestUrl }to a durable sink so spend is reviewable. - Fund a hot wallet, not your treasury. Use a dedicated low-balance wallet for agent spending.
Does this support Solana / SOL / SPL tokens?
No. It signs EVM EIP-3009 USDC only (Base preferred). A Solana-only accepts[] throws no supported network/asset was found in accepts[].
The endpoint is paid but I get no parseable payment challenge.
The server returned a 402 without a valid x402 envelope (neither a PAYMENT-REQUIRED header nor a JSON body with accepts[]). It isn't x402-compliant, or it's returning HTML. Confirm the endpoint speaks x402 v2.
My payment is signed but the server still 402s.
You'll get payment submitted but server still returned 402. The signature is valid but the facilitator rejected it — typically because the on-chain value/payTo didn't match the offer, the authorization expired, or the wallet balance was insufficient. Verify the wallet is funded with USDC on the target chain and the facilitator is healthy.
Do I need viem or ethers? No. The package has zero runtime dependencies — secp256k1, keccak256, and EIP-712 are inlined. You can pass a viem account if you already use one (see the Wallet guide).
Does it work with Request objects, not just URL strings?
Yes. withX402(...) mirrors fetch, so the first argument can be a URL string or a Request. Headers from the original request are preserved on the retry; X-PAYMENT is added.
Can I use it in a Cloudflare Worker / edge runtime?
Yes, as long as the runtime has global fetch and Web Crypto (crypto.subtle) — both are present in Workers and Node ≥18. For private-key signing the runtime must expose crypto.subtle (used for the RFC-6979 HMAC).
How do I cap total spend across many calls, not just per call?
maxPaymentUsd is per request. For a running total, track it in onPayment and throw from your own code once a budget is exceeded.
Why does the price look tiny (e.g. 50000)?
The challenge amount is atomic units. With USDC's 6 decimals, 50000 = $0.05. The library converts it for you in onPayment.amount and the maxPaymentUsd check.
@three-ws/x402-server— the server half: gate any endpoint behind an x402 challenge and verify payments.@three-ws/x402-mcp— pay-per-call x402 for MCP (Model Context Protocol) tools.@three-ws/x402-payment-modal— a browser UI modal for x402 payment approval.- x402 protocol spec — the open standard this package implements.
docs/api.md— exhaustive API reference (every export, every option, every error).docs/examples.md— runnable examples: Node script, agent loop, viem integration, error handling.CONTRIBUTING.md— local dev, build, and test workflow.
All rights reserved. See LICENSE.