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,
// Recommended:
// Mainnet: https://facilitator.radiustech.xyz (assetTransferMethod: "permit2")
// Testnet: https://facilitator.testnet.radiustech.xyz (assetTransferMethod: "permit2")
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: {
// Use "erc2612" instead if using Stablecoin.xyz
assetTransferMethod: 'permit2',
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 (Permit2, Radius facilitator):
{
"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": "permit2",
"name": "Stable Coin",
"version": "1"
}
}
]
}amount uses six-decimal precision. "100000" equals 0.1 SBC, and "100" equals 0.0001 SBC.
PAYMENT-REQUIRED header (EIP-2612, Stablecoin.xyz):
When using Stablecoin.xyz, set assetTransferMethod to "erc2612":
{
"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"
}
}
]
}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:
Radius (recommended)
| Detail | Value |
|---|---|
| URL (mainnet) | https://facilitator.radiustech.xyz |
| URL (testnet) | https://facilitator.testnet.radiustech.xyz |
| Networks | Radius mainnet ({MAINNET_CAIP2}), testnet ({TESTNET_CAIP2}) |
| Token | SBC via Permit2 with EIP-2612 gas sponsoring |
| Protocol | x402 v2 |
| Operator | Radius (first-party) |
The Radius facilitator uses Permit2-based settlement. Key differences from EIP-2612 facilitators:
- Settlement is atomic (one on-chain call, not two)
- The facilitator sponsors all gas costs
- The
assetTransferMethodin your 402 response must be"permit2", not"erc2612" - Integrators do not need to know the facilitator's internal wallet addresses
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
Middlebit
| Detail | Value |
|---|---|
| URL | https://middlebit.com |
| Networks | Radius mainnet (723487) |
| Token | SBC (routes through stablecoin.xyz) |
| Protocol | Middleware layer |
Choosing an asset transfer method
The assetTransferMethod in your 402 response accepts array must match the facilitator you use. Query the facilitator's GET /supported endpoint to confirm.
| Facilitator | assetTransferMethod | Settlement mechanism |
|---|---|---|
| Radius | permit2 | Permit2 + gas sponsoring (atomic, one tx) |
| Stablecoin.xyz | erc2612 | EIP-2612 permit + transferFrom (two txs) |
| Middlebit | erc2612 | Routes through Stablecoin.xyz (EIP-2612 under the hood) |
If you switch facilitators, update the assetTransferMethod to match. Otherwise, verification or settlement will fail.
What permit2 means for integrators
When using the Radius facilitator with assetTransferMethod: "permit2":
- The paying client signs a Permit2
SignatureTransferfor the canonicalx402ExactPermit2Proxy(0x402085c248EeA27D92E8b30b2C58ed07f9E20001), not for the facilitator itself - The facilitator calls the proxy contract, which atomically transfers tokens to the merchant address
- If the payer hasn't approved the Permit2 contract for SBC, the
eip2612GasSponsoringextension handles the approval gaslessly viasettleWithPermit() - You do not need to know the facilitator's pool-wallet addresses — the proxy contract enforces that funds go only to the
payToaddress
From the server side, the only code change versus an EIP-2612 facilitator is the assetTransferMethod value in your 402 response. The facilitator API (/verify, /settle) works identically.
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 can use either Permit2 or EIP-2612:
- Permit2 (used by the Radius facilitator): atomic settlement through the
x402ExactPermit2Proxycontract. One on-chain call, gas-sponsored. - EIP-2612 (used by Stablecoin.xyz): two-step
permit()+transferFrom(). 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 most integrations, use the Radius hosted facilitator — it handles Permit2 settlement and gas sponsoring automatically.
If you are building a self-hosted facilitator using EIP-2612 permit + transferFrom, 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 (permit2for Radius facilitator,erc2612for Stablecoin.xyz)- signature is valid and signer identity matches
payload.from - nonce and validity window checks pass
- payer balance is sufficient
- if using a self-hosted facilitator, confirm the settlement wallet has enough native gas balance (not needed with hosted facilitators that sponsor gas)
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.
The example below shows the EIP-2612 payload format (authorization object). When using the Radius facilitator with assetTransferMethod: "permit2", the client sends a permit2Authorization object instead, with different fields (permitted, spender, witness). See the x402 exact EVM scheme spec for the Permit2 payload structure.
{
"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 (Stablecoin.xyz)
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
The inline pattern uses EIP-2612 permit + transferFrom for direct on-chain settlement. It applies when you settle without a hosted facilitator. If you use the Radius hosted facilitator, you do not need this section.
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
- If self-hosting settlement, keep settlement wallets funded for native gas (hosted facilitators with gas sponsoring handle this automatically)
- Use short validity windows to reduce replay risk
- Add structured logs for verification failures and settlement outcomes
Troubleshooting
"No available wallets in pool"
This error from the facilitator /settle endpoint means the facilitator's internal pool of settlement wallets is temporarily exhausted. This is a facilitator operational issue, not a client-side or payer-wallet problem.
Retry after a brief delay (1-2 seconds). If persistent, contact the facilitator operator or switch to an alternate facilitator.
502 from facilitator
The facilitator service is unreachable. This does not mean the payment is invalid. Retry or fail over to an alternate facilitator URL.
"Permit expired"
The validBefore or deadline timestamp in the payment authorization has passed. The paying client needs to re-sign with a fresh deadline.