# x402 — Paid APIs + Agent Buyer Clients Sell paid APIs to AI agents and build agent buyer clients over HTTP 402, settled in USDC through the OpenZeppelin Channels facilitator. Companion to [SKILL.md](SKILL.md) (decision table, shared testnet setup, USDC addresses); the facilitator-free alternative lives in [mpp.md](mpp.md). ## When to use x402 x402 is the right choice when: - You want the fastest path to a paid API — minimal code, no contract deployment - You want clients (including AI agents) to pay with **zero XLM** — the OZ Channels facilitator sponsors all network fees - You're building on top of an existing x402 ecosystem (Coinbase, other chains) Trade-off: you depend on OZ Channels (or a self-hosted relayer) for verification and settlement. If you need zero third-party dependency, use MPP Charge ([mpp.md](mpp.md)) instead. ## How x402 works on Stellar ``` Client → GET /resource → Server Client ← 402 Payment Required (payment requirements) ← Server Client builds SAC USDC transfer Client signs auth entries only (not the full tx envelope) Client → GET /resource + X-PAYMENT header → Server Server → OZ Channels /verify + /settle → Stellar (~5s) Client ← 200 OK + resource ``` The key Stellar difference: clients sign **auth entries**, not full transaction envelopes. The facilitator assembles the transaction, pays fees, and submits. Clients need zero XLM. ## Seller: monetize an Express API ```bash npm install @x402/express @x402/core @x402/stellar express dotenv npm pkg set type=module ``` ```js // server.js import "dotenv/config"; import express from "express"; import { paymentMiddleware, x402ResourceServer } from "@x402/express"; import { HTTPFacilitatorClient } from "@x402/core/server"; import { ExactStellarScheme } from "@x402/stellar/exact/server"; // Drive the CAIP-2 network ID from one place. Switching to mainnet means // flipping STELLAR_NETWORK and FACILITATOR_URL in .env, nothing in code. const NETWORK = process.env.STELLAR_NETWORK || "stellar:testnet"; if (!process.env.OZ_API_KEY) { throw new Error( "OZ_API_KEY is required. Generate one at https://channels.openzeppelin.com/testnet/gen (testnet) or https://channels.openzeppelin.com/gen (mainnet)." ); } const facilitator = new HTTPFacilitatorClient({ url: process.env.FACILITATOR_URL ?? "https://channels.openzeppelin.com/x402/testnet", // OZ Channels requires Bearer auth on both testnet and mainnet createAuthHeaders: async () => { const h = { Authorization: `Bearer ${process.env.OZ_API_KEY}` }; return { verify: h, settle: h, supported: h }; }, }); const resourceServer = new x402ResourceServer(facilitator) .register(NETWORK, new ExactStellarScheme()); const app = express(); app.use( paymentMiddleware( { "GET /weather": { accepts: { scheme: "exact", price: "$0.001", // human-readable, auto-converts to 7-decimal USDC units network: NETWORK, payTo: process.env.STELLAR_RECIPIENT, // recipient G... account }, description: "Current weather data", }, }, resourceServer ) ); app.get("/weather", (_req, res) => { res.json({ city: "San Francisco", temp: 18, conditions: "Foggy" }); }); app.listen(3001, () => console.log(`x402 server on http://localhost:3001 (${NETWORK})`)); ``` **Env vars:** - `STELLAR_NETWORK` — CAIP-2 network ID; defaults to `stellar:testnet`. Set to `stellar:pubnet` for mainnet. - `STELLAR_RECIPIENT` — your G... address (receives USDC, needs a USDC trustline) - `OZ_API_KEY` — OZ Channels API key (**required on both testnet and mainnet**; generate at the link in the runbook below) - `FACILITATOR_URL` — defaults to testnet URL above; set to `https://channels.openzeppelin.com/x402` for mainnet **Price format options:** - `"$0.001"` — human-readable, auto-converts to 7-decimal USDC units - `{ amount: "1000", asset: "ASSET_SAC_CONTRACT_ID" }` — explicit base units for non-USDC assets **`payTo` is the recipient's classic Stellar account (`G...`), not the USDC SAC contract address.** Sending USDC lands in the classic balance of the `payTo` account, which is why that account also needs a USDC trustline. The SAC contract address is what the protocol invokes `transfer` on; see [Two USDC addresses](SKILL.md#two-usdc-addresses-dont-confuse-them) in the router. ## Buyer: agent client ```bash npm install @x402/fetch @x402/stellar dotenv npm pkg set type=module ``` ```js // client.js import "dotenv/config"; import { wrapFetchWithPaymentFromConfig } from "@x402/fetch"; import { createEd25519Signer } from "@x402/stellar"; import { ExactStellarScheme } from "@x402/stellar/exact/client"; const NETWORK = process.env.STELLAR_NETWORK || "stellar:testnet"; // createEd25519Signer takes the raw S... secret string and the CAIP-2 network ID. // Do NOT pre-wrap with Keypair.fromSecret or call getNetworkPassphrase yourself — // the signer does both internally. const signer = createEd25519Signer(process.env.STELLAR_SECRET_KEY, NETWORK); // wrapFetchWithPaymentFromConfig returns a fetch that handles 402 negotiation // and auth-entry signing transparently. const fetchWithPayment = wrapFetchWithPaymentFromConfig(fetch, { schemes: [{ network: NETWORK, client: new ExactStellarScheme(signer) }], }); const res = await fetchWithPayment("http://localhost:3001/weather"); console.log(await res.json()); // Paid automatically: 402 negotiation + auth-entry signing under the hood ``` **Env vars:** - `STELLAR_NETWORK` — CAIP-2 network ID; defaults to `stellar:testnet`. Must match the server's network. - `STELLAR_SECRET_KEY` — your S... secret key (needs USDC trustline + balance) **Browser frontends:** this client uses Node `fetch` and `createEd25519Signer`, both of which run in Node. A vanilla browser cannot sign contract auth entries through a typical wallet extension without additional glue. For a browser payer, run the x402 client server-side and expose a thin proxy endpoint to the page, or wire up Wallets-Kit / Freighter with custom auth-entry signing. ## Testnet runbook First complete the [shared testnet setup in SKILL.md](SKILL.md#testnet-setup-shared) — keypairs, XLM funding, USDC trustlines on **both** accounts, and testnet USDC from the Circle faucet (or run `setup.js` below). Then: 1. **Generate an OZ Channels testnet API key** ([channels.openzeppelin.com/testnet/gen](https://channels.openzeppelin.com/testnet/gen)). **Required, not optional.** Without it the server crashes at startup with `Failed to initialize: no supported payment kinds loaded from any facilitator`. 2. **Fill in `.env`** ``` STELLAR_NETWORK=stellar:testnet STELLAR_RECIPIENT=G... (recipient public key) STELLAR_SECRET_KEY=S... (payer secret key) OZ_API_KEY=... ``` 3. **Run it** ```bash node server.js # in another terminal node client.js ``` ### Optional: setup.js to automate the shared setup Drop this in your project and run once. It generates keys, friendbots, and adds USDC trustlines (the shared setup steps 1–3), then writes a starter `.env` so you only need to do the two manual web steps afterward. ```js // setup.js import fs from "fs/promises"; import { Keypair, Horizon, Networks, TransactionBuilder, Operation, Asset, BASE_FEE, } from "@stellar/stellar-sdk"; const USDC_ISSUER = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; const horizon = new Horizon.Server("https://horizon-testnet.stellar.org"); const friendbot = (addr) => fetch(`https://friendbot.stellar.org?addr=${addr}`); async function addTrustline(kp) { const acc = await horizon.loadAccount(kp.publicKey()); const tx = new TransactionBuilder(acc, { fee: BASE_FEE, networkPassphrase: Networks.TESTNET }) .addOperation(Operation.changeTrust({ asset: new Asset("USDC", USDC_ISSUER) })) .setTimeout(60).build(); tx.sign(kp); return horizon.submitTransaction(tx); } const recipient = Keypair.random(); const payer = Keypair.random(); await Promise.all([friendbot(recipient.publicKey()), friendbot(payer.publicKey())]); await new Promise(r => setTimeout(r, 2000)); await Promise.all([addTrustline(recipient), addTrustline(payer)]); await fs.writeFile(".env", `STELLAR_RECIPIENT=${recipient.publicKey()} STELLAR_SECRET_KEY=${payer.secret()} OZ_API_KEY= `); console.log(`Fund payer with USDC: https://faucet.circle.com → ${payer.publicKey()}`); console.log(`Get OZ key: https://channels.openzeppelin.com/testnet/gen → paste into OZ_API_KEY`); ``` ## Mainnet checklist | Config | Value | |--------|-------| | Network ID | `stellar:pubnet` | | RPC URL | Provider-specific endpoint (see [Stellar RPC providers directory](https://developers.stellar.org/docs/data/apis/rpc/providers)) | | Facilitator URL | `https://channels.openzeppelin.com/x402` | | USDC SAC | `USDC_PUBNET_ADDRESS` from `@x402/stellar` (currently `CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75`) | | OZ Channels API key | Required ([channels.openzeppelin.com/gen](https://channels.openzeppelin.com/gen)) | | Funding | Real USDC on mainnet (CEX, DEX, or bridge) | Always test on testnet first. To switch a working setup to mainnet, change only the `.env` (`STELLAR_NETWORK=stellar:pubnet`, `FACILITATOR_URL=https://channels.openzeppelin.com/x402`, mainnet `OZ_API_KEY`, and a mainnet `STELLAR_RECIPIENT`); the samples derive their network from `STELLAR_NETWORK`, so no code changes are needed. Both networks require an OZ Channels API key in the `Authorization: Bearer` header. ## Key concepts **Auth entry signing** — On Stellar, x402 clients sign contract authorization entries, not full transaction envelopes. The facilitator assembles the complete transaction. This is lighter than EVM/Solana signing, and means clients never need to manage sequence numbers or pay fees. **Fee sponsorship** — OZ Channels pays all Stellar network fees (~$0.00001/tx). Clients need a funded wallet with USDC but zero XLM. **`exact-v2` scheme** — The Stellar x402 scheme version. Server advertises `scheme: "exact"` + `x402Version: 2`. Don't mix v1 and v2 packages. **SAC (Stellar Asset Contract)** — USDC on Stellar is a classic asset wrapped in a smart contract. x402 payments invoke `transfer` on the SAC. Any SEP-41 token works; USDC is the default. **Ledger expiration** — Auth entries include a `max_ledger` bound. Use `latestLedger + 12` (~1 minute at 5s/ledger). Expired entries fail at settlement. **CAIP-2 network IDs** — `stellar:testnet` and `stellar:pubnet`. These are the exact strings the protocol expects. ## Common pitfalls **Auth entry expired on settle** - Symptom: facilitator returns `isValid: false`, error mentions ledger expiration - Fix: ensure client uses `latestLedger + 12` (or higher) as expiration; don't cache auth entries across requests **Wrong USDC decimal precision** - Symptom: payment amount off by 10x or 100x - Fix: Stellar USDC uses **7 decimal places** (not 6 like EVM USDC). `$0.001` = `10000` in base units. **V1/V2 package mismatch** - Symptom: TypeScript errors or silent payment failures - Fix: use all `@x402/*` packages at the same major version. V2 is multi-chain; don't import V1 `@x402/core` alongside V2 `@x402/stellar`. **Missing USDC trustline** - Symptom: `op_no_trust` error during settlement - Fix: add a USDC `changeTrust` operation before attempting any x402 payment (see testnet runbook above) **OZ Channels 401 on testnet or mainnet** - Symptom: facilitator rejects with 401, server logs `Failed to initialize: no supported payment kinds loaded from any facilitator` - Fix: an API key is required on **both** networks. Generate one at [channels.openzeppelin.com/testnet/gen](https://channels.openzeppelin.com/testnet/gen) (testnet) or [channels.openzeppelin.com/gen](https://channels.openzeppelin.com/gen) (mainnet), then set `OZ_API_KEY` and pass it via `createAuthHeaders` (see the Seller example). **Trustline missing on the recipient** - Symptom: `op_no_trust` during settlement, even though the client has USDC - Fix: the `payTo` account needs a USDC trustline too. The SAC `transfer` settles the underlying classic asset, which the recipient cannot hold without a trustline. Add `changeTrust` to both accounts during setup. **Trying to sign auth entries from a browser** - Symptom: bundling errors, or a browser wallet that has no API to sign contract auth entries - Fix: run the x402 client server-side (e.g. an Express route the browser calls), or use Wallets-Kit / Freighter with custom auth-entry signing. `@x402/fetch` + `createEd25519Signer` target Node and assume a raw secret key. **Passing a `Keypair` (or a network passphrase) to `createEd25519Signer`** - Symptom: `TypeError: encoded argument must be of type String`, or `Error: Unknown Stellar network: Test SDF Network ; September 2015` - Fix: the signer takes the raw `S...` secret string and a CAIP-2 network ID. Do **not** wrap with `Keypair.fromSecret` first, and do **not** pre-convert with `getNetworkPassphrase` — both are done internally. ```js // wrong const signer = createEd25519Signer(Keypair.fromSecret(s), getNetworkPassphrase("stellar:testnet")); // right const signer = createEd25519Signer(s, "stellar:testnet"); ```