The Node quickstart's @x402/fetch wrapper hides the actual cryptography behind a clean call to wrapFetchWithPaymentFromConfig. Most integrations should stop there — it's the supported, maintained path. This post exists for the times you need to see what's underneath: debugging a 402 that won't clear, or building a client in an environment where pulling in the full @x402/evm package isn't an option and you need viem alone.
x402's exact scheme pays by authorizing a USDC transfer rather than sending one directly. EIP-3009 defines a transferWithAuthorization function that moves funds when presented with a valid signature, instead of requiring the sender's own transaction. That's the whole reason this is gasless from the signer's side: signing an EIP-712 typed-data message costs nothing and touches no chain. Whatever executes the actual transfer — a facilitator, in DimeBox's case — pays its own gas to submit it.
An EIP-712 signature is only valid for a specific domain — contract name, version, chain ID, the verifying contract's address — and for a specific message. Every one of those values is already sitting in the 402 response's decoded PAYMENT-REQUIRED header; pull them from there instead of hardcoding anything, because any of them changing out from under a client that assumed they were fixed is exactly the kind of failure that's hard to debug later:
const res = await fetch("https://dimebox.app/api/emails/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ recipient: "[email protected]", purpose: "follow up" }),
});
if (res.status !== 402) {
throw new Error("expected a 402 for this example");
}
const paymentRequired = JSON.parse(
Buffer.from(res.headers.get("PAYMENT-REQUIRED"), "base64").toString("utf8")
);
const { domain, authorization } = paymentRequired;
import { privateKeyToAccount } from "viem/accounts";
const account = privateKeyToAccount(process.env.EVM_PRIVATE_KEY);
const signature = await account.signTypedData({
domain,
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: authorization,
});
Those six fields are EIP-3009's own message shape, not anything DimeBox invented — the same fields show up signing against any EIP-3009-compliant token, which is what USDC on Base is for this purpose.
nonce in an EIP-3009 authorization is any unique 32-byte value the signer chooses — it doesn't need to be sequential, and it isn't tied to the signer's on-chain transaction count the way a regular nonce is. In practice, the decoded authorization object from the 402 response already includes one generated for this specific payment request, so you're signing over a value the server proposed rather than generating your own. If you were constructing an authorization from scratch rather than echoing back what a 402 response supplied, a cryptographically random 32 bytes is the safe default — reusing a nonce against the same contract is exactly what the field exists to prevent.
The other two message fields worth understanding before you sign anything are the authorization's validity window. validAfter and validBefore are Unix timestamps bounding exactly when the signed authorization can be redeemed — sign it, and the signature is worthless before validAfter and worthless again after validBefore, regardless of how cryptographically valid it otherwise is. A short window limits how long a leaked or intercepted signature stays usable; it's part of why x402 payments are scoped tightly to one call rather than behaving like a standing authorization. As with the nonce, the 402 response's decoded authorization object already carries the window the server expects — sign what it gave you rather than computing your own window and hoping it matches.
const payload = { ...authorization, signature };
const paymentSignature = Buffer.from(JSON.stringify(payload)).toString("base64");
const retry = await fetch("https://dimebox.app/api/emails/generate", {
method: "POST",
headers: {
"Content-Type": "application/json",
"PAYMENT-SIGNATURE": paymentSignature,
},
body: JSON.stringify({ recipient: "[email protected]", purpose: "follow up" }),
});
console.log(await retry.json());
The exact envelope x402 expects wrapped around that signature is defined by the protocol's own spec, not by DimeBox — check it directly for the authoritative shape before relying on anything beyond the signing step itself. ExactEvmScheme in the Node quickstart builds this same payload for you, correctly, which is the main argument for using it instead of this by hand in anything beyond a learning exercise.
Outside of debugging, the main case for writing this yourself is running in a constrained environment — a serverless function with a tight dependency budget, or a runtime @x402/evm doesn't target — where pulling the full client library costs more than writing the signing step directly against viem's primitives. x402's threat model is worth reading closely before shipping a hand-rolled signer anywhere near production; getting the message fields right and getting the security properties right are two different bars to clear.
Test any of this against Base Sepolia first — the full end-to-end testing flow covers what to check before moving a hand-rolled signer anywhere near mainnet funds.