pay.sh docs
SDKTypeScript

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.

SchemeFactoryProtocol · sub-formWhat it does
Fixed charge(default)mpp charge · x402 exactCharge a fixed amount once, settled immediately.
Metered usageusage(max)x402 uptoAuthorize a ceiling, meter actual consumption, refund the remainder.
Subscriptionsubscription(amount, …)mpp subscriptionRecurring on-chain authorization; the first call activates a plan, later calls reuse it.
Sessionsession(cap, …)mpp sessionOpen 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())

On this page