x402-sessions
v0.2.0
Published
Sign once, settle many times — session-based x402 payments on Stellar. One on-chain SAC approve unlocks unlimited micropayments via transfer_from until the cap or expiry is reached. No per-request wallet popup.
Maintainers
Readme
x402-sessions
Sign once, settle many times — session-based x402 payments on Stellar.
A tiny TypeScript SDK that turns a single Stellar Asset Contract (SAC) approve into an unlimited stream of x402 micropayments. Pay $1 upfront, then every API call automatically settles $0.10 on-chain via transfer_from. No per-call wallet popup. No off-chain bookkeeping tricks. The cap and expiry are enforced on-chain by the SAC itself.
Classic x402 = 1 request, 1 signature, 1 settlement. x402-sessions = 1 signature, N settlements.
Why
If you're building an AI agent, a dapp game, a pay-per-inference API, or anything where a user makes many small payments in a row, classic x402 becomes friction theatre — one wallet popup per request. Thirdweb built a session model for EVM using Permit2 + off-chain facilitator bookkeeping. This package is the Stellar-native equivalent, and it's arguably simpler and stronger:
- On-chain enforcement of the cap via SEP-41
approve. Not a facilitator-side ledger hack. - Zero escrow. Unused allowance stays in the user's wallet. No refund dance when a session expires.
- Works with existing x402 infra. Registers as a new scheme (
session) alongside Coinbase'sexact. Drop-in on the resource-server side via@x402/core'sx402ResourceServer.register(). - Tiny wire format. The retry
PaymentPayload.payloadis just{ sessionId }.
Install
npm install x402-sessions @stellar/stellar-sdk
# if you're also building the resource-server side:
npm install @x402/core @x402/nextPeer deps: @stellar/stellar-sdk ^14 || ^15, @x402/core ^2.8.0 (optional — only if you use the server-side scheme plugin).
You also need a running x402-sessions facilitator — a small service that verifies sessions and performs the on-chain transfer_from. A public testnet facilitator is live at:
https://courteous-emotion-production.up.railway.appTo self-host your own, use the scaffold: npm create x402-sessions-facilitator@latest my-facilitator.
30-second quickstart
1. Client side (Node)
import { Keypair } from "@stellar/stellar-sdk";
import { createSession } from "x402-sessions";
const session = await createSession({
signer: Keypair.fromSecret(process.env.USER_SECRET!),
facilitatorUrl: "https://courteous-emotion-production.up.railway.app",
network: "stellar:testnet",
asset: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA", // USDC testnet SAC
spendingCap: "1.00", // $1 total
expiresIn: 3600, // 1 hour
recipient: "GBWYMS7R...", // your resource server's wallet
});
// session.fetch is a drop-in fetch that transparently pays per call.
for (let i = 0; i < 10; i++) {
const res = await session.fetch("https://yourapi.example/inference");
console.log(await res.json()); // each call settles $0.10 on-chain
}Under the hood createSession signs and submits one SAC approve(user, facilitator, cap, expiration_ledger) tx, registers the approval with the facilitator, and returns a SessionHandle whose fetch() method transparently handles 402 responses.
2. Client side (browser with Freighter)
import { signTransaction, requestAccess } from "@stellar/freighter-api";
import { createSession } from "x402-sessions";
const { address } = await requestAccess();
const session = await createSession({
signer: {
publicKey: () => address,
signTransaction: async (xdr, opts) => {
const r = await signTransaction(xdr, {
networkPassphrase: opts?.networkPassphrase,
address,
});
if (r.error) throw new Error(r.error.message ?? "sign failed");
return r.signedTxXdr;
},
},
facilitatorUrl: "https://courteous-emotion-production.up.railway.app",
network: "stellar:testnet",
asset: process.env.NEXT_PUBLIC_USDC_SAC_ID!,
spendingCap: "1.00",
expiresIn: 3600,
recipient: process.env.NEXT_PUBLIC_RECIPIENT!,
});
// Use it anywhere you'd use fetch()
await session.fetch("/api/chat", {
method: "POST",
body: JSON.stringify({ prompt: "hi" }),
headers: { "Content-Type": "application/json" },
});Any signer with publicKey() + either sign(tx) (Node Keypair) or signTransaction(xdr, opts) (browser wallet adapter) works.
3. Resource-server side (Next.js + @x402/next)
import { paymentProxy, x402ResourceServer } from "@x402/next";
import { HTTPFacilitatorClient } from "@x402/core/server";
import { SessionStellarScheme } from "x402-sessions";
const facilitator = new HTTPFacilitatorClient({
url: process.env.SESSION_FACILITATOR_URL ?? "https://courteous-emotion-production.up.railway.app",
});
const server = new x402ResourceServer(facilitator).register(
"stellar:testnet",
new SessionStellarScheme({
assetContractId: "CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA",
facilitatorUrl: process.env.SESSION_FACILITATOR_URL,
}),
);
export const handler = paymentProxy(
{
"/api/chat": {
accepts: [
{
scheme: "session" as const,
price: "0.10",
network: "stellar:testnet",
payTo: process.env.SERVER_STELLAR_ADDRESS!,
},
],
description: "AI chat, settled per message via session",
},
},
server,
);That's it. Any request to /api/chat without a session payload gets a 402. With one, it passes through and $0.10 settles on-chain.
How it works
┌──────────────┐ ┌───────────────┐
│ user wallet │──1. approve(facilitator, $1, 1h) ─────▶│ USDC SAC │
│ (Freighter) │ │ (on-chain) │
└──────┬───────┘ └───────▲───────┘
│ │
│ 2. POST /sessions {approvalTxHash, cap, …} │
▼ │
┌──────────────┐ 3. sessionId │
│ x402-sessions│◀─────────────────┐ │
│ facilitator │ │ │
│ (HTTP) │ │ │
└──────┬───────┘ │ │
│ │ │
│ │ │
│ 4. POST /api/chat (per call) │
│ ┌──────────────┴───────────────┐ │
│ │ │ │
│ │ ┌──────────────┐ │ │
│ └──────▶│ resource │ │ │
│ │ server │ │ │
│ │ (@x402/next) │ │ │
│ └──────┬───────┘ │ │
│ │ │ │
│◀── 5. /verify ────────── │ │ │
│ /settle │
│──── 6. transfer_from(facilitator, user, server, $0.10) ─▶
│ │
│ 7. ok + reply │
│ └──────────────▶ user │
└──── decrement session (sqlite) ─────────────────────────┘- User signs one on-chain
approve(spender=facilitator, amount=cap, expiration_ledger). - SDK registers the approval with the facilitator (
POST /sessions). - Facilitator verifies on-chain allowance via
allowance(), stores the session, returns asessionId. - User hits protected endpoint with
session.fetch(...). SDK handles the 402 dance automatically:- Reads the
PAYMENT-REQUIREDheader (x402 v2) - Builds a payment payload
{ sessionId } - Retries with
PAYMENT-SIGNATUREheader
- Reads the
- Resource server's
x402ResourceServercalls facilitator/verifythen/settle. - Facilitator runs
transfer_from(spender, from, to, amount)on-chain. The SAC itself enforces the cap and expiry. - Response flows back to the user. Spent counter decrements.
API reference
createSession(options)
Signs + submits the on-chain approve, registers the session with the facilitator, and returns a SessionHandle.
function createSession(opts: CreateSessionOptions): Promise<SessionHandle>
interface CreateSessionOptions {
signer: ClientSigner; // Keypair or browser wallet adapter
facilitatorUrl: string; // e.g. "https://courteous-emotion-production.up.railway.app"
network?: "stellar:testnet" | "stellar:pubnet"; // default testnet
asset: string; // SAC contract id (C...)
spendingCap: string; // human units, e.g. "1.00"
decimals?: number; // default 7 (USDC)
expiresIn: number; // seconds; converted to ledger count
recipient: string; // payTo address (G...)
sorobanRpcUrl?: string; // override default
}
interface SessionHandle {
sessionId: string;
user: string;
spender: string;
asset: string;
recipient: string;
cap: string; // base units (stroops for 7-dec USDC)
spent: string; // base units
expirationLedger: number;
network: Network;
facilitatorUrl: string;
fetch: typeof fetch; // auto-paying fetch
}wrapFetch(sessionId)
Lower-level helper. Returns a fetch-compatible function that, on receiving a 402, reads the PAYMENT-REQUIRED header, builds a PaymentPayload with the given sessionId, and retries with PAYMENT-SIGNATURE. Use this if you want to manage the session handle yourself (e.g. persist it across pages).
function wrapFetch(sessionId: string): typeof fetchSessionStellarScheme
Resource-server plugin for @x402/core's x402ResourceServer.register(). Advertises the session scheme, parses prices, and enriches 402 responses with facilitator metadata.
class SessionStellarScheme {
constructor(config: {
assetContractId: string; // SAC contract id
decimals?: number; // default 7
facilitatorUrl?: string; // exposed to clients in 402.extra
});
}Structurally compatible with @x402/core's SchemeNetworkServer interface. (It doesn't implements literally, to avoid an @x402/core/types module-resolution requirement — your TypeScript will happily pass it to register(network, new SessionStellarScheme(...)) via structural typing.)
ClientSigner
Minimal signer interface. A Keypair from @stellar/stellar-sdk satisfies it natively (via its sign(Buffer) method). For browser wallets, provide the signTransaction shape.
interface ClientSigner {
publicKey(): string;
sign?(data: Buffer): Buffer;
signTransaction?(
xdr: string,
opts?: { networkPassphrase: string },
): Promise<string>;
}Helpers
import {
decimalToBaseUnits, // "1.50" + 7 decimals -> 15000000n
defaultRpcUrlFor, // network -> public Soroban RPC URL
networkPassphraseFor, // network -> stellar-sdk Networks constant
} from "x402-sessions";Wire format (session scheme)
- Scheme:
"session" - Network:
"stellar:testnet"|"stellar:pubnet" PaymentPayload.payload(the scheme-specific slot):{ sessionId: string }- Facilitator HTTP surface (added to the standard x402 triplet):
GET /supported— standard x402POST /verify— standard x402POST /settle— standard x402POST /sessions— new: register a session from a signed approval tx hashGET /sessions/:id— new: inspect remaining cap / spent / expiry
Trust model
| Limit | Enforced by | Hardness |
|---|---|---|
| Total cap (e.g. $1) | SAC approve + transfer_from reverts past cap | On-chain |
| Expiry (ledger) | expiration_ledger parameter in SAC approve | On-chain |
| Per-call price (e.g. $0.10) | Facilitator /settle refuses amounts > policy | Off-chain (trust your facilitator) |
| Recipient binding | Facilitator only pays the pre-registered recipient | Off-chain |
| Session reuse control | Facilitator sqlite bookkeeping | Off-chain |
| Unused balance handling | Funds never escrowed — they stay in the user's wallet | Native |
In short: on-chain handles the money-safety invariants (total and expiry). Off-chain handles the application policy (per-call price, recipient, bookkeeping). This is the same trust model as thirdweb's session x402 on EVM, except the cap enforcement is strictly better because ours is on-chain.
If you need on-chain per-call policy enforcement (e.g. "no more than $0.10 per call, no matter what"), upgrade to a Soroban custom account / smart wallet with a policy-signer session key — out of scope for this package.
No refund dance
Because approve is a permission, not an escrow, tokens never leave the user's wallet until transfer_from is called:
| Scenario | What happens |
|---|---|
| Session expires unused | Still in user's wallet. Allowance becomes unusable. No refund call. |
| Session partially used | The untouched portion never moved. No refund call. |
| User wants to end early | Optional: sign approve(..., amount=0) to revoke. Not required — expiry handles it. |
Compare this to escrow-based session models where you must explicitly claim refunds.
Security notes
- Treat the
sessionIdas a bearer token — anyone who steals it can spend the full cap to the pre-registered recipient. Keep it in memory or secure storage; don't log it. - The recipient is bound at session creation. A stolen sessionId cannot redirect funds.
- Worst-case: an attacker drains the whole cap to your game server. User loses up to
cap; funds still end up where they were meant to go. - For higher-trust scenarios, use a smaller cap per session and rotate often.
FAQ
Q: Is this the same as Coinbase x402's upto scheme?
A: No — upto is a canonical Coinbase-authored scheme that's currently EVM-only and enforces single-use authorizations. This is a new scheme called session, Stellar-native, built on SAC approve.
Q: Is this the same as thirdweb's session x402?
A: Same model (sign once, settle many), different mechanism. Thirdweb uses Uniswap Permit2 on EVM with facilitator-side bookkeeping. This package uses Stellar SAC approve/transfer_from with on-chain bookkeeping. No EIP-7702 involved on either side.
Q: Can I use it with real Circle USDC on Stellar?
A: Yes — the SAC contract ID for Circle testnet USDC is CBIELTK6YBZJU5UP2WWQEUCYKLPU6AUNZ2BQ4WWFEIE3USCIHMXQDAMA, and pubnet is CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75. Any SEP-41 token works — native XLM SAC, Circle USDC, or your own issued asset.
Q: What if on-chain transfer_from fails after the facilitator debits sqlite?
A: The reference facilitator rolls back the sqlite debit automatically and returns success: false, errorReason: "onchain_transfer_failed" in the payment-response header.
Q: Can I reuse a session across multiple resource servers?
A: Only if the recipient matches. The facilitator binds a session to exactly one recipient at creation time.
Q: What happens if the session cap runs out mid-request?
A: Facilitator /verify returns invalidReason: "cap_exceeded". The client sees a 402 with the reason in the payment-response header; the resource is not served and no on-chain tx is submitted.
Q: Can I use the browser fetch directly, without wrapFetch?
A: Yes — just handle the 402 yourself. Read the PAYMENT-REQUIRED response header (base64 JSON), find the scheme: "session" accept, build a PaymentPayload with { payload: { sessionId } }, base64-encode it, and retry with a PAYMENT-SIGNATURE header.
Live demo
A reference Next.js app ships at x402-sessions/x402-session-app. It includes two x402-protected routes so you can see sessions in action:
| Route | What it does |
|---|---|
| /test2 | AI Chat — connect Freighter, create a session ($1 cap / 1 hr), and chat with an LLM. Each message settles $0.10 on-chain via transfer_from. |
| /slot | Slot Machine — same session flow, but each pull of the lever is a micropayment. Fun way to drain a session cap. |
Both routes use the deployed facilitator at https://courteous-emotion-production.up.railway.app by default.
Run it yourself
git clone https://github.com/x402-sessions/x402-session-app.git
cd x402-session-app
npm install
cp .env.local.example .env.local # edit values if needed
npm run devOpen http://localhost:3000/test2 (chat) or http://localhost:3000/slot (slot machine) in a browser with the Freighter wallet extension set to Testnet.
License
MIT © madhav
