Skip to main content
This guide shows the full x402 payment flow from a custom frontend using only viem. Your resource server owns pricing, builds the accepts challenge, and calls Meridian to settle the signed payment. The browser requests the resource, signs the selected payment requirement, and resubmits the signed payload. If you only need the shortest possible path on one network, start with the Quickstart. This guide adds multi-chain challenges, requirement selection, and error handling on top of it.

Prerequisites

  • A frontend with wallet connection (MetaMask, WalletConnect, etc.)
  • A protected resource endpoint that returns an x402 402 Payment Required challenge
  • A server-side Meridian API key for the resource server

Installation

npm install viem

Environment Setup

Keep the Meridian API key on your server. The browser only needs to know your resource URL.
# Browser-exposed client config
NEXT_PUBLIC_RESOURCE_URL=https://your-api.example.com/api/premium

# Server-side resource server config
MERIDIAN_API_KEY=pk_test_...

Client Implementation

1. Wallet Connection

import { createWalletClient, custom, type WalletClient } from "viem";
// Import the chain(s) your app supports, e.g. base, optimism, ink, ...
import { base } from "viem/chains";

const connectWallet = async () => {
  const ethereum = (window as any).ethereum;
  if (!ethereum) {
    throw new Error("Please install MetaMask or another wallet extension");
  }

  const accounts = (await ethereum.request({
    method: "eth_requestAccounts",
  })) as `0x${string}`[];

  if (accounts.length === 0) {
    throw new Error("No wallet account selected");
  }

  const account = accounts[0];
  const walletClient = createWalletClient({
    account,
    transport: custom(ethereum),
    chain: base, // the chain the buyer pays from
  });

  return { account, walletClient };
};

2. Request the Protected Resource

Request the resource without payment first. A 402 response carries the challenge with one payment requirement per accepted source chain.
interface PaymentRequirements {
  scheme: string;
  network: string;
  asset: string;
  payTo: string;
  maxAmountRequired: string;
  resource: string;
  description: string;
  mimeType: string;
  maxTimeoutSeconds: number;
  extra?: {
    name?: string;
    version?: string;
    creditedRecipient?: string;
    destinationChainId?: number;
  };
}

interface X402Challenge {
  x402Version: number;
  accepts: PaymentRequirements[];
  error?: string;
}

const RESOURCE_URL = process.env.NEXT_PUBLIC_RESOURCE_URL!;

const requestResource = async (
  walletClient: WalletClient,
  account: `0x${string}`,
) => {
  const response = await fetch(RESOURCE_URL);

  if (response.status === 402) {
    const challenge = (await response.json()) as X402Challenge;
    return payAndRetry(challenge, walletClient, account);
  }

  if (!response.ok) {
    throw new Error(`Resource request failed: ${response.status}`);
  }

  return response.json();
};

3. Select the Payment Requirement

Pick the accepts entry whose network matches the buyer’s connected chain. A requirement is source-chain specific — a Base entry cannot be paid from Ink, because the EIP-712 signature domain includes the source chain id and source token contract.
// Chain ids for the networks your app supports
const NETWORK_CHAIN_IDS: Record<string, number> = {
  "base-sepolia": 84532,
  base: 8453,
  ink: 57073,
  optimism: 10,
};

const selectRequirement = (
  accepts: PaymentRequirements[],
  connectedChainId: number,
): PaymentRequirements => {
  const match = accepts.find(
    (entry) => NETWORK_CHAIN_IDS[entry.network] === connectedChainId,
  );
  if (!match) {
    throw new Error("No payment route matches the connected wallet network");
  }
  return match;
};

4. Sign and Retry

Sign the EIP-3009 TransferWithAuthorization as typed data and resubmit the request with the signed payload. The verifying contract is the token (requirement.asset), not the facilitator, and authorization.to is the facilitator (requirement.payTo).
import { bytesToHex } from "viem";

const payAndRetry = async (
  challenge: X402Challenge,
  walletClient: WalletClient,
  account: `0x${string}`,
) => {
  const chainId = walletClient.chain!.id;
  const requirement = selectRequirement(challenge.accepts, chainId);

  const now = BigInt(Math.floor(Date.now() / 1000));
  const authorization = {
    from: account,
    to: requirement.payTo as `0x${string}`,
    value: BigInt(requirement.maxAmountRequired),
    validAfter: 0n,
    validBefore: now + BigInt(requirement.maxTimeoutSeconds),
    nonce: bytesToHex(crypto.getRandomValues(new Uint8Array(32))),
  };

  const signature = await walletClient.signTypedData({
    account,
    domain: {
      name: requirement.extra?.name ?? "USD Coin",
      version: requirement.extra?.version ?? "2",
      chainId,
      verifyingContract: requirement.asset as `0x${string}`,
    },
    types: {
      TransferWithAuthorization: [
        { name: "from", type: "address" },
        { name: "to", type: "address" },
        { name: "value", type: "uint256" },
        { name: "validAfter", type: "uint256" },
        { name: "validBefore", type: "uint256" },
        { name: "nonce", type: "bytes32" },
      ],
    },
    primaryType: "TransferWithAuthorization",
    message: authorization,
  });

  const paymentPayload = {
    x402Version: 1,
    scheme: "exact",
    network: requirement.network,
    payload: {
      signature,
      authorization: {
        from: authorization.from,
        to: authorization.to,
        value: authorization.value.toString(),
        validAfter: authorization.validAfter.toString(),
        validBefore: authorization.validBefore.toString(),
        nonce: authorization.nonce,
      },
    },
  };

  const response = await fetch(RESOURCE_URL, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ paymentPayload }),
  });

  if (response.status === 402) {
    const body = await response.json();
    throw new Error(body?.error ?? "Payment was rejected");
  }

  if (!response.ok) {
    throw new Error(`Paid resource request failed: ${response.status}`);
  }

  return response.json();
};
On chains whose payment token does not support EIP-3009 (MegaETH, BSC, BOT chain, Tempo), the buyer signs a Permit2 witness instead of TransferWithAuthorization. That is a separate payload shape and signing flow, not a small variation of the code above. See the non-EIP-3009 guide for the Permit2 implementation.

Server Responsibilities

Your resource server must not trust buyer-supplied pricing, recipient, network, or token data. Build or look up the matching paymentRequirements server-side, validate that the signed payload is bound to that requirement, then settle the buyer’s paymentPayload through Meridian:
const paymentRequirements = lookupRequirementForAcceptedNetwork(
  paymentPayload.network,
);

if (!paymentRequirements) {
  throw new Error("Unsupported payment network");
}

if (paymentPayload.network !== paymentRequirements.network) {
  throw new Error("Payment network does not match the server requirement");
}

if (
  paymentPayload.payload.authorization.to.toLowerCase() !==
  paymentRequirements.payTo.toLowerCase()
) {
  throw new Error("Payment must authorize the Meridian facilitator");
}

if (
  paymentPayload.payload.authorization.value !==
  paymentRequirements.maxAmountRequired
) {
  throw new Error("Payment amount does not match the server price");
}
const response = await fetch("https://api.mrdn.finance/v1/settle", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.MERIDIAN_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ paymentPayload, paymentRequirements }),
});

const result = await response.json();
if (!response.ok || !result.success) {
  throw new Error(result.errorReason ?? "Meridian settlement failed");
}
For multi-chain sellers, include one paymentRequirements object per source chain in accepts. Set network, asset, and payTo for the source chain, then set extra.destinationChainId to the destination chain where Meridian should settle after Across fills. Add extra.creditedRecipient when the payout should go to a specific merchant or marketplace recipient — see Marketplace Fees. The full request shape, including cross-chain and Permit2 payloads, is documented in Settle x402 Payment.