Are you an LLM? Read llms.txt for a summary of the docs, or llms-full.txt for the full context.
Skip to content

x402 integration

Accept HTTP payments on Radius
View as Markdown

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

Request lifecycle

  1. Client requests a protected resource.
  2. Server responds with 402 Payment Required and x402 v2 requirements.
  3. Client signs payment data and retries with PAYMENT-SIGNATURE.
  4. Facilitator verifies and settles on Radius.
  5. Server returns 200 OK and 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 response:
HTTP/1.1 402 Payment Required
Content-Type: application/json
PAYMENT-REQUIRED: eyJ4NDAyVmVyc2lvbiI6MiwiZXJyb3IiOi...(base64)
 
{}
Decoded 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

DetailValue
URLhttps://x402.stablecoin.xyz
NetworksRadius mainnet (723487), Radius testnet (72344)
TokenSBC via EIP-2612
Protocolx402 v1 + v2

FareSide

DetailValue
URLhttps://facilitator.x402.rs
NetworksRadius testnet (72344)
TokenSBC via EIP-2612
Protocolx402 v2
CostFree (testnet only)
SourceOpen-source

Middlebit

DetailValue
URLhttps://middlebit.com
NetworksRadius mainnet (723487)
TokenSBC (routes through stablecoin.xyz)
ProtocolMiddleware layer

Token compatibility and settlement strategy

The token you settle with determines which x402 strategies are available.

EIP support overview

StandardUSDC (FiatTokenV2_2)SBC (Radius native)Integration impact
EIP-20Standard token transfers are supported
EIP-712Typed-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:

  1. Call permit() to grant allowance.
  2. 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:

  • scheme matches expected value (for example, exact)
  • network matches Radius (for example, eip155:723487 or eip155:72344)
  • asset matches the accepted token contract (0x33ad9e4bd16b69b5bfded37d8b5d9ff9aba014fb for SBC)
  • payTo matches your merchant address
  • amount is greater than or equal to the required amount in six-decimal format
  • extra.assetTransferMethod matches 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 fieldEIP-2612 permit() parameterDescription
fromownerAddress that signed the permit and owns the tokens
tospenderAddress authorized to transfer tokens (the settlement wallet)
valuevalueAmount in raw token units (no rename)
validBeforedeadlineUnix timestamp after which the permit expires
nonceUsed for replay protection; not passed to permit() directly (the contract tracks nonces internally)
validAfterEarliest 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 /verify or /settle calls — the server talks directly to the Radius RPC
  • The settlement wallet (SETTLEMENT_KEY) submits both the permit() and transferFrom() transactions and pays gas
  • The authorization.to field must match the settlement wallet address (it's the spender in 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

Related pages