Skip to main content
Use this guide to integrate Meridian x402 payments on Solana, where the buyer pays USDC (an SPL token) and settlement runs through the Meridian meridian_x402 program. It is written for sellers, buyers, and AI coding agents implementing either side of the payment flow. This path applies to the solana (mainnet-beta) and solana-devnet networks. It is same-chain only — there is no Across cross-chain routing on Solana in this version. Do not use the EIP-3009 or Permit2 flows here: Solana has no EIP-712 signing, no Permit2, and no token approvals. The buyer signs the whole settlement transaction instead, and that signature is the authorization. The integration needs only @solana/web3.js and @solana/spl-tokenno Anchor, no IDL, and no on-chain config reads. Everything the buyer needs to build the transaction comes from a single API call: GET /v1/solana/facilitator.
The Solana program is live on both solana (mainnet) and solana-devnet. Do not hardcode the program ID. Read it (and everything else) from GET /v1/solana/facilitator at runtime so your integration keeps working across any redeploys. See Solana Program.

How the flow works

seller creates payment requirements (asset = USDC mint, payTo = recipient wallet)
buyer fetches GET /v1/solana/facilitator (fee payer, program id, mint, treasury, fees)
buyer builds a transfer_with_authorization transaction, fee payer = facilitator
buyer signs it once with their wallet (as USDC token authority)
buyer sends the base64 transaction to the seller in paymentPayload
seller settles through Meridian POST /v1/settle
Meridian validates, co-signs as fee payer, submits, and confirms on-chain
Meridian-specific rules:
  • paymentRequirements.asset is the USDC mint address, not a token account
  • paymentRequirements.payTo is the seller’s recipient wallet (the owner, not a token account); it must match the Solana recipient on your Meridian organization
  • paymentRequirements.network is solana or solana-devnet
  • paymentRequirements.extra.feePayer is the facilitator pubkey from GET /v1/solana/facilitator; it pays the transaction fee and must equal the on-chain config.backendSigner
  • paymentRequirements.extra.name is "USDC" and extra.decimals is 6
  • The buyer signs the full transaction; there is no typed-data signature, no token approval, and no Permit2
  • All USDC associated token accounts (payer, recipient, platform, treasury) must already exist — the settlement transaction never creates accounts
  • The buyer needs no SOL; the facilitator pays the transaction fee
  • The seller keeps the Meridian API key on the backend

Prerequisites

Seller:
  • A Meridian organization with a Solana recipient wallet configured
  • An organization API key from https://mrdn.finance/dev/api-keys
  • A backend endpoint that can receive paymentPayload from the buyer
Buyer:
  • A Solana wallet (Phantom, Solflare, Backpack, …)
  • A USDC balance on the selected network — on solana-devnet, get test USDC from Circle’s faucet at https://faucet.circle.com (select Solana Devnet)
  • An existing USDC associated token account (the recipient’s must exist too)
Install the client dependencies:
npm install @solana/web3.js @solana/spl-token

Network constants

export const MERIDIAN_API_BASE = "https://api.mrdn.finance/v1";

// The facilitator base URL that serves GET /v1/solana/facilitator and POST /v1/settle.
export const FACILITATOR_URL = "https://api.mrdn.finance";

export const USDC_DECIMALS = 6;

export const SOLANA_NETWORKS = {
  solana: {
    cluster: "mainnet-beta",
    rpcUrl: "https://api.mainnet-beta.solana.com",
    usdcMint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
  },
  "solana-devnet": {
    cluster: "devnet",
    rpcUrl: "https://api.devnet.solana.com",
    usdcMint: "4zMMC9srt5Ri5X14GAgXhaHii3GnPAEERYPJgZJDncDU",
  },
} as const;
The public RPC endpoints above are heavily rate-limited. Use a dedicated devnet/mainnet RPC (Helius, QuickNode, Triton, …) for anything beyond a quick test. The buyer only needs the RPC for a recent blockhash.

The facilitator info endpoint

One GET returns everything needed to build the settlement transaction — the fee-payer pubkey, program ID, config PDA, USDC mint, treasury token account, protocol fee, and pause state (read on-chain by the facilitator and cached):
export interface SolanaFacilitatorInfo {
  network: string;
  facilitator: string; // fee payer pubkey (== on-chain config.backendSigner)
  programId: string;
  configPda: string;
  usdcMint?: string; // enriched fields: present when the on-chain
  treasury?: string; //   config is readable (normal case)
  treasuryToken?: string; // treasury's USDC associated token account
  treasuryFeeBps?: number;
  paused?: boolean;
}

export async function getFacilitatorInfo(
  network: keyof typeof SOLANA_NETWORKS,
): Promise<SolanaFacilitatorInfo> {
  const res = await fetch(
    `${FACILITATOR_URL}/v1/solana/facilitator?network=${network}`,
    { cache: "no-store" },
  );
  if (!res.ok) {
    throw new Error(`facilitator info failed (${res.status})`);
  }
  return (await res.json()) as SolanaFacilitatorInfo;
}
The enriched fields (usdcMint, treasury, treasuryToken, treasuryFeeBps, paused) mirror the program’s on-chain Config account, so clients no longer read it themselves. If they are missing, the facilitator could not reach the chain — fail closed and retry rather than guessing values.

Seller setup

1. Create a Meridian API key

Seller-side setup starts in Meridian:
  1. Connect the seller wallet at https://mrdn.finance
  2. Configure your organization’s Solana recipient wallet
  3. Open https://mrdn.finance/dev/api-keys and create an organization-scoped key
  4. Store the public pk_... key on the server
Use the key in the Authorization header when calling Meridian:
const meridianHeaders = {
  Authorization: `Bearer ${process.env.MERIDIAN_API_KEY}`,
  "Content-Type": "application/json",
};

2. Build paymentRequirements

Use the USDC mint as asset and your recipient wallet as payTo. Fetch the fee-payer pubkey from the facilitator and put it in extra.feePayer.
const network = "solana-devnet";
const config = SOLANA_NETWORKS[network];
const amountInUsdcBaseUnits = "10000"; // 0.01 USDC (6 decimals)

const { facilitator } = await getFacilitatorInfo(network);

const paymentRequirements = {
  scheme: "exact",
  network,
  asset: config.usdcMint,
  payTo: process.env.SOLANA_RECIPIENT_WALLET, // owner wallet, not a token account
  maxAmountRequired: amountInUsdcBaseUnits,
  resource: "https://seller.example/api/tool",
  description: "Paid access to agent action",
  mimeType: "application/json",
  maxTimeoutSeconds: 600,
  extra: {
    name: "USDC",
    decimals: USDC_DECIMALS,
    feePayer: facilitator,
    creditedRecipient: process.env.SOLANA_RECIPIENT_WALLET,
  },
};
If you expose a standard HTTP 402 challenge, return:
return Response.json(
  { x402Version: 1, accepts: [paymentRequirements] },
  { status: 402, headers: { "Cache-Control": "no-store, max-age=0" } },
);

3. Receive paymentPayload

The buyer sends a base64-encoded, already-signed Solana transaction under payload.transaction. Build or retrieve the matching paymentRequirements server-side; do not trust buyer-supplied requirements for pricing, recipient, network, or asset.
{
  "x402Version": 1,
  "scheme": "exact",
  "network": "solana-devnet",
  "payload": {
    "transaction": "<base64 of the payer-signed settlement transaction>"
  }
}
Clients usually deliver this base64-encoded in an X-PAYMENT header; decode it before settling:
const header = request.headers.get("X-PAYMENT");
const paymentPayload = JSON.parse(Buffer.from(header, "base64").toString("utf8"));

4. Settle with Meridian

Call POST /v1/settle from the seller backend. Meridian validates the signed transaction, co-signs as fee payer, submits it, and confirms on-chain. Do not expose the Meridian API key to an untrusted client.
const response = await fetch(`${MERIDIAN_API_BASE}/settle`, {
  method: "POST",
  headers: meridianHeaders,
  body: JSON.stringify({ paymentPayload, paymentRequirements }),
});

const result = await response.json();

if (!response.ok || !result.success) {
  throw new Error(result.error ?? result.errorReason ?? "Meridian settlement failed");
}

// result.transaction is the confirmed Solana transaction signature.
const signature = result.transaction;

Buyer setup

1. Select a payment requirement and fetch the facilitator info

const requirements = accepts[0];
const network = requirements.network as keyof typeof SOLANA_NETWORKS;

const info = await getFacilitatorInfo(network);

// Sanity-check the 402 against the facilitator's authoritative config.
if (!info.usdcMint || !info.treasuryToken) {
  throw new Error("facilitator did not return on-chain config");
}
if (info.paused) throw new Error("Solana x402 program is paused");
if (requirements.extra.feePayer !== info.facilitator) {
  throw new Error("402 fee payer does not match the facilitator");
}
if (requirements.asset !== info.usdcMint) {
  throw new Error("402 asset does not match the USDC mint");
}
Because the info is fetched directly from the facilitator over HTTPS, it is the trust anchor for the payment — a tampered 402 from a malicious seller cannot redirect fees or swap the mint. (The paranoid can additionally verify the returned values against the on-chain Config PDA at info.configPda, but this is not required.)

2. Build the settlement transaction

Build one transfer_with_authorization instruction with plain @solana/web3.js — no Anchor, no IDL. This helper is self-contained; copy-paste it as-is:
import {
  getAssociatedTokenAddressSync,
  TOKEN_PROGRAM_ID,
} from "@solana/spl-token";
import { PublicKey, Transaction, TransactionInstruction } from "@solana/web3.js";

// transfer_with_authorization: anchor discriminator, stable across deployments.
const SETTLE_DISCRIMINATOR = Uint8Array.from([241, 208, 6, 43, 81, 61, 213, 10]);

export function buildSettleTransaction(params: {
  info: SolanaFacilitatorInfo;
  from: PublicKey; // buyer wallet
  recipient: PublicKey; // paymentRequirements.payTo (owner wallet)
  value: bigint; // USDC base units
  validAfter: bigint; // unix seconds, 0 = immediately valid
  validBefore: bigint; // unix seconds, e.g. now + maxTimeoutSeconds
  nonce: Uint8Array; // 32 random bytes
  recentBlockhash: string;
  platform?: PublicKey; // only in platform mode
  platformFeeBps?: number;
}): Transaction {
  const { info, from, recipient, recentBlockhash, platform } = params;
  if (!info.usdcMint || !info.treasuryToken) {
    throw new Error("facilitator did not return on-chain config");
  }
  if (info.paused) throw new Error("Solana settlement program is paused");
  if (params.nonce.length !== 32) throw new Error("nonce must be 32 bytes");

  const payer = new PublicKey(info.facilitator);
  const programId = new PublicKey(info.programId);
  const usdcMint = new PublicKey(info.usdcMint);

  // Instruction data: 66 bytes.
  const data = Buffer.alloc(66);
  data.set(SETTLE_DISCRIMINATOR, 0);
  data.writeBigUInt64LE(params.value, 8);
  data.writeBigInt64LE(params.validAfter, 16);
  data.writeBigInt64LE(params.validBefore, 24);
  data.set(params.nonce, 32);
  data.writeUInt16LE(params.platformFeeBps ?? 0, 64);

  // Recipient / platform / treasury owners may be off-curve (PDAs); the payer
  // signs, so their wallet must be a regular on-curve key.
  const instruction = new TransactionInstruction({
    programId,
    keys: [
      { pubkey: payer, isSigner: true, isWritable: true },
      { pubkey: from, isSigner: true, isWritable: false },
      { pubkey: new PublicKey(info.configPda), isSigner: false, isWritable: false },
      { pubkey: usdcMint, isSigner: false, isWritable: false },
      {
        pubkey: getAssociatedTokenAddressSync(usdcMint, from),
        isSigner: false,
        isWritable: true,
      },
      {
        pubkey: getAssociatedTokenAddressSync(usdcMint, recipient, true),
        isSigner: false,
        isWritable: true,
      },
      // Optional platform slot: Anchor expects the PROGRAM ID here as a
      // placeholder when there is no platform fee — always pass 9 accounts.
      platform
        ? {
            pubkey: getAssociatedTokenAddressSync(usdcMint, platform, true),
            isSigner: false,
            isWritable: true,
          }
        : { pubkey: programId, isSigner: false, isWritable: false },
      {
        pubkey: new PublicKey(info.treasuryToken),
        isSigner: false,
        isWritable: true,
      },
      { pubkey: TOKEN_PROGRAM_ID, isSigner: false, isWritable: false },
    ],
    data,
  });

  const tx = new Transaction({ feePayer: payer, recentBlockhash });
  tx.add(instruction);
  return tx;
}
Then use it:
import { Connection } from "@solana/web3.js";

const connection = new Connection(SOLANA_NETWORKS[network].rpcUrl, "confirmed");
const { blockhash } = await connection.getLatestBlockhash("confirmed");

const tx = buildSettleTransaction({
  info,
  from: walletPublicKey, // the connected buyer wallet
  recipient: new PublicKey(requirements.payTo),
  value: BigInt(requirements.maxAmountRequired),
  validAfter: 0n,
  validBefore: BigInt(Math.floor(Date.now() / 1000) + requirements.maxTimeoutSeconds),
  nonce: crypto.getRandomValues(new Uint8Array(32)),
  recentBlockhash: blockhash,
});
If the monorepo package is available to you (Meridian’s own apps), the same builder ships as buildSettleTransaction / fetchSolanaFacilitatorInfo from @meridian/x402/shared/svm — import it instead of copy-pasting.
Confirm the recipient’s USDC associated token account exists before paying — the settlement transaction does not create it. Getting a TokenAccountNotFoundError at settle time means the recipient (or platform) ATA is missing.

3. Sign the transaction with the buyer wallet

The buyer signs once, as the USDC token authority. The facilitator adds the fee payer’s signature later, so serialize without requiring all signatures.
const signed = await wallet.signTransaction(tx); // e.g. wallet-adapter

const transactionB64 = Buffer.from(
  signed.serialize({ requireAllSignatures: false, verifySignatures: false }),
).toString("base64");

4. Send paymentPayload to the seller

const paymentPayload = {
  x402Version: 1,
  scheme: "exact",
  network: requirements.network,
  payload: { transaction: transactionB64 },
};

const paymentHeader = Buffer.from(JSON.stringify(paymentPayload)).toString(
  "base64",
);

await fetch("https://seller.example/api/tool", {
  method: "GET",
  headers: { "X-PAYMENT": paymentHeader },
});

Instruction reference

For implementations in other languages, the layout (stable across deployments — it is derived from the instruction name):
discriminator  8 bytes   [241, 208, 6, 43, 81, 61, 213, 10]
value          u64 LE     amount in USDC base units
valid_after    i64 LE     unix seconds (0 = immediately valid)
valid_before   i64 LE     unix seconds (now + maxTimeoutSeconds)
nonce          32 bytes   random
platform_fee_bps u16 LE   0 unless in platform mode
                          → total data length = 66 bytes
Account order — always 9 accounts, all non-signer/read-only unless noted: payer (signer, writable), from (signer), config, usdc_mint, from_token (writable), recipient_token (writable), platform_token (writable — or the program ID as a read-only placeholder when there is no platform fee), treasury_token (writable), token_program.
Do not omit the platform_token slot when there is no platform fee. Anchor resolves optional accounts positionally: an 8-account list misaligns treasury_token and token_program and the facilitator rejects it. Pass the program ID in slot 6 as the “no platform” placeholder.

Checklist for AI agents

  • Read the seller’s existing route structure before adding payment handling
  • Keep settlement on the backend; never ship the Meridian API key to the client
  • Use network = "solana" or "solana-devnet"
  • Use asset = USDC mint address
  • Use payTo = the seller's recipient wallet (owner, not a token account), matching the org’s configured Solana recipient
  • Fetch everything (programId, facilitator, configPda, usdcMint, treasuryToken, paused) from GET /v1/solana/facilitator; hardcode nothing
  • Set extra.feePayer to the facilitator pubkey and tx.feePayer to it too
  • Check info.paused === false and that the 402’s feePayer/asset match the facilitator info before signing
  • Ensure the recipient (and platform) USDC associated token account exists first
  • Generate a fresh 32-byte nonce per payment
  • Always pass 9 accounts (program ID placeholder in the platform slot)
  • Have the buyer sign once and serialize with requireAllSignatures: false
  • Send payload.transaction as base64; keep amounts as decimal strings

Common mistakes

  • Using EIP-3009 typed data, Permit2, or a token approve on Solana — none apply
  • Setting asset to a token account instead of the USDC mint
  • Setting payTo to a token account instead of the recipient wallet owner
  • Hardcoding the program ID instead of reading it from the facilitator endpoint
  • Omitting the platform_token slot (8 accounts) instead of passing the program ID placeholder — the account list must always be 9 long
  • Setting tx.feePayer to the buyer wallet instead of the facilitator
  • Paying to a recipient whose USDC associated token account does not exist yet
  • Expecting the settlement transaction to create token accounts (it never does)
  • Serializing with requireAllSignatures: true before the facilitator co-signs
  • Reusing a nonce, or expecting cross-chain (Across) routing on Solana

References