How to add x402 payments to a Laravel API, step by step
By Requestway · · 7 min read
The requestway/laravel-x402 package lets you add x402 payments to a Laravel API with one middleware: an unpaid request gets 402 Payment Required with machine-readable terms, an agent signs a USDC payment and retries, and your controller runs once the payment checks out. This guide goes from composer require to a priced, tested route, then covers what to change before you charge real money.
What you need before you start
The requirements are short:
- PHP 8.2 or newer and Laravel 12, 13 or newer.
- A wallet address to receive payments. Any EVM address works. You need the address, not the private key: nothing is signed on your server.
- A facilitator URL. The default,
https://x402.org/facilitator, is free, needs no account and works on testnets only.
There is no blockchain node to run and no web3 PHP extension to install. The package checks what the payer signed and asks the facilitator to submit the transfer. Funds move directly from the payer's wallet to yours.
If the protocol itself is new to you, read what x402 is first. The short version: the server answers 402 with a PAYMENT-REQUIRED header, the client retries with a PAYMENT-SIGNATURE header, and the server responds 200 with a PAYMENT-RESPONSE receipt.
Step 1: install and configure the package
composer require requestway/laravel-x402
php artisan x402:install # publishes config, asks for wallet, network and facilitator
php artisan migrate # optional: records every payment
x402:install publishes config/x402.php and the migration, prompts for your wallet, network and facilitator, and validates the address format. The migration is optional, but a table of every payment attempt is the first thing you will want when something looks wrong, so run it.
Your .env ends up with something like this:
X402_PAY_TO=0xYourWalletAddress
X402_NETWORK=eip155:84532
X402_FACILITATOR_URL=https://x402.org/facilitator
eip155:84532 is Base Sepolia, the testnet. It is also the default, so an install you forgot to configure cannot move real money. Networks use CAIP-2 ids; Base mainnet is eip155:8453.
Step 2: protect a route
Add the middleware with a USD price:
use Illuminate\Support\Facades\Route;
Route::get('/market-data', MarketDataController::class)->middleware('x402:0.01');
A request without payment now gets a 402. The PAYMENT-REQUIRED header is base64-encoded JSON listing what the route accepts: the scheme (exact), the network, the amount in atomic units, the USDC contract, your payTo address, maxTimeoutSeconds, and an extra object with the token's EIP-712 name and version. USDC has 6 decimals, so 0.01 becomes 10000.
When the client retries with PAYMENT-SIGNATURE, the package validates the payload locally (amount, asset, payTo, network, resource, validity window, replay claim), asks the facilitator's /verify endpoint to confirm it, runs your controller, settles through /settle, and returns your response with a PAYMENT-RESPONSE header carrying the transaction hash.
The middleware takes options after the price:
Route::get('/report', ReportController::class)
->middleware('x402:0.01,description=Daily report,mime=text/csv,timeout=30');
| Option | What it does |
|---|---|
description= |
Text shown to agents and directories |
mime= |
The content type the client is buying |
network= / networks=a|b |
Charge on one network, or accept several |
pay_to= |
A different wallet for this route |
settle=after_response |
Per-route settlement mode (see step 5) |
timeout= |
The advertised maxTimeoutSeconds |
free-for-auth |
Signed-in users are not charged |
tags=a|b |
Discovery tags |
If you prefer, the same route can be declared with the fluent macro or an attribute:
Route::get('/report', ReportController::class)->paid('0.01', [
'description' => 'Daily report',
'mime_type' => 'text/csv',
]);
use Requestway\X402\Attributes\RequiresPayment;
class ReportController
{
#[RequiresPayment(price: '0.01', description: 'Daily report', mimeType: 'text/csv')]
public function show(): JsonResponse
{
// ...
}
}
// routes/api.php
Route::get('/report', [ReportController::class, 'show'])->middleware('x402');
Every paid route is also listed at GET /.well-known/x402, in the shape of the x402 discovery ("Bazaar") listing, so the catalogue cannot drift from what you actually charge.
Step 3: set prices that hold up
Prices are USD strings converted to atomic units with string arithmetic, never floats. Anything finer than one atomic unit rounds up, so rounding can never underprice a route.
Named tiers keep prices in one place. They live in config/x402.php:
'tiers' => [
'micro' => '0.001',
'standard' => '0.01',
'premium' => '0.10',
],
Route::get('/report', ReportController::class)->middleware('x402:premium');
When the price depends on the request, register a resolver. A closure that returns null falls back to the route's own price:
use Requestway\X402\Facades\X402;
X402::priceUsing(function (Request $request, RoutePayment $route): ?string {
return $request->boolean('full_history') ? '1.00' : null;
});
For anything you want to unit test on its own, implement Requestway\X402\Pricing\PriceResolver and register the class with X402::priceUsing(UsageBasedPrice::class). Its resolve(Request $request, RoutePayment $route) method returns the price or null.
To accept more than one network, set X402_ADDITIONAL_NETWORKS=eip155:84532,eip155:42161 alongside X402_NETWORK, or per route with networks=eip155:8453|eip155:42161. Every network appears in accepts and the client pays on whichever one it holds funds on. If you are still deciding what to charge, observe mode records what each route would have earned without charging anyone.
Step 4: test without a wallet or a chain
x402:test runs the full exchange against a fake facilitator. Nothing is charged:
php artisan x402:test /market-data
It prints the unpaid 402, the decoded requirements (resource, network, amount, pay-to address), then the paid 200 and its receipt. Add --underpay or --fail-settlement to watch the failure paths, and use php artisan x402:routes for a table of every paid route with its price and networks.
For your test suite, X402::fake() swaps in an in-memory facilitator and the MakesX402Payments trait builds a payment for your own routes:
use Requestway\X402\Facades\X402;
use Requestway\X402\Testing\MakesX402Payments;
uses(MakesX402Payments::class);
it('charges for the report', function () {
X402::fake();
$required = $this->getJson('/report')->assertStatus(402);
$this->getJson('/report', $this->x402Payment($required))->assertOk();
X402::assertSettledFor('/report', '0.01');
});
it('rejects an underpayment', function () {
X402::fake();
$required = $this->getJson('/report');
$this->getJson('/report', $this->x402Payment($required, amount: '1'))->assertStatus(402);
X402::assertNotCharged();
});
The fake can also reject at either stage, so you can test how your app behaves when a payment fails:
X402::fake()->rejectVerification(ErrorCode::InsufficientFunds);
X402::fake()->rejectSettlement(ErrorCode::InvalidTransactionState);
Once those pass, make one real payment with test USDC. The Base Sepolia testing guide walks through it.
Step 5: decide who pays, and when settlement happens
Not every caller should pay. Bypass rules serve a request free and add an X-X402-Bypass header naming the rule that matched, so "why wasn't this charged?" always has an answer:
- Signed-in users:
free-for-authon a route, orX402_FREE_FOR_AUTH=trueeverywhere. - IPs and CIDR ranges in
X402_BYPASS_IPS, for your own monitoring. - API keys in a header (
X402_API_KEY_HEADER, defaultX-API-Key, with keys inX402_API_KEYS), for customers who already pay you another way. - Anything else, with a closure:
X402::bypassUsing(fn (Request $request, RoutePayment $route): bool
=> $request->user()?->hasCredits() === true);
Then pick a settlement mode with X402_SETTLEMENT_MODE:
| Mode | Order | Trade-off |
|---|---|---|
before_response (default) |
verify, run controller, settle, respond | A failed settlement discards the response and returns 402. No paid response leaves without a committed payment. |
after_response |
verify, run controller, respond, settle | Faster, but a response already served can later fail to settle. Those fire PaymentFailed and are recorded as failed. |
Both modes verify before your controller runs. Start with before_response and only switch a route to after_response if you have measured the latency and accept the risk.
Step 6: listen to events and keep records
The package fires PaymentRequired, PaymentVerified, PaymentSettled, PaymentFailed and PaymentObserved. Each carries $event->context with the route label, amountUsd() and payer(). The one that deserves an alert is a failed settlement after the response was already sent:
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(),
]);
}
});
With the migration run, every attempt is a row in x402_payments, with the route, amount, network, transaction hash, status and failure reason:
use Requestway\X402\Models\X402Payment;
X402Payment::query()->settled()->forRoute('reports.show')->between($from, $to)->sum('amount_atomic');
X402Payment::query()->failed()->latest()->take(20)->get();
When payments fail, the failure_reason column holds the protocol error code, such as insufficient_funds or invalid_exact_evm_payload_authorization_value_mismatch for an underpayment. Why x402 payments fail explains each one.
Before you charge real money
Run php artisan x402:doctor. It checks your config, wallet format, assets, whether the facilitator is reachable and lists your network in /supported, the replay cache and the migrations. Then work through the essentials:
X402_PAY_TOis a mainnet wallet you control. Check every character: payments are irreversible.X402_NETWORK=eip155:8453for Base.- A production facilitator.
x402.orgwill not settle mainnet payments. Coinbase Developer Platform runs a managed one that needs per-request signed headers, which you supply through a class set inX402_FACILITATOR_AUTH. X402_REPLAY_STOREpoints at a cache shared by every web node, andX402_REPLAY_TTLis longer than the largestmaxTimeoutSecondsyou advertise.X402_MODE=enforce, and one small real payment made end to end.
The full list is in moving from testnet to mainnet.
Measuring what each route earns
The package records payments in your own database. If you would rather see earnings per route, failures grouped by stage and reason, and observed demand without writing queries, create a free Requestway project and set its key:
X402_PLATFORM_KEY=rw_live_your_key
Events are buffered per request and posted in one batch from a queued job after the response, so run a queue worker in production. Reporting never blocks or fails a request, and payer addresses are not sent. The install guide covers setup, and the endpoint tester pays a testnet route the way an agent would.
FAQ
Do I need a private key on my server to accept x402 payments in Laravel?
No. The server needs a receiving address in X402_PAY_TO, never a private key. The payer signs the transfer and the facilitator submits it.
Which Laravel and PHP versions does laravel-x402 support?
PHP 8.2 or newer, and Laravel 12, 13 or newer. It needs no blockchain node and no web3 extension.
What happens to my Laravel API if the x402 facilitator is down?
Paid routes return 502 and the resource is not served. Set X402_ON_FACILITATOR_ERROR=fail_open if you would rather serve it free during an outage.
Can an agent reuse one x402 payment to call my route twice?
No. Each payload is claimed in the cache before verification, and the token contract refuses to execute the same authorization twice. Use a cache shared by every web node so the claim holds across servers.
Can logged-in users use a paid route without paying?
Yes. Add free-for-auth to the route's middleware, or set X402_FREE_FOR_AUTH=true to exempt signed-in users everywhere. Those responses carry an X-X402-Bypass header.
Related articles
-
Production
A checklist for moving an x402 API from testnet to mainnet
7 min read
-
Testing
Testing x402 payments on Base Sepolia without spending real money
6 min read
-
Node.js
x402 with Express, Next.js and Hono: a practical guide
7 min read