The Node path to a working x402 payment looks different from Python's mainly because the tooling is higher-level by default — @x402/fetch wraps the entire 402-sign-retry cycle inside a drop-in replacement for fetch, rather than asking you to branch on status code 402 yourself. This post walks through getting that working against DimeBox, end to end.
npm install @x402/fetch @x402/evm viem
Pin these in your lockfile — @x402/[email protected] is the version DimeBox's own docs target as of this writing. If you're newer than that, check for breaking changes before assuming the snippets below still match the current API.
import { privateKeyToAccount } from "viem/accounts";
const account = privateKeyToAccount(process.env.EVM_PRIVATE_KEY);
Never commit that key. If you want to understand what actually happens when this account signs a payment, signing an EIP-3009 authorization in viem directly peels back what @x402/evm is doing for you here.
For anything past a one-off script, load EVM_PRIVATE_KEY from a .env file via dotenv rather than exporting it in a shell session you'll forget about, and make sure that file is in .gitignore before the first commit, not after. The same key that signs a two-cent email also signs anything else that account is authorized to spend — treat it with the same care you'd give a production database credential, not the care you'd give a test fixture.
import { wrapFetchWithPaymentFromConfig } from "@x402/fetch";
import { ExactEvmScheme } from "@x402/evm";
// eip155:84532 = Base Sepolia (testnet); eip155:8453 = Base mainnet
const fetchWithPayment = wrapFetchWithPaymentFromConfig(fetch, {
schemes: [{ network: "eip155:84532", client: new ExactEvmScheme(account) }],
});
See Base Sepolia vs Base mainnet for why you want to be running against the test network before any of this touches real USDC.
Provisioning is unauthenticated, so it never needs the payment-aware fetch at all — plain fetch is enough:
const provision = await fetch("https://dimebox.app/api/inboxes", { method: "POST" });
const { data, read_token } = await provision.json();
const slug = data.slug; // save read_token now - it's shown exactly once
Sending from that inbox is paid, so it goes through fetchWithPayment instead of bare fetch:
const res = await fetchWithPayment(`https://dimebox.app/api/inboxes/${slug}/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ to: "[email protected]", purpose: "follow up after our demo call" }),
});
console.log(res.status, await res.json()); // 202 - queued for delivery
fetchWithPayment handles the 402 transparently: it signs the authorization EIP-3009 describes, retries with the PAYMENT-SIGNATURE header attached, and only hands you back the final response. From the caller's side, a paid route and a free one look almost identical.
Before wiring this into anything unattended, check the signer's USDC balance directly with viem rather than discovering it's empty from a failed send halfway through a test run:
import { createPublicClient, http as viemHttp } from "viem";
import { baseSepolia } from "viem/chains";
const publicClient = createPublicClient({ chain: baseSepolia, transport: viemHttp() });
const balance = await publicClient.getBalance({ address: account.address });
console.log("native balance (for gas, if this signer ever needs it):", balance);
Checking the USDC token balance itself needs the token contract's address and ABI rather than the native-balance call above — pull both from wherever your own project already tracks them, since hardcoding a token address here is exactly the kind of assumption that breaks quietly if it ever changes. Testing the full flow on Sepolia covers getting a signer funded with test USDC in the first place.
If the signer can't cover the amount in the 402 response, fetchWithPayment fails rather than silently sending an unsigned request — treat that failure as distinct from an HTTP error on the underlying route. Catching it separately (rather than one broad try/catch around every fetch call) makes it obvious in logs whether a given failure was "the route rejected the request" or "the signer couldn't afford it," which otherwise look identical from the outside.
const inbox = await fetch(`https://dimebox.app/api/inboxes/${slug}/messages`, {
headers: { "X-Inbox-Token": read_token },
});
console.log(await inbox.json());
Reading never touches the payment wrapper at all — it's unmetered, authenticated by the token alone.
The only thing that changes between a test run and a production one is the network id passed into schemes and which key the signer is loaded from. Nothing about the request shapes, the endpoints, or the retry logic changes. Run the full cycle on Sepolia first before pointing a funded signer at mainnet — mistakes against a testnet cost nothing.
Full request and response shapes for every route are in the API reference; the quickstart section there is kept in sync with whatever package versions are current.