thunder-bridge
v0.7.0
Published
JavaScript client for the Thunder Bridge Lightning donation proxy. REST API + WebSocket with PoW anti-spam.
Maintainers
Readme
thunder-bridge
Accept Lightning donations and relay sats to any Lightning wallet. Hook up automations: trigger functions on your web app, ping a server, control IoT devices.
REST API + WebSocket + PoW anti-spam + QR codes + webhook verification. Works in Node.js, Bun, Deno, browsers, and edge runtimes. Webhook verification requires node:crypto (Node.js, Bun, Deno).
Quick start
import { ThunderBridge } from 'thunder-bridge';
const gw = new ThunderBridge('https://thunder-bridge.agora.gripe');
// Create a donation
const { id, bolt11 } = await gw.createDonation({
destination: '[email protected]',
amountSat: 100,
});
// Wait for fulfillment via WebSocket
const status = await gw.waitForFulfillment(id, {
onStatusChange(s) { console.log('status:', s); },
});
console.log(status); // "success" | "failed" | "expired"API
new ThunderBridge(gatewayUrl, options?)
const gw = new ThunderBridge('https://thunder-bridge.agora.gripe');
// Verification is strict by default. Opt into a named risk with `allow`:
const lenient = new ThunderBridge('https://gateway.example', {
allow: {
custodialFallback: true, // accept custody (gateway holds funds first)
unverifiedRecipient: true, // proceed when the address did not resolve at all
unboundRecipient: true, // proceed when only the provider, not the account, was confirmed
},
});| Option | Type | Description |
|--------|------|-------------|
| autoVerify | boolean? | Verify the gateway's invoice automatically and throw on a cheat. Default true |
| allow | AllowPolicy? | Per-risk opt-outs from the strict default (all false). See above |
| allowUnverifiedRecipient | boolean? | Deprecated, use allow.unverifiedRecipient |
gw.createDonation(params)
Create a new donation. Automatically solves a PoW challenge if required.
const { id, bolt11, invoiceHash, forwardingMode } = await gw.createDonation({
destination: '[email protected]', // Lightning Address
amountSat: 100, // amount in satoshis
webhookUrl: 'https://myapp.com/hook', // optional
webhookSecret: 'my-hmac-secret', // optional HMAC-SHA256 signing key
});The trustless path is demanded by default. Set allow.custodialFallback: true
on the client to accept custody instead.
| Param | Type | Description |
|-------|------|-------------|
| destination | string | Lightning Address (user@domain). BOLT12 offers are not currently accepted by the gateway |
| amountSat | number | Amount in satoshis (min 1) |
| webhookUrl | string? | URL to POST on successful donation relay |
| webhookSecret | string? | HMAC-SHA256 key for signing webhook payloads (use HTTPS) |
| requireTrustless | boolean? | Deprecated (the trustless path is now demanded by default). Use the client-level allow.custodialFallback. Still overrides the policy per call |
| maxDonorFeeSat | number? | Max sat the donor invoice may exceed the donation by during auto-verify. Default 1% of the donation (min 2 sat), a hard ceiling against extreme fee inflation. Set smaller to tighten |
| amountKind | "recipient_receives" \| "donor_pays"? | Who covers the routing fee. recipient_receives (default): the recipient is paid the full amountSat and the fee is added on top of the donor invoice. donor_pays: the donor is charged exactly amountSat and the recipient nets it minus the fee. verifyTrustless checks the matching leg |
Returns { id, bolt11, invoiceHash, amountSat, expiresAt, forwardingMode, recipientInvoice, amountKind }.
forwardingMode is "trustless" or "custodial".
Automatic verification (on by default). Before returning, createDonation
checks the gateway's invoice against your destination and amountSat, and
waitForFulfillment checks the settled preimage. A proven cheat (skim,
overcharge, chain swap, recipient swap, faked settlement) throws
GatewayCheatError with code naming the failed check, so the only input you
supply is the destination. The checks are listed under verifyTrustless below.
Disable with autoVerify: false and verify by hand.
Strict when identity cannot be confirmed. For a Lightning address the SDK
resolves the address off the gateway and binds the recipient invoice to it by
LUD-06 description_hash, which tells apart two accounts on one shared custodial
node (Wallet of Satoshi, Alby, ...) where a node id alone cannot. If it cannot
confirm the account, createDonation refuses with UnverifiedRecipientError: an
unbound address (no description_hash, or generic metadata) unless
allow.unboundRecipient, an unresolvable one (provider down, multi-node) unless
allow.unverifiedRecipient. A proven substitution always throws
GatewayCheatError, which no flag relaxes.
Run the SDK where the gateway cannot tamper with it (your backend, or a frontend you host). If the gateway serves the page or the SDK, it controls the verifier and the checks are theater. See docs/trust/ for the residuals no client code removes.
A custodial donation (the gateway's fallback when the trustless path cannot
serve it) is refused by default: the SDK demands trustless and throws
GatewayCheatError (code: "custodial_fallback"). Custody is honest, not theft,
but the bedrock refund guarantee does not cover it. Opt in with
allow.custodialFallback: true (verification then reports "custodial" and does
not throw), or read forwardingMode on the result and decide.
import {
ThunderBridge,
GatewayCheatError,
UnverifiedRecipientError,
} from 'thunder-bridge';
const gw = new ThunderBridge('https://gateway.example');
try {
const donation = await gw.createDonation({ destination, amountSat });
// safe to show donation.bolt11 to the payer
} catch (e) {
if (e instanceof GatewayCheatError) {
// the gateway demonstrably cheated. e.code: hash_mismatch | amount_skim | amount_overcharge | ...
} else if (e instanceof UnverifiedRecipientError) {
// identity could not be confirmed to the account. Use a provider that binds
// by description_hash, or set allow.unboundRecipient /
// allow.unverifiedRecipient to accept the risk.
}
}See verifying trustlessness and the trust vault in docs/trust/.
gw.createDonationForInvoice(recipientInvoice, options?)
Forward to a recipient invoice you supply, the bring-your-own-invoice path.
You resolve the recipient yourself (see resolveInvoice below, or decode any
BOLT11 you already hold) and hand the gateway a finished invoice instead of an
address. The gateway then chooses nothing about the recipient, it relays to the
exact invoice, so its trust drops to zero on who gets paid and you trust it for
nothing beyond relaying. This is the strongest trust posture and works for any
wallet, because it rests on no provider signal, only on the donor holding the
choice.
import { resolveInvoice } from 'thunder-bridge';
const invoice = await resolveInvoice('[email protected]', 100);
const donation = await gw.createDonationForInvoice(invoice);
// safe to show donation.bolt11 to the payer| Param | Type | Description |
|-------|------|-------------|
| recipientInvoice | string | A BOLT11 invoice carrying a whole-sat amount, the destination the gateway must pay |
| options.maxDonorFeeSat | number? | Max sat the donor invoice may exceed the recipient amount by. Default 1% (min 2 sat) |
| options.webhookUrl | string? | URL to POST on successful relay |
| options.webhookSecret | string? | HMAC-SHA256 key for signing webhook payloads |
Returns the same CreateDonationResult as createDonation. The trustless path
is always required (a custodial relay would defeat the donor's reason for
choosing the invoice), so a custodial response throws GatewayCheatError
(code: "custodial_fallback"). Verification confirms the gateway's donor invoice
reuses your invoice's payment hash, checked against your own copy and not the
gateway's echo, so settling it can only pay your recipient. There is no identity
step, you already chose the recipient. A mismatched hash, amount, or chain throws
GatewayCheatError.
resolveInvoice(address, amountSat) fetches a recipient invoice off the gateway
over LNURL, returning null when resolution is unavailable (a CORS-blocked
browser, a provider down, a malformed address). Server-side it is unrestricted,
in a browser it is subject to the provider's CORS policy. See
the BYOI design for the flow and the never-at-a-loss
economics on the operator side.
gw.waitForFulfillment(donationId, options?)
Subscribe to donation status updates via WebSocket. Resolves when the donation reaches a terminal status (success, failed, or expired).
const status = await gw.waitForFulfillment(id, {
onStatusChange(s) { console.log(s); },
timeoutMs: 600_000, // 10 minutes
});gw.getDonation(id)
Fetch a single donation by ID. Rate-limited to 60 req/min per IP.
const donation = await gw.getDonation('550e8400-...');
console.log(donation.status, donation.amount_sat);
console.log(donation.forwarding_mode); // "trustless" | "custodial"
// On success, donation.preimage proves payout: sha256(preimage) === invoice_hash.verifyTrustless(donation)
Verify a donation forwarded trustlessly, without trusting the gateway (when you
run this SDK yourself, not a copy the gateway serves). Decodes
both the donor invoice you pay and the recipient invoice locally and confirms
they share a payment hash, so the gateway cannot settle your payment without
paying the recipient. It checks the amounts against the fee mode (amount_kind).
One leg is pinned to the donation exactly and the other may differ only by a fee
allowance, so the gateway cannot skim the recipient or overcharge the donor
beyond the fee. In the default recipient_receives the recipient invoice equals
the donation, with no skim. In donor_pays the donor invoice equals the donation
and the recipient nets it minus the bounded fee. It also confirms both invoices
are on the same chain, no testnet hash reuse. Once the donation succeeds it also
confirms sha256(preimage) equals that hash.
It accepts the createDonation result too, so you can gate before showing the
invoice to a payer: only display it when the verdict is "verified".
import { verifyTrustless } from 'thunder-bridge/lowlevel';
const created = await gw.createDonation({ destination, amountSat });
// `created` carries the amount you requested, so the hash, amount, and (once
// settled) preimage are all checked with no extra argument:
if (verifyTrustless(created) !== 'verified') {
throw new Error('gateway returned an untrustworthy invoice, not showing it');
}
// safe to display created.bolt11 to the payer
// after settlement, the same check also proves payout:
const status = verifyTrustless(await gw.getDonation(created.id));
// "verified" | "custodial" | "hash_mismatch" | "amount_skim" | "amount_overcharge"
// | "amount_mismatch" | "network_mismatch" | "preimage_mismatch"
// | "recipient_mismatch" | "recipient_undecodable"To also catch a gateway forwarding to a different recipient than intended,
pin the recipient's node public key (obtained out of band from the recipient)
and pass it. The recipient invoice's payee node id is recovered from its
signature and compared, so a substituted recipient returns "recipient_mismatch"
and you refuse to show the invoice:
const RECIPIENT_NODE_ID = '03e7156a...'; // pinned, from the recipient directly
if (verifyTrustless(created, { expectedRecipientNodeId: RECIPIENT_NODE_ID }) !== 'verified') {
throw new Error('recipient substitution or untrustworthy invoice, not showing it');
}"hash_mismatch", "amount_skim", "amount_overcharge", "amount_mismatch",
"network_mismatch", and "preimage_mismatch" mean the gateway's claim did not
hold. "recipient_undecodable"
means the recipient invoice could not be decoded (for example a lno1... or
garbage value): the SDK money checks cannot run, so treat it as unverified and do
not show the invoice.
A Lightning address is anchored by its DNS and TLS resolution plus the LUD-06 description_hash bind, which distinguishes accounts on one shared custodial node. BOLT12 offers, whose identity is cryptographic and DNS-free, are not currently accepted by the gateway. See verifying trustlessness.
verifyBolt12Donation(donation, offer) (BOLT12 recipients)
Not currently usable through this gateway. BOLT12 offer forwarding is
disabled, so the gateway rejects a lno1... destination at createDonation and
there is no BOLT12 donation to verify. This entry point ships for when BOLT12 is
re-enabled. It needs the optional boltz-bolt12 peer dependency
(npm install boltz-bolt12).
When usable, it parses the recipient invoice (rejecting a forged/tampered
signature), confirms the invoice's node id equals the offer's issuer key, confirms
the payment hash matches the donor invoice, and checks the amounts against the fee
mode (amount_kind) just like the BOLT11 path, one leg pinned to the donation and
the other within the fee allowance. Unlike the BOLT11 path it does not check the
chain (a BOLT12 invoice does not expose it here).
gw.estimateFees(amountSat, amountKind?)
Estimate relay fees for a given amount. Rate-limited to 60 req/min per IP. Pass
amountKind ("recipient_receives" default, or "donor_pays") to preview the
mode you will use: it shifts the fee onto the matching leg.
const fees = await gw.estimateFees(1_000);
console.log(`Donor pays: ${fees.donor_pays_sat} sat`);
console.log(`Recipient receives: ${fees.relay_estimate_sat} sat`);
console.log(`Total fee: ${fees.total_fee_estimate_sat} sat`);Triggers (IoT)
Triggers are Lightning-powered buttons. Someone donates, your device reacts. Each trigger has a fixed price and a Lightning address for QR codes.
Create a trigger
const trigger = await gw.createTrigger({
priceSat: 100, // fixed price
name: 'Fountain', // optional display name
forwardTo: '[email protected]', // optional: relay donation to this address
invoiceExpirySecs: 86400, // optional: invoice valid for 1 day
});
console.log(trigger.ln_address); // "[email protected]"
console.log(trigger.hash); // "a1b2c3" (use for WS + stats)Triggers without forwardTo are signal-only: the IoT device gets notified, funds stay in the node.
Listen for donations (continuous stream)
const cleanup = gw.streamTrigger(trigger.hash, {
onDonation(d) {
console.log(`Donated ${d.amount_sat} sats, signal: ${d.signal_only}`);
// activate fountain, ring bell, unlock door, ...
},
onConnect() { console.log('Connected'); },
reconnect: true, // auto-reconnect on disconnect (default)
reconnectDelayMs: 3000,
});
// Later: cleanup() to disconnectOn connect, the last 10 successful donations are replayed so the device can catch up after a restart.
Wait for a single donation
const donation = await gw.waitForTrigger(trigger.hash, {
timeoutMs: 300_000,
});
console.log(donation.amount_sat, donation.signal_only);Get trigger stats
const stats = await gw.getTriggerStats(trigger.hash);
console.log(`${stats.total_payments} donations, last at ${stats.last_payment_at}`);Generate QR code from Lightning address
import { invoiceToSvg } from 'thunder-bridge';
import { invoiceToDataUrl } from 'thunder-bridge/lowlevel';
const svg = invoiceToSvg(trigger.ln_address);
const dataUrl = invoiceToDataUrl(trigger.ln_address);Donation event format
{
"event": "donation",
"amount_sat": 100,
"signal_only": false,
"timestamp": 1744382400
}signal_only is true when the amount is too small to relay (fee exceeds amount). timestamp is Unix seconds.
Anti-spam
- One active invoice per trigger at a time (reused until fulfilled or expired)
- Unused triggers (zero donations) are auto-deleted after 7 days
- PoW challenge required after too many requests from one IP
Webhooks
When you provide webhookUrl on donation creation, the server POSTs a JSON payload to that URL immediately after a successful relay. If delivery fails, it retries with increasing delays: 10s, 1m, 2m, 5m, 10m, 30m, 1h, then hourly up to 1 week.
Payload
{
"event": "donation.success",
"donation": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"destination": "[email protected]",
"amount_received_sat": 100,
"amount_sent_sat": 95,
"network_fee_sat": 5,
"status": "success",
"error_message": null,
"created_at": "2025-03-28T10:30:00Z",
"updated_at": "2025-03-28T10:31:00Z"
},
"signal_only": false
}Headers
| Header | Description |
|--------|-------------|
| Content-Type | application/json |
| X-Event-Type | donation.success |
| X-Signature-256 | HMAC-SHA256 hex signature (only if webhookSecret was provided) |
Verifying signatures
The client exports helpers to verify and parse webhook payloads:
parseWebhookRequest(request, secret) / Fetch API (Hono, Next.js, SvelteKit, Remix, Deno, Bun)
import { parseWebhookRequest } from 'thunder-bridge';
// Hono
app.post('/webhook', async (c) => {
const payload = await parseWebhookRequest(c.req.raw, MY_SECRET);
if (!payload) return c.text('Bad signature', 401);
console.log('Donation succeeded:', payload.donation.id);
return c.text('OK');
});// Next.js app router (app/api/webhook/route.ts)
import { parseWebhookRequest } from 'thunder-bridge';
export async function POST(req: Request) {
const payload = await parseWebhookRequest(req, process.env.WEBHOOK_SECRET!);
if (!payload) return new Response('Bad signature', { status: 401 });
// payload.donation is fully typed
return new Response('OK');
}parseWebhook(body, signature, secret) / Express / Fastify / raw body
import { parseWebhook } from 'thunder-bridge/lowlevel';
// Express (with express.raw() middleware on this route)
app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
const sig = req.headers['x-signature-256'] as string;
const payload = await parseWebhook(req.body, sig, MY_SECRET);
if (!payload) return res.status(401).send('Bad signature');
console.log(payload.donation.id);
res.sendStatus(200);
});verifyWebhookSignature(body, signature, secret)
import { verifyWebhookSignature } from 'thunder-bridge/lowlevel';
const valid = await verifyWebhookSignature(rawBody, signature, secret);All functions are async. All signature checks use constant-time comparison.
QR codes
Generate QR codes for any Lightning destination: BOLT11 invoices, BOLT12 offers, or Lightning addresses. Returns SVG strings, no canvas or browser APIs needed. Each input type is encoded per its own spec, since the rules differ.
import { invoiceToSvg } from 'thunder-bridge';
import { invoiceToDataUrl, encodeForQr } from 'thunder-bridge/lowlevel';
// BOLT11 invoice
const svg = invoiceToSvg(bolt11, { size: 300, color: '#1a1a2e' });
// BOLT12 offer
const offerQr = invoiceToSvg('lno1qgsq...');
// Lightning address (for triggers)
const triggerQr = invoiceToSvg('[email protected]');
// Data URL (for <img src="...">)
const dataUrl = invoiceToDataUrl(bolt11);
// If you want to drive your own QR library, get just the encoded payload:
const payload = encodeForQr('lno1qgsq...'); // -> "LNO1QGSQ..."Encoding rules
| Input | Encoding | Reference |
|---|---|---|
| BOLT11 invoice (lnbc...) | LIGHTNING:LNBC... (uppercase) | BIP 21, BOLT 11 §QR |
| BOLT12 offer (lno1...) | LNO1... (uppercase, no scheme) | BOLT 12 §encoding |
| LN address (LUD-17) | LIGHTNING:user@domain (case preserved) | LUD-17 |
The lightning: URI scheme is BOLT11-specific. Wrapping a BOLT12 offer in LIGHTNING:LNO1... causes wallets that recognise the scheme to try parsing the body as BOLT11 and reject it as invalid. The unified URI form for offers is BIP 321's bitcoin:?lno=..., which this library does not generate.
Pass { withPrefix: false } to emit the raw input unchanged (debug only).
Utilities
Lightning address detection
Basic format checks for UI heuristics (icon switching, input hints). Full validation happens server-side.
import { detectDestinationType } from 'thunder-bridge';
import { isLnAddress, isBolt12Offer } from 'thunder-bridge/lowlevel';
isLnAddress('[email protected]'); // true
isBolt12Offer('lno1qgsqvgnwgcg35z6...'); // true
detectDestinationType('[email protected]'); // "lnAddress"Proof of work (automatic)
The server requires proof-of-work when an IP creates too many open invoices (anti-spam). createDonation() handles this transparently, picking the fastest available strategy:
node:crypto(native C++): Node.js, Bun, Deno- Pure JS SHA-256: Cloudflare Workers, edge runtimes
crypto.subtle(async, yielding): browsers
All types (Donation, DonationStatus, CreateDonationParams, CreateTriggerParams, Trigger, TriggerDonation, TriggerPublicStats, StreamTriggerOptions, WebhookPayload, etc.) are exported from the package root.
Lower-level primitives (verifyTrustless, classifyIdentity, preimageMatchesHash, resolveLnAddress, the bolt11* decoders, encodeForQr, invoiceToDataUrl, parseWebhook, verifyWebhookSignature, and the isBolt11Invoice/isBolt12Offer/isLnAddress detectors) and their types live under the thunder-bridge/lowlevel subpath, which needs a moduleResolution of node16, nodenext, or bundler.
Claude Code skill
If you build with Claude Code, the package ships a skill that teaches Claude the current SDK surface, naming conventions, and integration patterns. Install it once and Claude will use it automatically when you write thunder-bridge code.
# Install into the current project (./.claude/skills/)
npx thunder-bridge install-skill
# Or install globally for your user (~/.claude/skills/)
npx thunder-bridge install-skill --global
# Overwrite an existing copy
npx thunder-bridge install-skill --forceThe CLI is bundled with the npm package, so no separate install step is needed. The skill source lives at node_modules/thunder-bridge/skills/thunder-bridge-sdk/SKILL.md if you want to inspect or vendor it.
License
MIT
