bolthub logobolthub
SDKs & Tools

@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/pay

The package follows SemVer from 0.1.0. The wire format it speaks (the Tool Payment Profile, TPP 0.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

  1. 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).
  2. 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>" } }.
  3. 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.

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 NWCClient

LNbits

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 remaining

Payment 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: true when 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

RailStatusWhat it does
l402Rail / l402PayerLiveLightning. HMAC-signed, resource-scoped, time-limited tokens; constant-time verification.
facilitatorRail + httpFacilitatorLive (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 l402Rail has 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).