A checklist for moving an x402 API from testnet to mainnet
By Requestway · · 7 min read
Moving an x402 API from testnet to mainnet changes four lines of configuration and every consequence of getting them wrong. On Base Sepolia a mistake costs test USDC; on Base it sends real money to the wrong place, and payments are irreversible. This checklist covers what to change, what to verify before you flip the switch, and how to prove the first real payment worked.
The examples use requestway/laravel-x402, with Node equivalents where they differ. If you have not tested on a testnet yet, start with testing x402 payments on Base Sepolia and come back.
What actually changes between testnet and mainnet
Less than you might fear, but each item matters.
| Setting | Testnet (Base Sepolia) | Mainnet (Base) |
|---|---|---|
| Network (CAIP-2) | eip155:84532 |
eip155:8453 |
| USDC contract | 0x036CbD53842c5426634e7929541eC2318f3dCF7e |
0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913 |
| Facilitator | https://x402.org/facilitator (free, testnets only) |
a production facilitator, e.g. Coinbase Developer Platform, or self-hosted |
payTo wallet |
any address, often a throwaway | a wallet you control and have backed up |
| Explorer | sepolia.basescan.org | basescan.org |
| Consequence of a mistake | lost test USDC | lost real USDC |
Your code does not change. Routes, prices, middleware and tests stay as they are. What changes is configuration and the operational setup around it: caches, alerts and monitoring that were optional on testnet stop being optional.
The Laravel package defaults to Base Sepolia, so an unconfigured install cannot move real money. Going to mainnet is always an explicit act.
Step 1: prove the whole flow works on testnet first
Do not use mainnet to find bugs. Before changing anything:
php artisan x402:routes
php artisan x402:test /report
php artisan x402:test /report --underpay
php artisan x402:test /report --fail-settlement
x402:routes lists every paid route and its price, so you can confirm nothing is priced at a typo. x402:test runs the full 402, pay, 200 exchange against a fake facilitator, so nothing is charged. The --underpay run should end in a 402; the --fail-settlement run should end in a 402 with no response served (in the default settlement mode). Repeat for each paid route.
Then make at least one real payment on Base Sepolia with test USDC from faucet.circle.com, or run the Requestway endpoint tester against your staging URL, which signs a payment with test USDC and links the transaction.
Step 2: set up the receiving wallet
X402_PAY_TO is where every payment lands. The server needs the address only, never a private key, so the wallet itself can live wherever you keep funds safely.
- Use a wallet you control on Base mainnet, with its recovery material backed up.
- Do not reuse a testnet throwaway address whose key you never kept.
- Check the address character by character against the wallet. Copy-paste errors and clipboard tampering both exist, and a payment to the wrong address cannot be recovered.
If different routes should pay different wallets, the middleware takes pay_to= per route. Most APIs need one.
Step 3: switch network and facilitator together
These two settings must move at the same time. x402.org will not settle mainnet payments, so a mainnet network with the testnet facilitator fails every payment.
X402_MODE=enforce
X402_NETWORK=eip155:8453
X402_PAY_TO=0xYourMainnetAddress
X402_FACILITATOR_URL=https://api.cdp.coinbase.com/platform/v2/x402
X402_FACILITATOR_AUTH=App\Services\CdpAuth
Coinbase Developer Platform's facilitator needs per-request signed headers, which is what X402_FACILITATOR_AUTH is for: a class implementing Requestway\X402\Core\Facilitator\FacilitatorAuth whose headers(string $operation) returns the headers for verify, settle or supported. A facilitator that takes a static bearer token uses X402_FACILITATOR_TOKEN instead. See x402 facilitators explained for the trade-offs.
You do not need to set the USDC contract: the package ships the canonical addresses per network and picks the right one from X402_NETWORK.
If you accept testnet payments too, for example to keep a staging client working, make it explicit:
X402_ADDITIONAL_NETWORKS=eip155:84532
Both networks are then advertised in accepts and the client pays on whichever one it holds funds on. Leave it out in production unless you have a reason.
Node
With the official v2 packages, the network appears in two places: the scheme registration on the resource server and each route's accepts. Change both.
// Point the client at your production facilitator, with the authentication it requires.
const server = new x402ResourceServer(new HTTPFacilitatorClient({ url: 'https://api.cdp.coinbase.com/platform/v2/x402' }))
.register('eip155:8453', new ExactEvmScheme())
app.use(
paymentMiddleware(
{
'GET /premium-data': {
accepts: [{ scheme: 'exact', price: '$0.01', network: 'eip155:8453', payTo: '0xYourMainnetAddress' }],
description: 'Premium market data',
},
},
server,
),
)
Change one without the other and the route advertises a network the server has no scheme registered for.
Run the doctor and check /supported
In Laravel, run:
php artisan x402:doctor
It checks configuration, wallet format, assets, facilitator reachability including /supported, the replay cache and migrations. On mainnet it should pass with no failures. The /supported check matters most: if your production facilitator does not list the exact scheme on eip155:8453, nothing will verify.
Step 4: production plumbing
Four things that matter far more on mainnet than on a laptop.
A shared replay cache
The package claims each payment payload in the cache before verification, so the same signed payment cannot buy two responses. That only works if every web node sees the same claims. With an array or file store on several servers, each node keeps its own claims, so a replayed payload passes the first check on another node and only the facilitator and token contract stand in its way. x402:doctor warns about this.
X402_REPLAY_STORE=redis
X402_REPLAY_TTL=86400
X402_MAX_TIMEOUT_SECONDS=60
X402_REPLAY_STORE names a cache store shared by all nodes (redis, memcached, dynamodb or database). X402_REPLAY_TTL must be longer than the largest maxTimeoutSeconds any route advertises. The defaults (86400 and 60) satisfy that; check again if you raise timeout= on a route.
Payment records
Run the migration so every payment, failure and observation is stored:
php artisan vendor:publish --tag=x402-migrations
php artisan migrate
When someone says "I paid and got nothing", the x402_payments table with its transaction_hash, status and failure_reason columns is how you answer.
An alert for responses served without payment
Event::listen(PaymentFailed::class, function (PaymentFailed $event): void {
if ($event->servedWithoutPayment()) {
Log::critical('x402 response served without payment', [
'route' => $event->context->route->label(),
'amount' => $event->context->amountUsd(),
]);
}
});
servedWithoutPayment() can only be true in after_response settlement mode, where the client has its response before settlement runs. It is the one failure that needs a human, so route that log line somewhere a person sees it.
Choose the settlement mode on purpose
| Mode | Order | Trade-off |
|---|---|---|
before_response (default) |
verify, run controller, settle, respond | a failed settlement discards the response and returns 402; never serves unpaid |
after_response |
verify, run controller, respond, settle | faster response; a served response can later fail to settle |
On testnet the difference rarely shows. On mainnet it is a business decision: how much latency you will trade for the risk of occasionally serving a response that never settles. For expensive endpoints the default is the sensible choice. Set it with X402_SETTLEMENT_MODE or per route with settle=after_response.
Step 5: consider a week in observe mode
Before charging anyone, you can run the full mainnet configuration with nothing charged:
X402_MODE=observe
Every request that would have been charged is logged and recorded with status = observed, and the response carries X-X402-Would-Charge. A week of that tells you which routes agents actually hit and what you would have earned, with no risk. See observe mode for how to read the results. When you are ready, set X402_MODE=enforce.
Step 6: make one small real payment
The last check is a real payment, end to end, with a small amount:
- Fund a dedicated low-balance client wallet with a little USDC on Base.
- Call a paid route with an x402 client such as
@x402/fetch, configured foreip155:8453. - Decode the
PAYMENT-RESPONSEheader and look up thetransactionon basescan.org. - Confirm the USDC arrived in the
X402_PAY_TOwallet. - Confirm the payment row exists with
statussettled.
Only then announce the route. The Requestway endpoint tester will not do this step for you: it only pays on testnets, so on a mainnet-only route it validates the 402 and stops.
The checklist
- Every paid route passes
x402:test, including--underpayand--fail-settlement. -
X402_PAY_TOis a mainnet wallet you control, checked character by character. -
X402_NETWORK=eip155:8453(Node: bothregister()andaccepts). - A production facilitator is configured, with its authentication.
-
x402:doctorpasses, and/supportedlists your network. -
X402_REPLAY_STOREis shared by all web nodes. -
X402_REPLAY_TTLis longer than the largestmaxTimeoutSeconds. - The payments migration has run.
- A
PaymentFailedlistener alerts onservedWithoutPayment(). - Settlement mode chosen knowingly.
-
X402_MODE=enforce(after an optional observe week). - One small real payment seen in the wallet and on the explorer.
Keeping testnet and mainnet numbers apart
If you report to Requestway, use the project's rw_live_… key in production and rw_test_… on staging, so test traffic is kept apart. The dashboard also separates mainnet from testnet payments and follows the network your app last reported from, so after the switch you see real earnings, settled and failed counts, and failures by stage and reason without testnet noise mixed in. A free account is enough; the install guide covers the one environment variable for Laravel and the reporter for Node.
FAQ
Can I use the x402.org facilitator on mainnet?
No. It is free and needs no account, but it only supports testnets and will not settle mainnet payments. Use a production facilitator such as Coinbase Developer Platform's, or host your own.
Do I need a private key on my server to accept x402 payments on mainnet?
No. The server needs a receiving address in X402_PAY_TO, never a private key. Payers sign; the facilitator submits; funds go straight to your address.
Can I accept both Base and Base Sepolia from the same API?
Yes. Set X402_NETWORK=eip155:8453 and X402_ADDITIONAL_NETWORKS=eip155:84532, and both are advertised in accepts. Your facilitator has to support every network you advertise, and x402.org cannot settle the mainnet side.
What happens if I set the wrong payTo address on mainnet?
Payments go to that address and cannot be reversed. That is why the checklist says to compare the address character by character and to make one small real payment before announcing the route.
Why does my mainnet x402 payment fail with invalid_network?
The network in the payment does not match one the server or facilitator accepts. After a switch, check that the network changed everywhere it is set (in Node, both register() and accepts), then run x402:doctor and read the facilitator's /supported response.
Related articles
-
Laravel
How to add x402 payments to a Laravel API, step by step
7 min read
-
Testing
Testing x402 payments on Base Sepolia without spending real money
6 min read
-
Concepts
What x402 facilitators do and how to choose one
8 min read