How x402 Works: Implementing the Payment Handshake

Guides  ·  Payment

Every paid DocketLayer endpoint uses the x402 payment protocol. Payment is the authentication mechanism — there are no API keys. This guide covers the implementation: what the handshake looks like in code, how to construct a valid USDC payment transaction on Solana, and how to avoid the common mistakes.

The Three-Step Handshake

The x402 flow has three steps: probe, pay, and retry.

  1. Probe — Send the request without a payment header. The server returns 402 with payment requirements in the X-Payment-Requirements response header.
  2. Pay — Construct a signed USDC transaction on Solana for the required amount, wrap it in an x402 payment payload, and base64-encode it.
  3. Retry — Resend the original request with the encoded payment in the x402-payment header. The server verifies and responds with 200.

The entire handshake happens in the span of a single user-facing operation. Steps 1 and 3 are HTTP requests; step 2 is local — signing a transaction doesn't require a network call. End-to-end latency is dominated by the two HTTP round trips and the RPC call to fetch a recent blockhash.

Step 1: Probe the Endpoint

Send the request without any payment header. The server returns 402 Payment Required with an X-Payment-Requirements header containing the payment specification as JSON:

# Step 1: probe the endpoint — no payment header yet
curl -i "https://api.docketlayer.ai/v2/case?court_code=nysd&case_id=1:24-cv-09822"
import httpx, json

# Step 1: probe the endpoint
resp = httpx.get(
    "https://api.docketlayer.ai/v2/case",
    params={"court_code": "nysd", "case_id": "1:24-cv-09822"},
)
# Expect 402 — parse the payment requirements
assert resp.status_code == 402
requirements = json.loads(resp.headers["X-Payment-Requirements"])
print(requirements["payTo"])              # destination wallet address
print(requirements["maxAmountRequired"])  # "990000" ($0.99 in USDC base units)
print(requirements["network"])            # "solana-mainnet"
print(requirements["asset"])              # USDC mint address
// Step 1: probe the endpoint
const probe = await fetch(
  'https://api.docketlayer.ai/v2/case?court_code=nysd&case_id=1:24-cv-09822'
);
// Expect 402 — parse the payment requirements
const requirements = JSON.parse(probe.headers.get('X-Payment-Requirements'));
console.log(requirements.payTo);              // destination wallet address
console.log(requirements.maxAmountRequired);  // "990000" ($0.99 in USDC base units)
console.log(requirements.network);            // "solana-mainnet"
console.log(requirements.asset);              // USDC mint address

The key fields in the X-Payment-Requirements JSON:

  • payTo — the Solana wallet address to send USDC to
  • maxAmountRequired — the required amount in USDC base units (always "990000" for DocketLayer — $0.99 at 6 decimal places)
  • network — always "solana-mainnet"
  • asset — the USDC mint address (EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v)

You can skip the probe

If you already know the payment terms — and for DocketLayer they're always $0.99 USDC on Solana — you can build the payment transaction preemptively and skip the probe step. Include x402-payment on your first request. If the payment is valid, you'll get a 200 directly.

Step 2: Build the Payment Transaction

Construct a USDC SPL token transfer on Solana, sign it with your keypair, then wrap it in an x402 payment payload and base64-encode the result. This is the value you'll put in the x402-payment header.

from solders.keypair import Keypair
from solders.pubkey import Pubkey
from solana.rpc.api import Client
from solana.transaction import Transaction
from spl.token.instructions import transfer, TransferParams, get_associated_token_address
import base64, json

USDC_MINT     = Pubkey.from_string("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v")
TOKEN_PROGRAM = Pubkey.from_string("TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA")
USDC_DECIMALS = 6  # USDC uses 6 decimal places on Solana

def build_payment_tx(
    client: Client,
    payer: Keypair,
    receiver_address: str,
    amount_usd: float,
) -> str:
    receiver = Pubkey.from_string(receiver_address)

    # Convert to base units: $0.99 = 990_000
    amount    = int(amount_usd * 10 ** USDC_DECIMALS)
    blockhash = client.get_latest_blockhash().value.blockhash

    src_ata  = get_associated_token_address(payer.pubkey(), USDC_MINT)
    dest_ata = get_associated_token_address(receiver, USDC_MINT)

    ix = transfer(TransferParams(
        program_id=TOKEN_PROGRAM,
        source=src_ata,
        dest=dest_ata,
        owner=payer.pubkey(),
        amount=amount,
    ))

    tx = Transaction(recent_blockhash=blockhash, fee_payer=payer.pubkey())
    tx.add(ix)
    tx.sign(payer)

    # Serialize and base64-encode the transaction
    tx_base64 = base64.b64encode(bytes(tx)).decode()

    # Wrap in an x402 payment payload and base64-encode for the header
    payment_payload = {
        "x402Version": 1,
        "scheme": "exact",
        "network": "solana-mainnet",
        "payload": {"transaction": tx_base64},
    }
    return base64.b64encode(json.dumps(payment_payload).encode()).decode()
import {
  Connection,
  PublicKey,
  Transaction,
} from '@solana/web3.js';
import {
  getAssociatedTokenAddress,
  createTransferInstruction,
  TOKEN_PROGRAM_ID,
} from '@solana/spl-token';

const USDC_MINT     = new PublicKey('EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v');
const USDC_DECIMALS = 6; // USDC uses 6 decimal places on Solana

async function buildPaymentTx(
  connection: Connection,
  payer: Keypair,
  receiverAddress: string,
  amountUsd: number
): Promise<string> {
  const receiver = new PublicKey(receiverAddress);

  // Get associated token accounts for sender and receiver
  const senderAta   = await getAssociatedTokenAddress(USDC_MINT, payer.publicKey);
  const receiverAta = await getAssociatedTokenAddress(USDC_MINT, receiver);

  // Convert dollar amount to USDC base units (1 USDC = 1,000,000 units)
  const amount = BigInt(Math.round(amountUsd * 10 ** USDC_DECIMALS));

  const { blockhash } = await connection.getLatestBlockhash();

  const tx = new Transaction().add(
    createTransferInstruction(
      senderAta,
      receiverAta,
      payer.publicKey,
      amount,
      [],
      TOKEN_PROGRAM_ID
    )
  );

  tx.recentBlockhash = blockhash;
  tx.feePayer = payer.publicKey;
  tx.sign(payer);

  // Serialize and base64-encode the transaction
  const txBase64 = Buffer.from(tx.serialize()).toString('base64');

  // Wrap in an x402 payment payload and base64-encode for the header
  const paymentPayload = {
    x402Version: 1,
    scheme: 'exact',
    network: 'solana-mainnet',
    payload: { transaction: txBase64 },
  };
  return Buffer.from(JSON.stringify(paymentPayload)).toString('base64');
}

A few things to get right here:

  • Use a fresh blockhash. Solana uses recent blockhashes to bound transaction validity to a short window (~5 minutes). Fetch a new blockhash for each payment — don't cache one across multiple transactions.
  • USDC uses 6 decimal places. $0.99 in base units is 990000, not 99. The conversion is amount * 10^6.
  • The receiver ATA must exist. The DocketLayer receiver wallet has an established USDC associated token account. This is not an issue you'll encounter in practice, but it's why you can't send to an arbitrary new address.

Step 3: Retry with Payment

Resend the original request with the encoded payment payload in the x402-payment header. Putting it all together:

from solders.keypair import Keypair
from solana.rpc.api import Client
import httpx, json, os

client = Client("https://solana-mainnet.publicnode.com")
payer  = Keypair.from_base58_string(os.environ["WALLET_PRIVATE_KEY"])

court_code = "nysd"
case_id    = "1:24-cv-09822"
url        = "https://api.docketlayer.ai/v2/case"
params     = {"court_code": court_code, "case_id": case_id}

# Step 1: probe — no payment header
probe = httpx.get(url, params=params)
assert probe.status_code == 402

requirements     = json.loads(probe.headers["X-Payment-Requirements"])
receiver_address = requirements["payTo"]
amount_usd       = int(requirements["maxAmountRequired"]) / 10 ** 6  # 990000 → 0.99

# Step 2: build, sign, and encode
encoded_payment = build_payment_tx(client, payer, receiver_address, amount_usd)

# Step 3: retry with the payment header
response = httpx.get(url, params=params, headers={"x402-payment": encoded_payment})
response.raise_for_status()
print(response.json())
import { Connection, Keypair } from '@solana/web3.js';

const connection = new Connection('https://solana-mainnet.publicnode.com');

// Load your keypair (from env in production)
const secretKey = Uint8Array.from(JSON.parse(process.env.WALLET_SECRET_KEY!));
const payer = Keypair.fromSecretKey(secretKey);

// Step 1: probe — no payment header
const courtCode = 'nysd';
const caseId    = '1:24-cv-09822';
const url       = `https://api.docketlayer.ai/v2/case?court_code=${courtCode}&case_id=${encodeURIComponent(caseId)}`;

const probe = await fetch(url);

if (probe.status !== 402) {
  throw new Error(`Unexpected status: ${probe.status}`);
}

const requirements    = JSON.parse(probe.headers.get('X-Payment-Requirements')!);
const receiverAddress = requirements.payTo;
const amountUsd       = parseInt(requirements.maxAmountRequired) / 1e6; // 990000 → 0.99

// Step 2: build, sign, and encode
const encodedPayment = await buildPaymentTx(connection, payer, receiverAddress, amountUsd);

// Step 3: retry with the payment header
const response = await fetch(url, {
  headers: { 'x402-payment': encodedPayment },
});

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

const data = await response.json();
console.log(data);

On a successful payment, the server returns 200 with the query result. The transaction is broadcast to the Solana network asynchronously after the response is sent — you receive your data without waiting for on-chain confirmation.

Signatures expire in ~5 minutes

A Solana transaction signature is only valid for the lifetime of its blockhash — roughly five minutes. Don't build a transaction, cache the base64 string, and reuse it across requests. Generate a fresh transaction for each query.

Testing Without Real Funds

Use sandbox mode to validate your integration before connecting a funded wallet. Add ?test=1 to any request, or send the X-DocketLayer-Test: 1 header. The endpoint returns fixture data with no payment required.

# Sandbox mode — fixture data, no payment required
curl "https://api.docketlayer.ai/v2/case?court_code=nysd&case_id=1:24-cv-09822&test=1"
import httpx

# Sandbox via query parameter
resp = httpx.get(
    "https://api.docketlayer.ai/v2/case",
    params={"court_code": "nysd", "case_id": "1:24-cv-09822", "test": "1"},
)

# Sandbox via header (useful when the URL is constructed elsewhere)
resp = httpx.get(
    "https://api.docketlayer.ai/v2/case",
    params={"court_code": "nysd", "case_id": "1:24-cv-09822"},
    headers={"X-DocketLayer-Test": "1"},
)
// Sandbox via query parameter
const response = await fetch(
  'https://api.docketlayer.ai/v2/case?court_code=nysd&case_id=1:24-cv-09822&test=1'
);

// Sandbox via header (useful when the URL is constructed elsewhere)
const response = await fetch(url, {
  headers: { 'X-DocketLayer-Test': '1' },
});

Sandbox responses follow the same schema as live responses. Test the full shape of the data your application needs to handle before going live. For devnet wallet setup and CI integration, see the x402 testing guide.

Common Errors

402 on the retry request — The payment didn't verify. Check that the signed transaction targets the correct payTo address from the X-Payment-Requirements header, uses the correct USDC mint, and carries an amount of at least $0.99 in base units (990000). Also check that the blockhash isn't stale.

422 — The court you queried has coverage='planned' — see coverage status. No charge. Check GET /v2/status to verify the court is active before querying.

503 — The upstream court portal is temporarily unreachable. No charge. Retry with exponential backoff; court portal outages are typically short-lived.

Transaction simulation failure — The most common cause is insufficient USDC balance. Verify your wallet balance before querying. The second most common cause is using a token account that hasn't been initialized — this only affects fresh wallets that have never held USDC.