Skip to content

Payments (CEP-8)

The ContextVM SDK provides a modular CEP-8 payment layer that allows servers to charge for specific capabilities (tools, resources, prompts) and clients to pay automatically when required.

Payments are implemented as middleware around transports, so your Nostr transport logic stays clean and payment rails stay pluggable.

At a glance:

  • Servers configure priced capabilities and one or more processors (how to issue/verify payment requests)
  • Clients configure one or more handlers (how to pay a payment request)
  • The protocol uses correlated JSON-RPC notifications:
    • notifications/payment_required (server → client)
    • notifications/payment_accepted (server → client)
    • notifications/payment_rejected (server → client, “reject without charging”)

Each payment rail is identified by a PMI (Payment Method Identifier). A PMI is just a string like bitcoin-lightning-bolt11.

In CEP-8, the PMI is how the client and server agree on how payment is settled.

The SDK currently ships a Lightning rail:

  • bitcoin-lightning-bolt11 - Lightning BOLT11 invoices via NWC (NIP-47) or LNbits

Under the hood, the built-in rail is implemented by:

  • Server processor: LnBolt11NwcPaymentProcessor
  • Client handler: LnBolt11NwcPaymentHandler

Payments are symmetric:

  1. Server-side: a PaymentProcessor creates payment requests (pay_req) and verifies settlement.
  2. Client-side: a PaymentHandler executes payments when the server requires payment.

pay_req is treated as opaque by the SDK. Only the selected PMI module understands its encoding.

CEP-8 separates discovery (“menu price”) from settlement (“what you actually charge”):

  • You advertise a price via pricedCapabilities[].amount + currencyUnit.
  • You settle via the chosen PMI’s processor. The processor encodes the settlement amount into pay_req.

Example: you can advertise in usd for transparency, but settle in sats if the chosen PMI is Lightning.

On the server you:

  1. define which capabilities are priced
  2. configure one or more processors
  3. attach server payment middleware
import { withServerPayments } from '@contextvm/sdk/payments';
const paidTransport = withServerPayments(baseTransport, {
processors: [processor],
pricedCapabilities: [
{
method: 'tools/call',
name: 'my-tool',
amount: 10,
currencyUnit: 'sats',
},
],
});

Notes:

  • pricedCapabilities is a set of patterns (method + name) that match incoming requests.
  • The wrapper gates the request: priced requests are not forwarded to the underlying server until payment is verified.

Fixed prices are useful, but most production services want dynamic pricing. The resolvePrice callback lets you compute the final quote at request time.

Common cases:

  • user-tier discounts
  • request-size pricing
  • promos/coupons
  • converting an advertised currency unit (e.g. USD) into settlement units (e.g. sats)
import type { ResolvePriceFn } from '@contextvm/sdk/payments';
const resolvePrice: ResolvePriceFn = async ({ capability, clientPubkey }) => {
// Example: give volume discounts
const usageCount = await getUserUsageCount(clientPubkey);
if (usageCount > 100) {
return { amount: capability.amount * 0.5 }; // 50% off for power users
}
return { amount: capability.amount };
};

Important: the amount returned by resolvePrice must be in the unit your chosen processor expects. For Lightning BOLT11 settlement, that means sats/msats according to the processor’s implementation.

You can reject requests before asking for payment by returning { reject: true, message? } from resolvePrice.

This is intentionally different from “payment required”: it’s a policy decision and there is no invoice created and no verification performed.

Typical use cases:

  • one-call-per-user / one-time coupons
  • quota exceeded
  • blocked users / missing allowlist
  • server-side validation failures you don’t want to charge for
import type { ResolvePriceFn } from '@contextvm/sdk/payments';
const usedCapabilities = new Set<string>(); // Track used capabilities per user
const resolvePrice: ResolvePriceFn = async ({
capability,
clientPubkey,
request,
}) => {
const key = `${clientPubkey}:${capability.method}:${capability.name}`;
if (usedCapabilities.has(key)) {
return {
reject: true,
message: 'This capability can only be used once per user',
};
}
usedCapabilities.add(key);
return { amount: capability.amount };
};

When rejected, the server emits notifications/payment_rejected instead of notifications/payment_required, and the request is not forwarded to the underlying server.

Paid request:

Client Request
→ Server detects priced capability
→ notifications/payment_required (correlated to request)
→ Client pays using a handler
→ Server verifies using a processor
→ notifications/payment_accepted (correlated)
→ Server forwards request to underlying MCP server

Rejected request:

Client Request
→ Server resolvePrice returns { reject: true }
→ notifications/payment_rejected (correlated)
→ Request is NOT forwarded

On the client you:

  1. configure one or more handlers
  2. attach client payment middleware
import { withClientPayments } from '@contextvm/sdk/payments';
const paidTransport = withClientPayments(baseTransport, {
handlers: [handler],
});

When the server responds with notifications/payment_required, the payments layer:

  1. chooses a handler by PMI
  2. calls the handler to pay pay_req
  3. continues the request flow once the server confirms via notifications/payment_accepted

When a server rejects a request, the client receives a notifications/payment_rejected notification correlated to the original request.

How you surface it is app-specific:

  • UI clients might show the message as an error toast.
  • Headless clients might treat it as a hard failure and stop retrying.

The important part: rejection happens without charging and without any processor/handler being invoked.

PMI selection is an intersection:

  • Clients can advertise what they can pay (via pmi tags).
  • Servers advertise what they can accept (based on configured processors).

If there is no overlap, the server cannot produce a usable pay_req for that client.

In practice, when you use payments wrappers, PMI advertisement is handled for you based on your configured handlers/processors.

  • Treat resolvePrice as part of your authorization layer: deterministic, fast, and side-effect aware.
  • If you enforce quotas/one-time use, use a durable store (not an in-memory map) if you run multiple server instances.
  • Keep settlement verification bounded: processors should have timeouts and should not poll indefinitely.