@bolthub/pay (Payments SDK)
The bolthub payments SDK, both sides of the sale. Charge agents for an MCP tool or HTTP endpoint in a few lines, and pay for tools and L402 APIs with budgeted clients. Built on L402 (Lightning).
@bolthub/pay is the bolthub payments SDK: the seller side prices a tool, the
buyer side pays for it. It is open source (MIT), free to self-host, needs no
bolthub account, and has zero runtime dependencies. As of 0.4.0 it absorbed
@bolthub/agent (now deprecated), so the HTTP L402 client and the wallet
adapters live here too.
The agent-to-tool protocol standardises what a tool does but has no slot for
what it costs; this SDK fills that slot. A paid tool answers an unpaid call
with a payment_required challenge and runs only once a valid proof comes
back, over whatever rail you accept.
bun add @bolthub/pay
# or: npm install @bolthub/payThe package follows SemVer from
0.1.0. The wire format it speaks (the Tool Payment Profile, TPP0.1) is a draft and may evolve before 1.0.
Seller: charge for an MCP tool
A rail needs a signing secret (32+ characters) and something that makes invoices: your own wallet (NWC, LND, phoenixd, LNbits) or the hosted facilitator.
import { createPaywall, l402Rail } from "@bolthub/pay";
const pay = createPaywall({
rails: [
l402Rail({
secret: process.env.PAY_SECRET!,
invoiceProvider: {
async createInvoice(amountSat, memo) {
const { invoice, paymentHash } = await myWallet.makeInvoice(amountSat, memo);
return { invoice, paymentHash };
},
},
}),
],
});
// Register the tool. `resource` defaults to the tool name; a proof is
// accepted only for the resource it was minted against.
pay.tool(
server,
"get_satellite_image",
"Recent high-res satellite imagery for a lat/lon and date.",
schema,
{ price: { amount: 2000 } }, // 2000 sats per call
async (args) => ({ content: [{ type: "text", text: await fetchImage(args) }] }),
);Prefer to call server.tool yourself? Wrap just the handler:
server.tool(
"get_satellite_image",
schema,
pay(
{ price: { amount: 2000 }, resource: "get_satellite_image" },
async (args) => ({ content: [{ type: "text", text: await fetchImage(args) }] }),
),
);What the buyer sees
- An unpaid call returns an error result whose
_meta["ai.bolthub/payment"]holds the challenge: the price, the resource, and one offer per rail (an L402 offer carries a Lightning invoice and a token). - The buyer pays an offer, then re-calls the tool with the proof in the
request
_meta:{ "ai.bolthub/payment": { "scheme": "l402", "proof": "<token>:<preimageHex>" } }. - The proof verifies and the handler runs.
A payment-blind client just sees a normal tool error ("Payment required: 2000 sat …") and moves on; nothing breaks.
Advertise the price
Optional, for cost-aware agents that budget before calling:
const ad = pay.advertise({ amount: 2000 }); // → { version, price, model, rails }
// attach to the tool's _meta["ai.bolthub/payment"]Buyer: pay for MCP tools automatically
ToolClient calls a tool; when it gets a payment_required challenge it
pays an offer it has a payer for and retries, all inside a per-asset budget.
The budget is a hard cap: every payment is counted against maxTotal before
it happens, and offers that would cross it are refused. (Known as
PayingClient before 0.3.0; that name remains as a deprecated alias.)
import { ToolClient, l402Payer } from "@bolthub/pay";
const client = new ToolClient({
payers: [
l402Payer({ wallet: myLightningWallet }), // pays L402 invoices
],
maxTotal: { sat: 10_000 }, // sat budget cap
onPaid: (i) => console.log(`paid ${i.amount} ${i.asset} via ${i.scheme}`),
});
// callTool handles challenge → pay → retry transparently:
const result = await client.callTool(mcpClient, "get_satellite_image", { lat, lon });Payers are tried in order, so the list is your rail preference. l402Payer's
wallet is the same WalletAdapter the built-in adapters implement, so
LndWallet, NwcWallet, PhoenixdWallet, etc. drop straight in.
Delegating to a sub-agent? Give it its own ToolClient with a smaller cap.
Buyer: pay for HTTP APIs (L402)
For paywalled HTTP endpoints — a gateway answering 402 Payment Required
with a WWW-Authenticate: L402 challenge, like every API on the bolthub Hub —
use L402Client. It pays the embedded Lightning invoice and retries with the
proof, caching session tokens between calls.
import { L402Client, LndWallet } from "@bolthub/pay";
const wallet = new LndWallet({
host: "https://your-lnd-node:8080",
macaroon: "0201036c6e...",
});
const client = new L402Client({
wallet,
maxPerRequestSats: 100,
budgetSats: 10_000,
});
const resp = await client.get(
"https://acme.gw.bolthub.ai/v1/weather",
{ params: { city: "berlin" } }
);
const data = await resp.json();Per-request options: maxCostSats tightens the per-request cap for one call,
and onPaid reports the exact cost of that call (reading totalSpent deltas
is racy when a budget is shared):
const resp = await client.get(url, {
maxCostSats: 20,
onPaid: (i) => console.log(`this call cost ${i.amount} sats`),
});Session persistence
By default, session tokens (from time_pass, metered, token_bucket, and
per_kb endpoints) are cached in memory. For CLI tools or agents that should
survive restarts, pass a FileSessionStore (defaults to
~/.bolthub/sessions.json):
import { L402Client, FileSessionStore } from "@bolthub/pay";
const client = new L402Client({ wallet, sessionStore: new FileSessionStore() });Prepaid credit (across a provider's endpoints)
When you'll call several of one provider's endpoints, buyCredit pays once for a
sats budget spendable across all of them. After that, ordinary calls to any of
that provider's endpoints draw the budget with no further payment, until it runs
out and the next call falls back to a normal per-call payment.
// One Lightning payment for a budget usable across the provider's endpoints.
await client.buyCredit("https://acme.gw.bolthub.ai/v1/data", 10_000);
// Any endpoint of acme now draws the credit: no payInvoice call happens.
await client.get("https://acme.gw.bolthub.ai/v1/data");
await client.get("https://acme.gw.bolthub.ai/v1/reports");Credit is face-value: you pass a sats budget and the gateway charges exactly
that, with no discount tiers. The client verifies the server echoed the requested
budget before it pays, so a provider that hasn't enabled credit is refused with
nothing spent. Each later call burns the endpoint's real per-call price against
the budget, and the same budget and maxCostSats rules apply as any other
payment. Credit is scoped to the provider (cached per host), so buying credit for
one provider never covers another. Unused credit at expiry is non-refundable, so
size it to what you expect to spend.
For a set of URLs across several providers, batchFetch groups them by provider,
buys one credit per provider, and fetches them all with bounded concurrency. This
is non-custodial by construction: N providers means N payments, never a pooled
balance.
const results = await client.batchFetch(
[
"https://acme.gw.bolthub.ai/v1/data",
"https://acme.gw.bolthub.ai/v1/reports",
"https://bolt.gw.bolthub.ai/v1/prices",
],
{ creditSats: 10_000 }, // sized to cover your calls per provider
);Wallet adapters
LND
import { LndWallet } from "@bolthub/pay";
const wallet = new LndWallet({
host: "https://your-lnd-node:8080",
macaroon: "scoped-macaroon-hex", // bake payment-scoped, never admin.macaroon
timeoutSeconds: 30,
});Bake the scoped macaroon per the agent wallet security guide — paying needs send permissions, but admin.macaroon grants far more than any agent should hold.
NWC (Nostr Wallet Connect)
import { NwcWallet } from "@bolthub/pay";
const wallet = new NwcWallet(nwcConnection); // e.g. @getalby/sdk's NWCClientLNbits
import { LnbitsWallet } from "@bolthub/pay";
const wallet = new LnbitsWallet({
url: "https://lnbits.example.com",
adminKey: "your-admin-key",
});Phoenixd
Use PhoenixdWallet when your agent pays via an existing Phoenixd HTTP API. Prefer LndWallet with the bolthub Node Launcher or your own LND when you are choosing a new setup.
import { PhoenixdWallet } from "@bolthub/pay";
const wallet = new PhoenixdWallet({
baseUrl: "http://localhost:9740",
password: "your-phoenixd-password",
});WebLN (browser)
The package ships a browser export condition; in the browser build,
WebLnWallet pays through a WebLN provider such as the Alby extension
(isWebLnAvailable() feature-detects one).
From environment variables
walletFromEnv() builds an adapter from the standard bolthub env vars, the
same ones the MCP server and CLI read.
Checked in priority order: LND_REST_HOST/LND_MACAROON,
LNBITS_URL/LNBITS_ADMIN_KEY, PHOENIXD_URL/PHOENIXD_PASSWORD,
NWC_URI. Returns undefined when none are set, so callers can run in a
free-tools-only mode.
import { walletFromEnv } from "@bolthub/pay";
const wallet = await walletFromEnv();NWC needs a protocol implementation this zero-dependency package does not
ship: pass nwcConnect (e.g. backed by @getalby/sdk's NWCClient).
NWC_URI set without a connector throws.
Custom wallet
Implement the WalletAdapter interface:
import type { WalletAdapter } from "@bolthub/pay";
const myWallet: WalletAdapter = {
async payInvoice(bolt11: string) {
const preimage = await myPaymentLogic(bolt11);
return { preimage };
},
};Adapters that hold a connection open (NWC relay sockets) can also implement
the optional close().
One budget across both buyer paths
Budget is the shared per-asset pool. Hand the same instance to a
ToolClient (MCP-wire payments) and an L402Client (HTTP-402 payments) and
together they can never spend past maxTotal. Reservations are synchronous,
so even concurrent calls across the two paths can't jointly overspend.
import { Budget, ToolClient, L402Client, l402Payer } from "@bolthub/pay";
const budget = new Budget({ maxTotal: { sat: 10_000 }, maxPerCall: { sat: 500 } });
const tools = new ToolClient({ payers: [l402Payer({ wallet })], budget });
const http = new L402Client({ wallet, budget });budget is mutually exclusive with the client's own limits (maxTotal/
maxPerCall on ToolClient, budgetSats on L402Client). Without a shared
budget, each client keeps its own accounting:
console.log(http.totalSpent); // sats spent so far
console.log(http.remainingBudget); // sats remainingPayment receipts
Every settled L402 payment yields a (invoice, payment_hash, preimage) triple
that proves the payment to anyone, offline. Configure a receipt store and the
client records one receipt per paid call: timestamp, resource, method, amount,
the triple, and the payment outcome. The result is a verifiable expense report
for agent spend.
import { L402Client, FileReceiptStore, verifyReceipt } from "@bolthub/pay";
const client = new L402Client({
wallet,
receiptStore: new FileReceiptStore(), // ~/.bolthub/receipts.jsonl, 0600
});
// ... paid calls happen ...
const csv = client.exportReceipts({ format: "csv", redact: true });Nothing is recorded unless a store is configured. onPaid callbacks also carry
preimage, invoice, and paymentHash per payment if you prefer your own sink.
Verification runs with no bolthub service in the loop. verifyReceipt
(or bolthub receipts verify in the CLI) checks three things: the preimage
hashes to payment_hash (SHA-256), payment_hash equals the hash the BOLT11
invoice commits to, and the recorded amount matches the invoice amount. A
third party holding the receipt file can run the same checks.
Two boundaries to know:
- Receipts prove the payment, not the context. The URL, timestamp, and method are self-reported by the client that wrote the file; only the money fields are cryptographically bound.
- Receipt files carry live preimages, so treat them like credentials. Use
redact: truewhen exporting for someone else: the expense record survives, the proof (and any residual credential value) does not. Verifiers report redacted receipts as "redacted", not "invalid".
Delegation (scoped sub-agent credentials)
A paid L402 macaroon can be narrowed offline and handed to another agent, so a parent that paid for access delegates a restricted credential without re-paying or calling bolthub. The holder appends caveats; the gateway enforces every one down the chain (most restrictive wins).
import { attenuate } from "@bolthub/pay";
// `macaroon` is the value from `Authorization: L402 <macaroon>:<preimage>`.
const restricted = attenuate(macaroon, {
method: "GET", // only GET requests
validUntil: Date.now() + 60_000, // expires in 60s, tighter than the original
nUses: 50, // at most 50 requests
maxSats: 300, // at most 300 sats of spend
pathPrefix: "/v1/reports", // only paths at or under /v1/reports
});
// Hand `restricted` plus the SAME preimage to the sub-agent, which sends
// Authorization: L402 <restricted>:<preimage>attenuate() is offline and Node-side (it is not in the browser build) and never
re-pays. The nUses, maxSats, and pathPrefix restrictions are backed by a
server-side grant, so they hold across processes and gateway instances: nUses
caps total requests, maxSats caps cumulative spend, and pathPrefix confines
the child to one path subtree (segment-boundary match, so /v1/reports allows
/v1/reports/42 but not /v1/reportsX).
Attenuation is tighten-only. Every restriction is validated against the
caveats the credential already carries and throws if it would widen scope: you
cannot raise nUses/maxSats, move validUntil later, or set a pathPrefix
outside the parent's. The gateway enforces the same folds, so even a hand-built
child that tries to widen simply fails verification.
A child is minted from a prepaid-credit credential you already hold
(buyCredit first, then getCreditCredential(url) returns the macaroon to
attenuate), so you can pay once and then hand out scoped slices across the
provider's endpoints.
Rails
| Rail | Status | What it does |
|---|---|---|
l402Rail / l402Payer | Live | Lightning. HMAC-signed, resource-scoped, time-limited tokens; constant-time verification. |
facilitatorRail + httpFacilitator | Live (hosted) | Delegates mint/verify to a bolthub facilitator: at-most-once proof redemption, usage metering, analytics. See the hosted facilitator. |
The challenge carries the price as an L402 offer (a Lightning invoice and a
token). Adding a rail is implementing one interface (assets, createOffer,
verify); the paywall core never sees rail-specific bytes.
Security model
- Tokens are HMAC-signed, scoped to a
resource, and time-limited (default 15 minutes). A proof minted for one tool can never unlock another. - Signature and preimage checks are constant-time.
- The wrapper fails closed: no
resource, or any unverifiable proof, means no service. - Self-hosted
l402Railhas no built-in replay dedup: a paid proof stays valid for the token TTL, so a buyer could re-call within it. Use the facilitator rail when you need strict at-most-once, per-call billing.
Which package do I need?
- Writing code that charges or pays (seller paywall,
ToolClient,L402Client) →@bolthub/pay(this page); in Python,bolthub. - Giving an MCP agent paid tools by config, not code (the marketplace, a
gateway, or your other MCP servers) →
@bolthub/mcp. - Calling paid APIs from the terminal or scripts →
@bolthub/cli. - Verifying gateway-proxied requests at your origin →
@bolthub/verify(see origin protection).