bolthub logobolthub
SDKs & Tools

Python SDK

bolthub: the bolthub payments SDK in Python — L402 client, wallet adapters, and the Tool Payment Profile (seller + buyer).

bolthub is the payments SDK in Python: the same two sides as @bolthub/pay. The buyer side calls L402-gated HTTP APIs (L402Client) and paid MCP tools (ToolClient); the seller side prices your own tools (create_paywall). The seller and MCP-buyer APIs mirror @bolthub/pay name-for-name (snake_case) and landed in bolthub 0.4.0.

Install

pip install bolthub

Minimal dependencies. Only one runtime dependency (httpx). No other third-party packages in your supply chain.

Quick start

from bolthub import L402Client, LndWallet

wallet = LndWallet(
    host="https://your-lnd-node:8080",
    macaroon="0201036c6e...",
)

client = L402Client(wallet, budget_sats=10_000)

resp = client.get(
    "https://acme.gw.bolthub.ai/v1/market-data",
    params={"symbol": "BTC"},
)
data = resp.json()

Wallet adapters

LND

from bolthub import LndWallet

wallet = LndWallet(
    host="https://your-lnd-node:8080",
    macaroon="scoped-macaroon-hex",  # bake payment-scoped, never admin.macaroon
    timeout_seconds=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)

NwcWallet takes a callable that receives a BOLT11 invoice and must return the preimage as a hex string. Plug in any NWC library you already use to handle the payment:

from bolthub import NwcWallet

# pay_fn receives the BOLT11 invoice string and returns the preimage hex
def pay(bolt11: str) -> str:
    preimage = my_nwc_client.pay_invoice(bolt11)
    return preimage

wallet = NwcWallet(pay_fn=pay)

LNbits

from bolthub import LnbitsWallet

wallet = LnbitsWallet(
    url="https://lnbits.example.com",
    admin_key="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 for new setups.

from bolthub import PhoenixdWallet

wallet = PhoenixdWallet(
    url="http://localhost:9740",
    password="your-phoenixd-password",
    timeout_seconds=35,
)

Custom wallet

Implement the WalletAdapter protocol:

class MyWallet:
    def pay_invoice(self, bolt11: str) -> str:
        preimage = my_payment_logic(bolt11)
        return preimage

Budget guards

client = L402Client(
    wallet,
    max_per_request_sats=100,  # reject invoices over 100 sats
    budget_sats=10_000,         # total spending cap
)

print(client.total_spent)       # sats spent so far
print(client.remaining_budget)  # sats remaining

Seller: charge for an MCP tool

create_paywall wraps a tool handler so an unpaid call returns a payment_required challenge and the handler runs only once a valid proof verifies — the Python port of @bolthub/pay's seller side, speaking the same Tool Payment Profile wire format. It is framework-agnostic: handlers take (args, extra) and return a dict-shaped tool result; both def and async def handlers work.

A rail needs a signing secret (32+ characters) and an invoice provider — any object with create_invoice(amount_sat, memo) -> (bolt11, payment_hash_hex):

from bolthub import create_paywall, l402_rail

class MyInvoiceProvider:
    def create_invoice(self, amount_sat: int, memo: str) -> tuple[str, str]:
        inv = my_wallet.make_invoice(amount_sat, memo)
        return inv.bolt11, inv.payment_hash

pay = create_paywall(rails=[l402_rail(os.environ["PAY_SECRET"], MyInvoiceProvider())])

# Register the tool; `resource` defaults to the tool name.
pay.tool(
    server,
    "get_satellite_image",
    "Recent high-res satellite imagery for a lat/lon and date.",
    schema,
    fetch_image,                    # (args, extra) -> ToolResult dict
    price={"amount": 2000},         # 2000 sats per call
)

Or wrap just the handler and register it yourself:

paid_handler = pay(fetch_image, price={"amount": 2000}, resource="get_satellite_image")

pay.advertise(price) builds the discovery-time advertisement for cost-aware agents, exactly like the TypeScript pay.advertise.

To delegate mint/verify to the hosted facilitator (at-most-once redemption, metering), use facilitator_rail with http_facilitator:

from bolthub import create_paywall, facilitator_rail, http_facilitator

transport = http_facilitator("https://api.bolthub.ai/facilitator", api_key)
pay = create_paywall(rails=[facilitator_rail("l402", ["sat"], transport)])

The token primitives are exported too, for building your own rail or verifier: sign_l402_token, verify_l402_token, verify_preimage, sha256_hex, random_preimage, plus the PAYMENT_META_KEY constant ("ai.bolthub/payment").

Buyer: pay for MCP tools automatically

ToolClient is the paywall's counterpart: it calls a tool, and if the result carries a payment_required challenge it pays an offer within your budget and retries. Free tools pass through untouched.

from bolthub import ToolClient, l402_payer

client = ToolClient(
    payers=[l402_payer(wallet)],     # any WalletAdapter, e.g. LndWallet
    max_total={"sat": 10_000},       # per-asset lifetime cap
    max_per_call={"sat": 500},       # per-asset per-call cap
    on_paid=lambda i: print(f"paid {i['amount']} {i['asset']} via {i['scheme']}"),
)

# call_tool handles challenge → pay → retry transparently. `mcp_client` only
# needs a call_tool(name=..., arguments=..., meta=...) method.
result = client.call_tool(mcp_client, "get_satellite_image", {"lat": lat, "lon": lon})

The budget is a hard gate: the amount is reserved (thread-safely) before the payment happens and rolled back if it fails. When every matching offer exceeds the budget, PaymentBudgetError is raised; when no configured payer matches an offered rail, the unpaid challenge result is returned so you can decide.

One budget across both buyer paths

Pass the same Budget to a ToolClient (MCP-wire payments) and an L402Client or AsyncL402Client (HTTP-402 payments, bolthub >= 0.4.1) and together they can never spend past max_total, even under concurrent calls — the same guarantee as TypeScript's @bolthub/pay. On the clients, budget= is mutually exclusive with budget_sats; the budget's max_per_call["sat"] also caps each request when max_per_request_sats is unset.

from bolthub import Budget, L402Client, ToolClient, l402_payer

budget = Budget(max_total={"sat": 10_000}, max_per_call={"sat": 500})
tools = ToolClient(payers=[l402_payer(wallet)], budget=budget)
http = L402Client(wallet, budget=budget, on_paid=print)

Both clients also take a per-request max_cost_sats= (a one-off ceiling that tightens, never loosens, the configured caps) and a per-request on_paid= for exact per-call cost attribution:

resp = http.get("https://acme.gw.bolthub.ai/v1/data", max_cost_sats=100)

Payment receipts

Parity with @bolthub/pay: configure a receipt_store and the client records one verifiable proof-of-payment receipt per paid call.

from bolthub import L402Client, FileReceiptStore, verify_receipt

client = L402Client(wallet, receipt_store=FileReceiptStore())
# ... paid calls happen ...
csv_report = client.export_receipts(format="csv", redact=True)

verify_receipt(receipt) runs the offline checks (preimage hashes to payment_hash, which equals the hash the BOLT11 invoice commits to, and the amount matches). Receipt files carry live preimages: treat them like credentials, and export with redact=True for shareable reports.

Prepaid credit (across a provider's endpoints)

Parity with @bolthub/pay. When you'll call several of one provider's endpoints, buy_credit pays once for a sats budget spendable across all of them. Ordinary calls to any of that provider's endpoints then draw the budget with no further payment, until it runs out.

# One Lightning payment for a budget usable across the provider's endpoints.
client.buy_credit("https://acme.gw.bolthub.ai/v1/data", 10_000)

# Any endpoint of acme now draws the credit: no invoice is paid.
client.get("https://acme.gw.bolthub.ai/v1/data")
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. Credit is scoped to the provider (cached per host), so buying credit for one provider never covers another, and the same budget_sats and max_cost_sats rules apply. AsyncL402Client exposes the same await client.buy_credit(...). Unused credit at expiry is non-refundable, so size it to what you expect to spend.

For URLs across several providers, batch_fetch groups them by provider, buys one credit per provider, and fetches them all. Non-custodial by construction: N providers means N payments, never a pooled balance. The async client fetches the group concurrently.

results = client.batch_fetch(
    [
        "https://acme.gw.bolthub.ai/v1/data",
        "https://acme.gw.bolthub.ai/v1/reports",
        "https://bolt.gw.bolthub.ai/v1/prices",
    ],
    credit_sats=10_000,  # sized to cover your calls per provider
)

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. The gateway enforces every caveat down the chain (most restrictive wins). Needs the optional pymacaroons dependency:

pip install bolthub[delegation]
import time
from bolthub import attenuate

# `macaroon` is the value from `Authorization: L402 <macaroon>:<preimage>`.
restricted = attenuate(
    macaroon,
    method="GET",
    valid_until=int(time.time() * 1000) + 60_000,  # 60s, tighter than the original
    n_uses=50,                # at most 50 requests
    max_sats=300,             # at most 300 sats of spend
    path_prefix="/v1/reports",  # only paths at or under /v1/reports
)
# Hand `restricted` plus the SAME preimage to the sub-agent.

Parity with @bolthub/pay: n_uses, max_sats, and path_prefix are backed by a server-side grant, so they hold across processes. Attenuation is tighten-only: each restriction is checked against the credential's existing caveats and raises ValueError if it would widen scope (raise a cap, move the expiry later, or step outside the parent's path). The gateway enforces the same folds, so a child can never escalate. The credential you narrow is a prepaid-credit credential you already hold (buy_credit first), so one payment funds every scoped child you hand out across the provider's endpoints.