Payment schemes
The four ways a pay-kit gate can charge — fixed charge, metered usage, subscription, and session — and how each maps to MPP and x402.
A gate's kind decides how it charges. The default is a one-time fixed charge; usage, subscription, and session are gate factories you wrap the price in. Protocol stays invisible to your handler — the only protocol knob is accept.
| Scheme | Factory | Protocol · sub-form | What it does |
|---|---|---|---|
| Fixed charge | (default) | mpp charge · x402 exact | Charge a fixed amount once, settled immediately. |
| Metered usage | usage(max) | x402 upto | Authorize a ceiling, meter actual consumption, refund the remainder. |
| Subscription | subscription(amount, …) | mpp subscription | Recurring on-chain authorization; the first call activates a plan, later calls reuse it. |
| Session | session(cap, …) | mpp session | Open a payment channel, stream metered deliveries, settle out-of-band on close. |
Fixed charge
The default gate: a fixed price the client settles over MPP or x402 (whichever it picks from accept). Settlement runs before your handler.
import express from 'express';
import { createPayKit, usd } from '@solana/pay-kit';const pay = await createPayKit({ accept: ['mpp', 'x402'], // settle over either protocol — the client picks network: 'mainnet', operator: { recipient: RECIPIENT, signer }, // KeyPairSigner verifies the charge pricing: { quote: { amount: usd('0.01'), description: 'Stock quote' } }, rpcUrl,})const app = express()// `pay.express(gate)` settles the 402 (MPP or x402) before the handler runs.app.get('/api/v1/quote/:symbol', pay.express('quote'), (_req, res) => { res.json({ ok: true })})The client side is a single fetch — pay-kit pays the 402 and retries:
const signer = await createKeyPairSignerFromBytes(getBase58Encoder().encode(SECRET))const client = await createPayKitClient({ signer, rpcUrl: 'http://localhost:8899' })// On a 402, pay-kit pays with the matching protocol and retries — transparently.const res = await client.fetch('https://api.example.com/api/v1/quote/AAPL')console.log(await res.json())Metered usage
A usage gate authorizes a ceiling (x402 upto), then bills what your handler actually consumed and refunds the rest. Record consumption with pay.charge(request), in base units of the gate's currency.
import { createPayKit, usage, usd } from '@solana/pay-kit';
const pay = await createPayKit({
accept: ['x402'], // upto is an x402 scheme
network: 'mainnet',
operator: { recipient: RECIPIENT, signer },
// Authorize up to $0.10; settle the actual usage, refund the remainder.
pricing: { summarize: usage(usd('0.10'), { description: 'Summarize text, billed per token' }) },
rpcUrl,
});
app.post('/api/v1/summarize', pay.express('summarize'), (req, res) => {
const summary = summarize(req.body.text);
// Meter what was consumed; pay.express settles the actual draw after the handler.
pay.charge(req)?.charge(BigInt(summary.tokens) * 100_000n);
res.json({ summary: summary.text });
});Subscription
A subscription gate is a recurring on-chain authorization (MPP). The first call activates the plan; subsequent calls within the period reuse it for free. The plan PDA and the puller (the entity allowed to debit renewals) are created ahead of time.
const pay = await createPayKit({ network: 'mainnet', operator: { recipient: RECIPIENT, signer }, pricing: { feed: subscription(usd('0.10'), { planId: PLAN_ID, // on-chain Plan PDA, created ahead of time puller: signer.address, // entity allowed to pull renewals periodUnit: 'day', periodCount: 1, }), }, rpcUrl,})const app = express()// First call activates the plan on-chain; `pay.express` gates the rest.app.get('/api/v1/feed', pay.express('feed'), (_req, res) => { res.json({ items: [/* … */] })})From the client, the same fetch activates the subscription on first use:
const signer = await createKeyPairSignerFromBytes(getBase58Encoder().encode(SECRET))const client = await createPayKitClient({ signer, rpcUrl: 'http://localhost:8899' })// First call activates the subscription on-chain; subsequent calls within// the period re-use it for free — pay-kit settles the 402 either way.const res = await client.fetch('https://api.example.com/api/v1/feed')console.log(await res.json())Session
A session gate opens a payment channel (MPP). You stream metered deliveries — billed per unit against the channel cap — and settlement runs out-of-band when the channel idle-closes. Unlike the other schemes, session gates need their side-channel and receipt routes mounted explicitly; pay-kit hands them over via pay.sessionRoutes(gate) rather than auto-injecting them.
const pay = await createPayKit({ network: 'mainnet', operator: { recipient: RECIPIENT, signer }, // Cap 1 USDC per session, metered at 0.0001 USDC per delivered chunk. pricing: { stream: session(usd('1.00'), { unitPrice: usd('0.0001') }) }, rpcUrl,})const app = express()// Gate the stream; settlement runs out-of-band when the channel idle-closes.app.get('/api/v1/stream', pay.express('stream'), (_req, res) => { res.writeHead(200, { 'Content-Type': 'text/event-stream' }) for (const chunk of chunks()) res.write(`data: ${chunk}\n\n`) res.end()})// Session side-channel + receipt routes — mounted explicitly (pay-kit, like// mppx, leaves route-mounting to the app rather than auto-injecting them).const routes = pay.sessionRoutes('stream')app.post('/api/v1/stream', routes.voucher) // voucher commits to the resource URLapp.post('/__402/session/deliveries', routes.deliveries)app.post('/__402/session/commit', routes.commit)app.get('/sessions/receipt/:channelId', routes.receipt)The client opens one channel and consumes the stream chunk by chunk:
const client = createSessionFetch({ opener: createPaymentChannelSessionOpener({ signer, rpcUrl }),})const res = await client.fetch('https://api.example.com/api/v1/stream')for await (const chunk of res.body!) { process.stdout.write(chunk)}MPP or x402?
accept is an ordered preference list. With both protocols enabled (accept: ['mpp', 'x402']) a fixed-charge gate advertises both and the client chooses; usage is x402-only, while subscription and session are MPP-only. To force a rail from the client when an endpoint offers both, pass the protocol as the third fetch argument:
const signer = await createKeyPairSignerFromBytes(getBase58Encoder().encode(SECRET))const client = await createPayKitClient({ signer, rpcUrl: 'http://localhost:8899' })// Force the x402 rail (omit the 3rd arg to let pay-kit pick MPP or x402).const res = await client.fetch('https://api.example.com/api/v1/quote/AAPL', undefined, 'x402')console.log(await res.json())