x402 integration
x402 is an HTTP-native payment protocol for paid APIs and paid content.
A protected endpoint returns 402 Payment Required with payment requirements.
The client signs payment data and retries with a PAYMENT-SIGNATURE header.
A facilitator verifies and settles the payment on Radius.
Radius is a strong fit for this model because fees are low and predictable.
What you can build with x402
- Per-request API billing: charge per call with immediate settlement
- Pay-per-visit content: charge for a single article, feed, or download
- Streaming payments: combine x402 with recurring payment loops for compute and inference workloads
Request lifecycle
- Client requests a protected resource.
- Server responds with
402 Payment Requiredand x402 v2 requirements. - Client signs payment data and retries with
PAYMENT-SIGNATURE. - Facilitator verifies and settles on Radius.
- Server returns
200 OKand serves the resource.
Minimal endpoint pattern
Define an X402Config interface to keep payment parameters consistent across endpoints:
interface X402Config {
asset: string;
network: string;
payTo: string;
facilitatorUrl: string;
amount: string;
facilitatorApiKey?: string;
}Use this Cloudflare Worker fetch handler to protect a resource with x402 v2:
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const config: X402Config = {
asset: env.SBC_TOKEN_ADDRESS,
network: env.X402_NETWORK,
payTo: env.MERCHANT_ADDRESS,
facilitatorUrl: env.FACILITATOR_URL,
amount: env.PAYMENT_AMOUNT,
facilitatorApiKey: env.FACILITATOR_API_KEY,
};
const paymentSig = request.headers.get('PAYMENT-SIGNATURE');
if (!paymentSig) {
const paymentRequired = {
x402Version: 2,
error: 'PAYMENT-SIGNATURE header is required',
resource: {
url: request.url,
description: 'Access to protected resource',
mimeType: 'application/json',
},
accepts: [
{
scheme: 'exact',
network: config.network,
amount: config.amount,
payTo: config.payTo,
asset: config.asset,
maxTimeoutSeconds: 300,
extra: {
assetTransferMethod: 'erc2612',
name: 'Stable Coin',
version: '1',
},
},
],
};
return new Response('{}', {
status: 402,
headers: {
'Content-Type': 'application/json',
'PAYMENT-REQUIRED': btoa(JSON.stringify(paymentRequired)),
},
});
}
const paymentPayload = JSON.parse(atob(paymentSig));
const paymentRequirements = {
scheme: 'exact',
network: config.network,
amount: config.amount,
asset: config.asset,
payTo: config.payTo,
maxTimeoutSeconds: 300,
extra: { name: 'Stable Coin', version: '1' },
};
const verifyRes = await fetch(`${config.facilitatorUrl}/verify`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(config.facilitatorApiKey ? { 'x-api-key': config.facilitatorApiKey } : {}),
},
body: JSON.stringify({ x402Version: 2, paymentPayload, paymentRequirements }),
});
if (!verifyRes.ok) {
const err = await verifyRes.json<{ error: string }>();
return new Response(JSON.stringify({ error: err.error ?? 'Verification failed' }), { status: 402, headers: { 'Content-Type': 'application/json' } });
}
const settleRes = await fetch(`${config.facilitatorUrl}/settle`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(config.facilitatorApiKey ? { 'x-api-key': config.facilitatorApiKey } : {}),
},
body: JSON.stringify({ x402Version: 2, paymentPayload, paymentRequirements }),
});
if (!settleRes.ok) {
const err = await settleRes.json<{ error: string }>();
return new Response(JSON.stringify({ error: err.error ?? 'Settlement failed' }), { status: 402, headers: { 'Content-Type': 'application/json' } });
}
const result = await settleRes.json<{ success: boolean; transaction: string; payer: string; network: string }>();
return new Response(JSON.stringify({ data: 'protected resource payload' }), {
status: 200,
headers: {
'Content-Type': 'application/json',
'PAYMENT-RESPONSE': btoa(JSON.stringify(result)),
},
});
},
};Sample 402 response
A compliant x402 v2 402 response carries a PAYMENT-REQUIRED header containing a Base64-encoded PaymentRequired object. The response body is empty or contains a human-readable message — all protocol data goes in the header.
HTTP/1.1 402 Payment Required
Content-Type: application/json
PAYMENT-REQUIRED: eyJ4NDAyVmVyc2lvbiI6MiwiZXJyb3IiOi...(base64)
{}PAYMENT-REQUIRED header:
{
"x402Version": 2,
"error": "PAYMENT-SIGNATURE header is required",
"resource": {
"url": "https://api.example.com/premium/report",
"description": "Access to /premium/report",
"mimeType": "application/json"
},
"accepts": [
{
"scheme": "exact",
"network": "eip155:723487",
"amount": "100000",
"payTo": "0x{{MERCHANT_ADDRESS}}",
"asset": "0x33ad9e4bd16b69b5bfded37d8b5d9ff9aba014fb",
"maxTimeoutSeconds": 300,
"extra": {
"assetTransferMethod": "erc2612",
"name": "Stable Coin",
"version": "1"
}
}
]
}amount uses six-decimal precision. "100000" equals 0.1 SBC, and "100" equals 0.0001 SBC.
Choose a facilitator model
Use a hosted facilitator when
- You need the fastest route to production
- You want lower operational overhead
- You want to validate demand before running settlement infrastructure
Build your own facilitator when
- You need custom policy, risk, or compliance checks
- You need custom settlement routing or treasury controls
- You need full ownership of keys, infrastructure, and observability
Endorsed facilitators
Three facilitators support Radius x402 integration today:
Stablecoin.xyz
| Detail | Value |
|---|---|
| URL | https://x402.stablecoin.xyz |
| Networks | Radius mainnet (723487), Radius testnet (72344) |
| Token | SBC via EIP-2612 |
| Protocol | x402 v1 + v2 |
- Stablecoin.xyz x402 overview
- Stablecoin.xyz x402 SDK documentation
- Stablecoin.xyz x402 facilitator documentation
FareSide
| Detail | Value |
|---|---|
| URL | https://facilitator.x402.rs |
| Networks | Radius testnet (72344) |
| Token | SBC via EIP-2612 |
| Protocol | x402 v2 |
| Cost | Free (testnet only) |
| Source | Open-source |
Middlebit
| Detail | Value |
|---|---|
| URL | https://middlebit.com |
| Networks | Radius mainnet (723487) |
| Token | SBC (routes through stablecoin.xyz) |
| Protocol | Middleware layer |
Token compatibility and settlement strategy
The token you settle with determines which x402 strategies are available.
EIP support overview
| Standard | USDC (FiatTokenV2_2) | SBC (Radius native) | Integration impact |
|---|---|---|---|
| EIP-20 | ✅ | ✅ | Standard token transfers are supported |
| EIP-712 | ✅ | ✅ | Typed-data signatures are supported |
EIP-2612 (permit) | ✅ | ✅ | Signature-based approvals are supported |
EIP-3009 (transferWithAuthorization) | ✅ | ❌ | One-transaction x402 settlement path is unavailable for SBC |
| EIP-1271 (contract-wallet signature validation) | ✅ | ❌ | Smart-account compatibility is reduced for signature-based payment flows |
| EIP-1967 (proxy slots) | ✅ | ✅ | No direct blocker for x402 flow design |
Why EIP-3009 matters
EIP-3009 enables transferWithAuthorization, which gives you:
- a single settlement transaction
- no long-lived allowance footprint
- concurrent authorization patterns with random nonces
- explicit validity windows (
validAfter,validBefore)
Without EIP-3009, a facilitator uses EIP-2612 in x402 v2:
- Call
permit()to grant allowance. - Call
transferFrom()to move funds.
This two-step path increases gas, adds latency, and introduces a non-atomic window.
Why EIP-1271 matters
EIP-1271 standardizes signature validation for smart contract wallets. Without it, wallet compatibility narrows for multisig and smart-account-heavy user bases.
Practical strategy on Radius with SBC
For SBC settlement flows, use a custom facilitator path based on EIP-2612 permit + transferFrom and implement strict controls:
- enforce nonce, amount, and expiry validation
- enforce idempotency keys and replay protection
- monitor settlement outcomes for partial or failed two-step execution paths
Validation checklist
Before settlement, validate all of the following:
schemematches expected value (for example,exact)networkmatches Radius (for example,eip155:723487oreip155:72344)assetmatches the accepted token contract (0x33ad9e4bd16b69b5bfded37d8b5d9ff9aba014fbfor SBC)payTomatches your merchant addressamountis greater than or equal to the required amount in six-decimal formatextra.assetTransferMethodmatches your expected transfer method (for example,erc2612)- signature is valid and signer identity matches
payload.from - nonce and validity window checks pass
- payer balance is sufficient
- settlement wallet has enough native gas balance
After settlement:
- wait for transaction receipt
- return deterministic success metadata
- store idempotency key and transaction hash for replay prevention
Payment payload structure (v2)
In x402 v2, the client sends a base64-encoded JSON payload in the PAYMENT-SIGNATURE header. The payload includes the protocol version, the accepted payment method, and the signed authorization:
{
"x402Version": 2,
"resource": {
"url": "https://api.example.com/premium/report",
"description": "Access to /premium/report",
"mimeType": "application/json"
},
"accepted": {
"scheme": "exact",
"network": "eip155:723487",
"amount": "100",
"asset": "0x33ad9e4bd16b69b5bfded37d8b5d9ff9aba014fb",
"payTo": "0x{{MERCHANT_ADDRESS}}",
"maxTimeoutSeconds": 300,
"extra": {
"assetTransferMethod": "erc2612",
"name": "Stable Coin",
"version": "1"
}
},
"payload": {
"signature": "0x...",
"authorization": {
"from": "0x{{PAYER_ADDRESS}}",
"to": "0x{{MERCHANT_ADDRESS}}",
"value": "100",
"validAfter": "0",
"validBefore": "115792089237316195423570985008687907853269984665640564039457584007913129639935",
"nonce": "0x..."
}
}
}The accepted object echoes the payment method the client selected from the accepts array. The authorization object contains the EIP-2612 permit fields. The signature is a single 65-byte hex string (not split into v, r, s components — the facilitator splits it when calling the on-chain permit() function).
The server decodes this directly as the paymentPayload and adds a paymentRequirements object from its own config before forwarding to /verify and /settle:
{
"x402Version": 2,
"paymentPayload": {
"x402Version": 2,
"resource": {
"url": "https://api.example.com/premium/report",
"description": "Access to /premium/report",
"mimeType": "application/json"
},
"accepted": {
"scheme": "exact",
"network": "eip155:723487",
"amount": "100",
"asset": "0x33ad9e4bd16b69b5bfded37d8b5d9ff9aba014fb",
"payTo": "0x{{MERCHANT_ADDRESS}}",
"maxTimeoutSeconds": 300,
"extra": {
"assetTransferMethod": "erc2612",
"name": "Stable Coin",
"version": "1"
}
},
"payload": {
"signature": "0x...",
"authorization": {
"from": "0x{{PAYER_ADDRESS}}",
"to": "0x{{MERCHANT_ADDRESS}}",
"value": "100",
"validAfter": "0",
"validBefore": "115792089237316195423570985008687907853269984665640564039457584007913129639935",
"nonce": "0x..."
}
}
},
"paymentRequirements": {
"scheme": "exact",
"network": "eip155:723487",
"amount": "100",
"asset": "0x33ad9e4bd16b69b5bfded37d8b5d9ff9aba014fb",
"payTo": "0x{{MERCHANT_ADDRESS}}",
"maxTimeoutSeconds": 300,
"extra": { "name": "Stable Coin", "version": "1" }
}
}See the x402 facilitator API for the full request and response schemas.
Facilitator settlement with EIP-2612
The x402 v2 authorization fields map to different parameter names in the EIP-2612 permit() function. This table shows the correspondence:
x402 authorization field | EIP-2612 permit() parameter | Description |
|---|---|---|
from | owner | Address that signed the permit and owns the tokens |
to | spender | Address authorized to transfer tokens (the settlement wallet) |
value | value | Amount in raw token units (no rename) |
validBefore | deadline | Unix timestamp after which the permit expires |
nonce | — | Used for replay protection; not passed to permit() directly (the contract tracks nonces internally) |
validAfter | — | Earliest valid timestamp; checked off-chain, not a permit() parameter |
This TypeScript example shows the EIP-2612 permit-based settlement flow that x402 v2 facilitators use on Radius:
import { createWalletClient, createPublicClient, http, type Address, type Hex } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
/** The decoded client payload from the PAYMENT-SIGNATURE header. */
interface X402ClientPayload {
payload: {
authorization: {
from: Address;
to: Address;
value: string;
validAfter: string;
validBefore: string;
nonce: string;
};
signature: Hex;
};
}
/** Input to the custom on-chain settlement function (for self-hosted facilitators, not the hosted facilitator request format). */
interface X402FacilitatorRequest {
payload: {
scheme: string;
from: Address;
to: Address;
value: string;
validAfter: string;
validBefore: string;
nonce: string;
};
requirements: {
tokenAddress: Address;
amount: string;
recipient: Address;
network: string;
};
signature: Hex;
}
/** Split a 65-byte hex signature into v, r, s for EVM permit calls. */
function splitSignature(sig: Hex): { v: number; r: Hex; s: Hex } {
const raw = sig.startsWith('0x') ? sig.slice(2) : sig;
return {
r: `0x${raw.slice(0, 64)}` as Hex,
s: `0x${raw.slice(64, 128)}` as Hex,
v: parseInt(raw.slice(128, 130), 16),
};
}
const SBC_ABI = [
{
name: 'permit',
type: 'function',
inputs: [
{ name: 'owner', type: 'address' },
{ name: 'spender', type: 'address' },
{ name: 'value', type: 'uint256' },
{ name: 'deadline', type: 'uint256' },
{ name: 'v', type: 'uint8' },
{ name: 'r', type: 'bytes32' },
{ name: 's', type: 'bytes32' },
],
outputs: [],
stateMutability: 'nonpayable',
},
{
name: 'transferFrom',
type: 'function',
inputs: [
{ name: 'from', type: 'address' },
{ name: 'to', type: 'address' },
{ name: 'value', type: 'uint256' },
],
outputs: [{ name: '', type: 'bool' }],
stateMutability: 'nonpayable',
},
] as const;
async function settlePayment(request: X402FacilitatorRequest, asset: Address, settlementKey: Hex, rpcUrl: string): Promise<{ payer: Address; txHash: Hex }> {
const publicClient = createPublicClient({ transport: http(rpcUrl) });
const account = privateKeyToAccount(settlementKey);
const walletClient = createWalletClient({
transport: http(rpcUrl),
account,
});
const { from, to, value, validBefore } = request.payload;
const deadline = BigInt(validBefore);
const amount = BigInt(value);
const now = BigInt(Math.floor(Date.now() / 1000));
if (deadline < now) {
throw new Error('Permit deadline has expired');
}
if (amount <= 0n) {
throw new Error('Payment value must be greater than zero');
}
// Split the single hex signature into v, r, s for the permit() call
const { v, r, s } = splitSignature(request.signature);
const permitHash = await walletClient.writeContract({
address: asset,
abi: SBC_ABI,
functionName: 'permit',
args: [from, to, amount, deadline, v, r, s],
});
await publicClient.waitForTransactionReceipt({ hash: permitHash });
const transferHash = await walletClient.writeContract({
address: asset,
abi: SBC_ABI,
functionName: 'transferFrom',
args: [from, to, amount],
});
const receipt = await publicClient.waitForTransactionReceipt({
hash: transferHash,
});
if (receipt.status !== 'success') {
throw new Error(`Settlement transaction reverted: ${transferHash}`);
}
return { payer: from, txHash: transferHash };
}Inline facilitator pattern
Instead of calling an external facilitator, you can settle x402 payments directly from your server using viem. This eliminates the facilitator as a dependency — your server decodes the payment, validates the permit, and submits transactions to Radius itself.
This pattern is a good fit when:
- You want zero external dependencies for settlement
- You need full control over the settlement wallet and transaction submission
- You're building a prototype or low-volume service where operational simplicity matters
- You want to avoid facilitator latency on the verify + settle round trips
Trade-offs compared to a hosted facilitator:
- You manage the settlement wallet — it must stay funded with RUSD for gas
- You handle replay protection — track nonces and idempotency keys yourself
- You absorb gas costs — the facilitator no longer pays on your behalf
Complete Cloudflare Worker example
This self-contained handler accepts x402 v2 payments, validates the permit, settles on-chain via viem, and serves the resource — no external facilitator involved:
import { createPublicClient, createWalletClient, http, type Address, type Hex } from 'viem';
import { privateKeyToAccount } from 'viem/accounts';
interface Env {
MERCHANT_ADDRESS: string;
SETTLEMENT_KEY: string; // Private key of the wallet that submits permit + transferFrom
}
const SBC_TOKEN: Address = '0x33ad9e4bd16b69b5bfded37d8b5d9ff9aba014fb';
const RADIUS_RPC = 'https://rpc.radiustech.xyz';
const NETWORK = 'eip155:723487';
const PRICE = '100'; // 0.0001 SBC per request
const SBC_ABI = [
{
name: 'permit',
type: 'function',
inputs: [
{ name: 'owner', type: 'address' },
{ name: 'spender', type: 'address' },
{ name: 'value', type: 'uint256' },
{ name: 'deadline', type: 'uint256' },
{ name: 'v', type: 'uint8' },
{ name: 'r', type: 'bytes32' },
{ name: 's', type: 'bytes32' },
],
outputs: [],
stateMutability: 'nonpayable',
},
{
name: 'transferFrom',
type: 'function',
inputs: [
{ name: 'from', type: 'address' },
{ name: 'to', type: 'address' },
{ name: 'value', type: 'uint256' },
],
outputs: [{ name: '', type: 'bool' }],
stateMutability: 'nonpayable',
},
] as const;
function splitSignature(sig: Hex): { v: number; r: Hex; s: Hex } {
const raw = sig.startsWith('0x') ? sig.slice(2) : sig;
return {
r: `0x${raw.slice(0, 64)}` as Hex,
s: `0x${raw.slice(64, 128)}` as Hex,
v: parseInt(raw.slice(128, 130), 16),
};
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const paymentHeader = request.headers.get('PAYMENT-SIGNATURE');
// No payment — return 402 with requirements
if (!paymentHeader) {
const paymentRequired = {
x402Version: 2,
error: 'PAYMENT-SIGNATURE header is required',
resource: {
url: request.url,
description: 'Access to paid endpoint',
mimeType: 'application/json',
},
accepts: [
{
scheme: 'exact',
network: NETWORK,
amount: PRICE,
payTo: env.MERCHANT_ADDRESS,
maxTimeoutSeconds: 300,
asset: SBC_TOKEN,
extra: {
assetTransferMethod: 'erc2612',
name: 'Stable Coin',
version: '1',
},
},
],
};
return new Response('{}', {
status: 402,
headers: {
'Content-Type': 'application/json',
'PAYMENT-REQUIRED': btoa(JSON.stringify(paymentRequired)),
},
});
}
// Decode the client payload (nested authorization + signature)
let clientPayload: any;
try {
clientPayload = JSON.parse(atob(paymentHeader));
} catch {
return Response.json({ error: 'Invalid payment header' }, { status: 400 });
}
const auth = clientPayload?.payload?.authorization;
const signature = clientPayload?.payload?.signature as Hex;
if (!auth || !signature) {
return Response.json({ error: 'Missing authorization or signature' }, { status: 400 });
}
// Validate fields
const deadline = BigInt(auth.validBefore);
const amount = BigInt(auth.value);
const now = BigInt(Math.floor(Date.now() / 1000));
if (deadline < now) {
return Response.json({ error: 'Permit expired' }, { status: 402 });
}
if (amount < BigInt(PRICE)) {
return Response.json({ error: 'Insufficient payment amount' }, { status: 402 });
}
// Settle directly on Radius — no external facilitator
const publicClient = createPublicClient({ transport: http(RADIUS_RPC) });
const account = privateKeyToAccount(env.SETTLEMENT_KEY as Hex);
const walletClient = createWalletClient({
transport: http(RADIUS_RPC),
account,
});
const { v, r, s } = splitSignature(signature);
try {
// Step 1: permit() — grant allowance from payer to settlement wallet
const permitHash = await walletClient.writeContract({
address: SBC_TOKEN,
abi: SBC_ABI,
functionName: 'permit',
args: [
auth.from as Address, // owner (authorization.from)
auth.to as Address, // spender (authorization.to)
amount, // value
deadline, // deadline (authorization.validBefore)
v,
r,
s,
],
});
await publicClient.waitForTransactionReceipt({ hash: permitHash });
// Step 2: transferFrom() — move tokens from payer to merchant
const transferHash = await walletClient.writeContract({
address: SBC_TOKEN,
abi: SBC_ABI,
functionName: 'transferFrom',
args: [auth.from as Address, env.MERCHANT_ADDRESS as Address, amount],
});
const receipt = await publicClient.waitForTransactionReceipt({ hash: transferHash });
if (receipt.status !== 'success') {
return Response.json({ error: 'Settlement reverted' }, { status: 502 });
}
// Payment settled — serve the resource
const paymentResponse = {
success: true,
transaction: transferHash,
network: NETWORK,
payer: auth.from,
};
return Response.json(
{ data: 'paid content' },
{
status: 200,
headers: {
'PAYMENT-RESPONSE': btoa(JSON.stringify(paymentResponse)),
},
},
);
} catch (e: any) {
return Response.json({ error: 'Settlement failed', detail: e.message }, { status: 502 });
}
},
};Key differences from the hosted facilitator approach:
- No
/verifyor/settlecalls — the server talks directly to the Radius RPC - The settlement wallet (
SETTLEMENT_KEY) submits both thepermit()andtransferFrom()transactions and pays gas - The
authorization.tofield must match the settlement wallet address (it's thespenderin the permit — see the field mapping table above) - Replay protection is your responsibility — in production, track processed nonces or payment hashes to prevent double-settlement
Implementation notes
- Configure clients and settlement services with Radius network settings
- Use Radius-compatible fee handling in transaction paths
- Keep settlement wallets funded for native gas
- Use short validity windows to reduce replay risk
- Add structured logs for verification failures and settlement outcomes