From stellar-dev
Implements x402 (paid APIs via OZ Channels) and MPP (Machine Payments Protocol) for AI-agent-to-agent payments on Stellar, supporting Charge and Channel modes with USDC.
How this skill is triggered — by the user, by Claude, or both
Slash command
/stellar-dev:agentic-payments [payment task][payment task]The summary Claude sees in its skill listing — used to decide when to auto-load this skill
Two complementary protocols for AI-agent and machine-to-machine payments on Stellar. Pick based on who depends on whom and how often the agent pays.
Two complementary protocols for AI-agent and machine-to-machine payments on Stellar. Pick based on who depends on whom and how often the agent pays.
| x402 | MPP Charge | MPP Channel | |
|---|---|---|---|
| Per-request on-chain tx? | Yes (via facilitator) | Yes (Soroban SAC) | No (off-chain commits) |
| Needs facilitator? | Yes (OZ Channels) | No | No |
| Client needs XLM? | No (fees sponsored) | Optional (feePayer) | Yes |
| Setup complexity | Low | Low | Medium (deploy contract first) |
| Best for | Quickest setup, fee-free clients | No third-party dep | High-frequency agents |
All protocols use USDC (SEP-41 SAC) by default; stellar:testnet / stellar:pubnet CAIP-2 network IDs.
../soroban/SKILL.md../assets/SKILL.md../dapp/SKILL.md../data/SKILL.md../standards/SKILL.mdx402 is the right choice when:
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 (Part 2 below) instead.
Client → GET /resource → Server
Client ← 402 Payment Required (payment requirements) ← Server
Client builds Soroban 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.
npm install @x402/express @x402/core @x402/stellar express dotenv
npm pkg set type=module
// 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 mainnetPrice 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 assetspayTo 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" below.
npm install @x402/fetch @x402/stellar dotenv
npm pkg set type=module
// 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 Soroban 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.
You need two Stellar testnet accounts: a client/payer (signs and pays from a USDC balance) and a server/recipient (the payTo in your route config). Both need a USDC trustline.
Two steps are web-only (Captcha or auth form) and cannot be scripted: the Circle USDC faucet and the OZ Channels key generator. Everything else can be automated. A complete setup.js sketch lives at the end of this section.
Generate two keypairs
node -e "const { Keypair } = require('@stellar/stellar-sdk'); for (const n of ['RECIPIENT','PAYER']) { const k = Keypair.random(); console.log(n, k.publicKey(), k.secret()); }"
Fund both with testnet XLM (friendbot)
curl "https://friendbot.stellar.org?addr=RECIPIENT_G..."
curl "https://friendbot.stellar.org?addr=PAYER_G..."
Add a USDC trustline to BOTH accounts — open Stellar Lab and add a USDC trustline to each G..., or run via SDK for each keypair:
import * as StellarSdk from "@stellar/stellar-sdk";
const horizon = new StellarSdk.Horizon.Server("https://horizon-testnet.stellar.org");
// Circle's classic USDC issuer on Stellar testnet
const USDC_ISSUER = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
async function addTrustline(secret) {
const kp = StellarSdk.Keypair.fromSecret(secret);
const acc = await horizon.loadAccount(kp.publicKey());
const tx = new StellarSdk.TransactionBuilder(acc, {
fee: StellarSdk.BASE_FEE,
networkPassphrase: StellarSdk.Networks.TESTNET,
})
.addOperation(StellarSdk.Operation.changeTrust({
asset: new StellarSdk.Asset("USDC", USDC_ISSUER),
}))
.setTimeout(60)
.build();
tx.sign(kp);
return horizon.submitTransaction(tx);
}
// Repeat for both the recipient secret and the payer secret.
await addTrustline(process.env.RECIPIENT_SECRET);
await addTrustline(process.env.PAYER_SECRET);
Without a trustline on the recipient, the SAC transfer settles into nothing and the request fails with op_no_trust.
Fund the PAYER with testnet USDC — open the Circle testnet faucet, select Stellar testnet, paste the payer's G.... Web Captcha; no API.
Generate an OZ Channels testnet API key (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.
Fill in .env
STELLAR_NETWORK=stellar:testnet
STELLAR_RECIPIENT=G... (recipient public key)
STELLAR_SECRET_KEY=S... (payer secret key)
OZ_API_KEY=...
Run it
node server.js
# in another terminal
node client.js
Drop this in your project and run once. It generates keys, friendbots, and adds USDC trustlines, then writes a starter .env so you only need to do the two manual web steps afterward.
// 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`);
USDC on Stellar has two addresses, used in different places. Mixing them up is a common stumble.
| Address | Format | Used for |
|---|---|---|
| Classic asset issuer | G... (32-byte ed25519 public key) | The issuer of the classic USDC asset; used when adding a trustline (new Asset("USDC", G...)) |
| SAC (Soroban Asset Contract) | C... (32-byte contract address) | The Soroban contract the protocol invokes transfer on; used in payment requirements |
Use the exported constants instead of hard-coding when possible:
import { USDC_TESTNET_ADDRESS, USDC_PUBNET_ADDRESS } from "@x402/stellar";
// USDC_TESTNET_ADDRESS = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA"
// USDC_PUBNET_ADDRESS = "CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75"
payTo in your route config is always a classic recipient account (G...). The SAC address only appears if you set a custom asset in the price config for a non-USDC token.
| Config | Value |
|---|---|
| Network ID | stellar:pubnet |
| RPC URL | Provider-specific endpoint (see Stellar RPC providers directory) |
| 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) |
| 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.
Auth entry signing — On Stellar, x402 clients sign Soroban 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 Soroban 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.
Auth entry expired on settle
isValid: false, error mentions ledger expirationlatestLedger + 12 (or higher) as expiration; don't cache auth entries across requestsWrong USDC decimal precision
$0.001 = 10000 in base units.V1/V2 package mismatch
@x402/* packages at the same major version. V2 is multi-chain; don't import V1 @x402/core alongside V2 @x402/stellar.Missing USDC trustline
op_no_trust error during settlementchangeTrust operation before attempting any x402 payment (see testnet runbook above)OZ Channels 401 on testnet or mainnet
Failed to initialize: no supported payment kinds loaded from any facilitatorOZ_API_KEY and pass it via createAuthHeaders (see the Seller example).Trustline missing on the recipient
op_no_trust during settlement, even though the client has USDCpayTo 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
@x402/fetch + createEd25519Signer target Node and assume a raw secret key.Passing a Keypair (or a network passphrase) to createEd25519Signer
TypeError: encoded argument must be of type String, or Error: Unknown Stellar network: Test SDF Network ; September 2015S... 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.
// wrong
const signer = createEd25519Signer(Keypair.fromSecret(s), getNetworkPassphrase("stellar:testnet"));
// right
const signer = createEd25519Signer(s, "stellar:testnet");
MPP is the right choice when:
Two modes:
| Mode | On-chain txs | Best for |
|---|---|---|
| Charge | One per request | Per-request payments, no pre-funding required |
| Channel | One deposit + one close | High-frequency agents (100s of requests/session) |
If you need zero-XLM clients or the simplest possible setup, use x402 (Part 1 above) instead.
Each request triggers a Soroban SAC token transfer settled on-chain. No facilitator. Server can optionally sponsor fees so clients don't need XLM.
npm install express @stellar/mpp mppx @stellar/stellar-sdk dotenv
npm pkg set type=module
Server:
// charge-server.js
import express from "express";
import { Mppx } from "mppx";
import * as stellar from "@stellar/mpp/charge/server";
import * as StellarSdk from "@stellar/stellar-sdk";
const USDC_SAC_TESTNET = "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA";
const RECIPIENT = process.env.STELLAR_RECIPIENT; // G... address
const mppx = Mppx.create({
secretKey: process.env.MPP_SECRET_KEY, // shared secret for credential verification
methods: [
stellar.charge({
recipient: RECIPIENT,
currency: USDC_SAC_TESTNET,
network: "stellar:testnet",
// optional: server pays network fees so clients don't need XLM
feePayer: process.env.FEE_PAYER_SECRET
? { envelopeSigner: StellarSdk.Keypair.fromSecret(process.env.FEE_PAYER_SECRET) }
: undefined,
}),
],
});
const app = express();
app.use(express.json());
// mppx middleware: returns 402 with challenge, then validates payment on retry
app.use(mppx.middleware());
app.get("/data", (req, res) => {
res.json({ result: "paid content", price: "$0.001 USDC" });
});
app.listen(3002, () => console.log("MPP charge server on http://localhost:3002"));
Client:
// charge-client.js
import { Mppx } from "mppx";
import * as stellar from "@stellar/mpp/charge/client";
import * as StellarSdk from "@stellar/stellar-sdk";
const keypair = StellarSdk.Keypair.fromSecret(process.env.STELLAR_SECRET_KEY);
const mppx = Mppx.create({
methods: [
stellar.charge({
keypair,
mode: "pull", // server assembles and broadcasts the transaction
onProgress(event) {
// event.type: "challenge" | "signed" | "settled"
if (event.type === "settled") console.log("Settled:", event.txHash);
},
}),
],
});
// mppx wraps fetch — 402 handling is transparent
const res = await mppx.fetch("http://localhost:3002/data");
console.log(await res.json());
Env vars (server): STELLAR_RECIPIENT, MPP_SECRET_KEY, FEE_PAYER_SECRET (optional)
Env vars (client): STELLAR_SECRET_KEY
mode: "pull" vs "push":
"pull" — client signs auth entries, server assembles + broadcasts (default; use with feePayer)"push" — client builds and broadcasts the transaction directly (client must have XLM for fees)The client deploys a one-way payment channel contract, deposits USDC once, then signs cumulative commitments off-chain for each request. No transaction per request — only two on-chain txs total (deposit + close). Ideal for AI agents making hundreds of calls in a session.
1. Deploy channel contract (one-time) → C... contract address
2. Client deposits USDC into channel → on-chain tx
3. Per request: client signs commitment → off-chain (just a signature)
Amount is cumulative: each sig covers all previous payments + this one
4. Server closes channel when done → on-chain tx, settles total
C... contract address// channel-server.js
import express from "express";
import { Mppx, Store } from "mppx";
import * as stellar from "@stellar/mpp/channel/server";
const mppx = Mppx.create({
secretKey: process.env.MPP_SECRET_KEY,
methods: [
stellar.channel({
channel: process.env.CHANNEL_CONTRACT, // C... contract address
commitmentKey: process.env.COMMITMENT_PUBKEY, // 64-char hex ed25519 public key
store: Store.memory(), // dev only — use persistent store in production
network: "stellar:testnet",
}),
],
});
const app = express();
app.use(express.json());
app.use(mppx.middleware());
app.get("/data", (req, res) => {
res.json({ result: "paid content" });
});
app.listen(3003);
// channel-client.js
import { Mppx } from "mppx";
import * as stellar from "@stellar/mpp/channel/client";
import * as StellarSdk from "@stellar/stellar-sdk";
// commitment key must be a raw ed25519 seed — NOT a standard Stellar secret key
const commitmentKey = StellarSdk.Keypair.fromRawEd25519Seed(
Buffer.from(process.env.COMMITMENT_SECRET, "hex") // 64-char hex secret
);
const mppx = Mppx.create({
methods: [
stellar.channel({
commitmentKey,
onProgress(event) {
// event.type: "challenge" | "signed"
},
}),
],
});
// Make many requests — each signs a cumulative off-chain commitment
for (let i = 0; i < 100; i++) {
const res = await mppx.fetch("http://localhost:3003/data");
console.log(i, await res.json());
}
import { close } from "@stellar/mpp/channel/server";
import * as StellarSdk from "@stellar/stellar-sdk";
const txHash = await close({
channel: process.env.CHANNEL_CONTRACT,
amount: lastCumulativeAmount, // bigint, total USDC owed in base units
signature: lastCommitmentSignature, // hex string from final commitment
feePayer: { envelopeSigner: StellarSdk.Keypair.fromSecret(process.env.FEE_PAYER_SECRET) },
network: "stellar:testnet",
});
// Single on-chain transaction settles the full session
console.log("Channel closed:", txHash);
Env vars (server): CHANNEL_CONTRACT, COMMITMENT_PUBKEY, MPP_SECRET_KEY, FEE_PAYER_SECRET
Env vars (client): COMMITMENT_SECRET
npm install @stellar/mpp mppx @stellar/stellar-sdk
| Import path | Recommended import pattern |
|---|---|
@stellar/mpp/charge/server | import * as stellar from "@stellar/mpp/charge/server" — use stellar.charge(...) |
@stellar/mpp/charge/client | import * as stellar from "@stellar/mpp/charge/client" — use stellar.charge(...) |
@stellar/mpp/channel/server | import * as stellar from "@stellar/mpp/channel/server" — use stellar.channel(...), stellar.close(...), stellar.getChannelState(...), stellar.watchChannel(...) |
@stellar/mpp/channel/client | import * as stellar from "@stellar/mpp/channel/client" — use stellar.channel(...) |
@stellar/mpp/channel | Zod schema definitions for channel types |
mppx | import { Mppx, Store } from "mppx" |
Steps shared with all protocols:
Channel mode only: 4. Deploy the one-way-channel contract (see stellar-mpp-sdk for deploy script) 5. Generate a 64-char hex ed25519 seed for the commitment key:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
Channel: wrong commitment key format
Keypair.fromRawEd25519Seed throws or signatures fail to verifyS... secret key. Generate with crypto.randomBytes(32).toString('hex').Channel: non-cumulative amounts
amount must be the running total of all payments so far, not just the price of the current request. The server tracks the highest-seen commitment.Channel: deposit TTL expired
close() fails or channel appears drainedbumpContractInstance. Don't leave channels open indefinitely.Charge: client has no XLM for fees
op_insufficient_balance or fee errors on client-submitted transactionsmode: "pull" on the client and configure feePayer on the server so the server pays fees. The client only signs auth entries.Store.memory() in production
Store.memory() with a persistent store (database-backed) before going to production.npx claudepluginhub stellar/stellar-dev-skill --plugin stellar-devHandles HTTP 402 Payment Required responses and x402 payment flows for agents. Supports payment channels, vouchers, and a2a-pay links.
Adds x402 payment execution to AI agents with per-task budgets, spending controls, and non-custodial wallets. Useful for paying APIs or settling with other agents.
Builds an Express server that charges USDC per API request using the x402 payment protocol. Use for monetizing endpoints as a paid service discoverable by other agents.