AGORA.GRIPE
Lightningbetav3.0.1

Thunder Bridge

A payment gateway with no middleman you have to trust. Sats land straight in your own wallet, all it takes is one package and four files.

A normal payment processor can throw you out over your age, your country or a line of business the regulator dislikes this season. Running your own Lightning node is the other extreme, because channels, liquidity and backups are a full-time job nobody wants before knowing whether the idea even breathes.

Thunder Bridge is the shortcut in between. The payer pays an invoice your own wallet issued, so the gateway never holds your sats and has nothing to take. Once the payment lands, your wallet releases the preimage, and that is the proof the money actually moved. Not an operator's promise, maths. 🧡

No node

Nothing to run, nothing to back up. The wallet you already have is the whole infrastructure.

No custody

Sats go straight into your wallet. They never pass through the gateway, not even for a second.

No fee

Nobody holds your money, so there is no cut to take.


// wallet

An address first

All of it rests on a Lightning address whose server speaks LUD-21, because that is what releases the preimage once someone pays. Blink does, and it takes two minutes to set up.

  1. Get Blink from the App Store or Google Play and pick the non-custodial wallet during setup, twelve words and the keys on your side.
  2. On the home screen, tap Settings in the upper right.
  3. Tap Your Blink address, then Set your Blink Address, and pick a username with no spaces and no special characters.
  4. Done, the address reads name@blink.sv. Paste it into the demo below and see whether the sats land.

Leave the custodial version alone. Keys somebody else holds are somebody else's money. Blink also stopped payments on custodial accounts on 31 August 2026, so it is a dead end in practice too. The non-custodial account runs on Spark, with an exit to the chain, and the name@blink.sv address stays the same.

If you would rather not use Blink

The wallet has to speak LUD-21, otherwise there is nothing to prove the payment arrived. These are the ones I measured, ranked by how much custody stays with you.

  • BTCPay Server from v2.3.8. Your own node, nobody else in the path of the money.
  • The Spark-based wallets. Cake.cash, breez.tips, stacked.cash or Blitz Wallet. Self-custody with an exit to the chain.
  • Alby Hub. Your own LDK node, addressed through getalby.com.
  • coinos. Open source and self-hostable. The hosted coinos.io is custodial.
  • stacker.news and Speed work too, but they hold your money.

It does not work with Wallet of Satoshi, Strike, Cash App, ZBD, Primal, Fountain or LNbits, none of those domains publishes verify at all. ZEUS Pay and ecash.love do serve verify but release no preimage, which amounts to the same thing. Support belongs to the address domain rather than the app, so when in doubt, paste the address into the demo below. It is refused as the invoice is created, not after somebody has already paid.


// try it

Have 21 sats sent to you

Put in your address, pick an amount and scan the QR with a wallet. The invoice comes from your own wallet, the gateway is handed only its hash and a URL to poll, and your browser then checks the preimage itself.

This works better on a desktop: the screen shows the QR and you pay it with your phone. On a phone you have nothing to scan your own display with, so it takes two devices.

Lightning address
How many sats

Leave it empty and the sats go to iamfatik@blink.sv. Most wallets refuse to pay their own invoice, so scan from a wallet other than the one behind the address.

YOUthe payerWALLETrecipient, LUD-21THIS SITEserver and browserGATEWAYwatch only1 · resolve the addressserver side2 · bolt11 and payment hashserver side3 · POST /watched-paymentsserver side4 · challenge, prove you askedserver side5 · you pay the bolt11your wallet6 · the gateway polls /verifyserver side, about every 5 s7 · ask the walletserver side8 · the preimageserver side9 · settled, with the preimageserver side10 · settlement, ed25519in your browser11 · checks the hash itselfin your browsersha256(preimage) has to be that payment hash, or it proves nothing
  1. 1THIS SITE -> WALLETresolve the addressserver side
  2. 2WALLET -> THIS SITEbolt11 and payment hashserver side
  3. 3THIS SITE -> GATEWAYPOST /watched-paymentsserver side
  4. 4GATEWAY -> THIS SITEchallenge, prove you askedserver side
  5. 5YOU -> WALLETyou pay the bolt11your wallet
  6. 6GATEWAY -> THIS SITEthe gateway polls /verifyserver side, about every 5 s
  7. 7THIS SITE -> WALLETask the walletserver side
  8. 8WALLET -> THIS SITEthe preimageserver side
  9. 9THIS SITE -> GATEWAYsettled, with the preimageserver side
  10. 10GATEWAY -> THIS SITEsettlement, ed25519in your browser
  11. 11THIS SITE -> WALLETchecks the hash itselfin your browser

sha256(preimage) has to be that payment hash, or it proves nothing

happenedright nowserver side, unseen hereproved here

// code

Four files, the whole payment

The client is on npm as thunder-bridge. It touches only fetch, crypto.subtle, URL and WebSocket, so it runs in a browser, on Node 22 and up, on Bun, on Deno and on Cloudflare Workers. The other half, invoiceFrom and serve.lightningVerify, asks the wallet itself and needs node:dns for it, so it belongs on a server and will not run on Workers.

bash
npm install thunder-bridge

On your server: the invoice and the watch

app/api/invoice/route.ts
import { invoiceFrom, msat, relayedVerifyUrl, ThunderBridge } from "thunder-bridge";

const CALLBACK_SECRET = process.env.CALLBACK_SECRET as string;
const payWithinSecs = 3600;

const gateway = new ThunderBridge("https://public.thunder-bridge.agora.gripe", {
  secret: CALLBACK_SECRET,
});

export async function POST(): Promise<Response> {
  const invoice = await invoiceFrom(["you@blink.sv"], msat(21_000));

  const watched = await gateway.watch({
    paymentHash: invoice.paymentHash,
    verifyUrl: await relayedVerifyUrl(
      "https://your.site/api/verify",
      { url: invoice.verifyUrl, hash: invoice.paymentHash },
      CALLBACK_SECRET,
    ),
    expiresAt: Math.min(invoice.expiresAt, Math.floor(Date.now() / 1000) + payWithinSecs),
  });

  return Response.json({ id: watched.id, paymentHash: invoice.paymentHash, bolt11: invoice.bolt11 });
}
app/api/verify/route.ts
import { ThunderBridge } from "thunder-bridge";

const gateway = new ThunderBridge("https://public.thunder-bridge.agora.gripe");

const serveVerify = gateway.serve.lightningVerify({
  secret: process.env.CALLBACK_SECRET as string,
});

export const GET = serveVerify;
export const POST = serveVerify;

CALLBACK_SECRET is one random string you generate with openssl rand -hex 16 and set on both routes. It seals the wallet's URL into your own, so the gateway never learns the host, and the same route unseals it again. It has to answer both methods: GET is the gateway asking whether it is paid, POST is the one-off challenge it uses to check the route is yours before it takes the payment on. payWithinSecs is your call on how long the invoice stays payable, the gateway itself watches for up to 30 days.

In the browser: the QR and the wait

app/checkout/pay.ts
import { ThunderBridge } from "thunder-bridge";
import { invoiceToSvg } from "thunder-bridge/qr";

const gateway = new ThunderBridge("https://public.thunder-bridge.agora.gripe");

export async function payInto(target: HTMLElement): Promise<boolean> {
  const minted = await fetch("/api/invoice", { method: "POST" });
  const { id, paymentHash, bolt11 } = (await minted.json()) as {
    id: string;
    paymentHash: string;
    bolt11: string;
  };

  target.innerHTML = invoiceToSvg(bolt11);

  const settled = await gateway.settled({ id, paymentHash });

  return settled.status === "paid";
}

The browser gets no secret, only an id, a payment hash and a bolt11. It draws the QR and waits on a socket. settled returns paid only once the preimage hashes to that payment hash, otherwise it throws GatewayCheatError.

Webhook

app/api/settled/route.ts
import { ThunderBridge } from "thunder-bridge";

declare function fulfil(paymentId: string, preimage: string): Promise<void>;

const gateway = new ThunderBridge("https://public.thunder-bridge.agora.gripe");

export const POST = gateway.serve.webhook({
  onSettled: async (settlement) => {
    await fulfil(settlement.id, settlement.preimage);
  },
});

The gateway signs every delivery with the key it publishes at /webhook-key, and there is no shared secret to register, so it holds nothing of yours. It challenges your URL before it takes the payment on and refuses it if nothing answers, so deploy the handler first and register it second. Delivery is at-least-once, so deduplicate on id. fulfil is your own function, the declaration only marks where the package ends and you begin.


// what else

What else the package does

Above is one payment from end to end. These three are not on this page, and they are the more interesting ones.

followTrigger

A trigger is a place rather than a payment. Every payment carrying the same secret belongs to it, so one QR on the wall and a process that reacts to each settled invoice. It holds a socket, so it belongs on something that stays up.

lnurlPayEndpoint

A whole lightning address as one fetch handler. A static QR points at your own domain instead of the gateway, nothing is stored, and it runs on Deno Deploy or Cloudflare Workers.

createQuote

Asks which address on your list would take an amount and mints nothing doing it. A probe, not a promise, because whether a wallet issues a provable invoice is only knowable by asking for one.


// what it cannot do

Where the edges are

So it is clear what you are buying.

  • The recipient must speak LUD-21. Which domains do and which do not is up above, under picking a wallet.
  • No privacy layer. The payer sees your real invoice and your wallet provider's node.
  • No BOLT12. Fetching an invoice from an offer needs a node.
  • The preimage proves your server released it. It protects the payer against the gateway, not against a recipient inflating their own totals.
  • Alpha. The gateway runs as a single replica on my own cluster, and a second instance is what makes it durable, not the disk. The one behind the demo above keeps its ledger in memory, so a restart forgets everything.