Lightning Network Casino Payouts: 14-Step API, 1.86s [2026]

Card processors charge an online casino 3–6% per transaction, claw money back through chargebacks, and still make a winning player wait three to five business days for a payout. The Bitcoin Lightning Network flips every one of those numbers. In a 30-day production pilot run through Voltage’s managed iGaming infrastructure, a single operator pushed 88.2 BTC across 237,000 payments with an average end-to-end settlement of 1.86 seconds, a 99.94% success rate, and routing fees under $0.01 per transaction – roughly 1,000× cheaper than cards on a percentage basis.

This tutorial shows you how to build the payout side of that stack: a production Lightning cashier microservice on Core Lightning (CLN), using the bundled clnrest plugin and rune authentication, LNURL-withdraw for one-tap cashouts, BOLT12 offers for reusable deposit codes, hold invoices for bet escrow, an idempotent settlement ledger, a reconciliation sweeper, and AML/KYC gating before any money leaves your node. Every step ships working code, expected output, and the failure modes that the marketing blogs never mention.

Google · Preferred Sources

Don't miss new tech stories on Google

Add Tech Insider once in the Google app and our stories appear in your news suggestions.

Add Now

Beyond the Standard Coverage: What the Top-Ranked Lightning Guides Skip

Before writing a line of code, it is worth naming the gap this article fills. The three pages that currently rank for Lightning-casino queries are useful primers but stop well short of an implementation. Voltage’s own blog (around 816 words) frames the problem – Bitcoin’s Replace-By-Fee mempool friction, fraud reduction, revenue expansion – but contains zero code. Bitcoin Magazine (≈1,546 words) argues that payout speed is the real iGaming battleground yet never touches an API. CCN’s analysis (≈2,847 words) is the most thorough of the three, walking through how Lightning works, its benefits, and its downsides for gamblers, but it is written for players choosing a casino, not engineers building one.

The concrete gaps this tutorial closes

None of the ranking pages explain how to credit a deposit exactly once when your node confirms a payment but your service crashes before writing to the database. None mention LNURL-withdraw, the protocol that turns a casino cashout into a single QR scan. None cover BOLT12 – the reusable-offer standard that Core Lightning pioneered – even though it removes the single biggest UX wart of BOLT11 (one invoice per deposit). And none address the compliance reality that a licensed operator paying out crypto must still satisfy the FATF Travel Rule and KYC obligations before releasing funds.

What you will have at the end

A runnable Node.js/TypeScript service exposing deposit, withdrawal, and escrow endpoints; a Postgres ledger that survives restarts; a reconciliation job that catches the inevitable edge cases; and a go-live checklist you can hand to a compliance officer. We build against Core Lightning specifically because its rune-based auth, native BOLT12, and clnrest REST surface make it the cleanest production target in 2026 – a deliberate contrast to the LND-REST approach most existing material assumes.

Why Lightning Payouts Win in 2026: The Numbers

Lightning is no longer a 2018 science experiment. The network now carries real institutional volume, and the casino-relevant benchmarks have moved from “promising” to “boring infrastructure” – which is exactly what a payments team wants. The table below pulls only 2025–2026 figures so you can size the opportunity honestly.

Metric Card processor Bitcoin on-chain Lightning (2026 pilot)
Median settlement 1–5 business days 10–60 min (1–6 confs) 1.86 seconds
Success rate ~85–95% (declines, fraud holds) ~99% (fee-dependent) 99.94%
Cost per payout 3–6% + fixed fee $0.50–$5 (variable) < $0.01 (~0.0029%)
Chargeback risk High (180-day window) None (final) None (final)
Cross-border FX + correspondent fees Native Native, instant

Network maturity behind the numbers

The capacity to support casino-scale flow is there. Public network trackers put aggregate Lightning capacity above 5,000 BTC in 2026 (up from roughly 4,000 BTC earlier in the year), spread across approximately 17,000 nodes and 40,000 active channels – fewer nodes than the 2022 peak, but more concentrated, better-capitalised liquidity. Lightning now accounts for an estimated 15% of merchant Bitcoin payments, nearly double the 2023 share, per a Coinlaw report.

Signals that institutions trust the rails

On January 28, 2026, Secure Digital Markets (SDM) executed a $1 million transfer to Kraken over Lightning – the first publicly acknowledged seven-figure transaction on the protocol – routed through Voltage’s managed nodes. Infrastructure firm LQWD Technologies reported routing over 2,012 BTC across 2 million transactions by December 2025. When seven-figure treasury transfers and million-transaction routers run on the same rails as your cashier, the “is this production-ready?” question is settled.

Prerequisites and Stack Versions (June 2026)

Pin your versions. Lightning tooling moves fast, and a casino cashier is the wrong place to discover a breaking change in a minor release. Everything below is what we tested against for this tutorial; treat it as a known-good baseline rather than “latest at all costs.”

Component Version (June 2026) Role
Bitcoin Core v29.0 Layer-1 backend / chain source
Core Lightning (CLN) v25.05 (CalVer) Lightning node implementation
clnrest plugin bundled with CLN REST + websocket API surface
Node.js 24 LTS (“Krypton”) Cashier microservice runtime
TypeScript 5.7 Type safety for money code
PostgreSQL 17 Ledger + idempotency store
Express 5.x HTTP framework
Redis 7.4 Rate limits, circuit-breaker state

Skills and access you need first

You should be comfortable running a Bitcoin full node, working with REST APIs, and writing SQL transactions. You will need a server with at least 4 vCPU, 8 GB RAM, and a fully-synced Bitcoin Core (≈700 GB pruned-off, or use a pruned node with a watch-only setup). For mainnet liquidity you will also need either inbound channels purchased from a liquidity marketplace or a managed node from a Lightning infrastructure provider.

Self-host vs managed: choose before Step 1

Running your own CLN node gives you full custody and the lowest marginal cost; a managed provider like Voltage gives you uptime SLAs and one-click liquidity at a monthly fee. For a regulated casino, most teams start managed to hit time-to-market, then migrate liquidity to self-hosted nodes once volume justifies the operations headcount. This tutorial’s code is identical either way – only the connection string changes.

Architecture: The Lightning Cashier Microservice

Keep the Lightning logic in one bounded service. Your game servers, wallet ledger, and risk engine should never talk to the node directly; they talk to the cashier, and the cashier owns every interaction with CLN. This boundary is what lets you swap LND for CLN, add a second node, or fail closed during an incident without touching game code.

The three money flows

A casino cashier handles exactly three flows. Deposits: the player funds their balance by paying an invoice or BOLT12 offer your node issues. Withdrawals: the player cashes out, and your node pays an invoice the player presents (typically via LNURL-withdraw). Escrow: for certain bet types you hold funds in-flight using a hold invoice and only settle once the game resolves. Each flow crosses a trust boundary, so each gets idempotency, an audit row, and a compliance gate.

Components and data ownership

The service has four internal modules: a node client (wraps clnrest), an invoice/offer manager, a settlement listener (subscribes to paid invoices), and a payout engine (gated by KYC/AML). Postgres is the source of truth for balances and idempotency keys; Redis holds ephemeral state (rate limits, circuit-breaker status). The node itself is treated as an external system that can fail, lag, or restart at any moment – never as your ledger.

┌─────────────┐     ┌──────────────────────── Cashier Service ───────────────────────┐
│ Game / UI   │────▶│  Express API                                                    │
└─────────────┘     │   ├── /deposit/invoice   (BOLT11)   ─┐                          │
                    │   ├── /deposit/offer     (BOLT12)    │   ┌─────────────┐        │
┌─────────────┐     │   ├── /.well-known/lnurlp/:user      ├──▶│ clnrest     │──▶ CLN │
│ Risk / KYC  │◀───▶│   ├── /withdraw/lnurl   (withdraw)   │   │ (rune auth) │   node  │
└─────────────┘     │   └── settlement listener (websocket)─┘   └─────────────┘        │
                    │   Postgres (ledger, idempotency)   Redis (limits, breaker)       │
                    └──────────────────────────────────────────────────────────────────┘

Step 1 – Provision Core Lightning (Self-Host vs Voltage Managed)

Install Core Lightning on top of a synced Bitcoin Core. On Debian/Ubuntu the maintained route is the official release binaries; for production prefer building from a tagged release or using your provider’s image. The minimum config wires CLN to bitcoind and names the node so you can find it on explorers.

Minimal production config

Create ~/.lightning/config. The clnrest options below are what we will authenticate against in Step 2.

# ~/.lightning/config  (Core Lightning v25.05)
network=bitcoin
alias=casino-cashier
rgb=00d4ff
bitcoin-rpcuser=bitcoin
bitcoin-rpcpassword=CHANGE_ME
bitcoin-rpcconnect=127.0.0.1
bitcoin-rpcport=8332

# clnrest: REST + websocket surface
clnrest-port=3010
clnrest-protocol=https
clnrest-host=127.0.0.1

# Production hygiene
log-level=info
fee-base=0
fee-per-satoshi=1
autoclean-cycle=3600

Start the node and confirm it is alive. Expected output is a JSON blob with your node’s public key and "network": "bitcoin".

$ lightningd --daemon
$ lightning-cli getinfo
{
  "id": "03a1b2c3d4...e9f0",
  "alias": "casino-cashier",
  "network": "bitcoin",
  "blockheight": 901432,
  "version": "v25.05",
  "num_active_channels": 0,
  "num_peers": 0
}

Managed node alternative

If you provisioned a managed node, you receive a base URL and rune from the provider dashboard instead of running lightningd yourself. Skip the install but keep reading Step 2 – you still scope your runes, and you still own deposit/withdrawal idempotency on your side. The managed path is the fastest way to a live mainnet endpoint; the trade-off is custody and per-month cost.

Step 2 – Expose clnrest and Mint Restricted Runes

Core Lightning’s authentication primitive is the rune – a cryptographically signed cookie that can be restricted to specific RPC methods, rate limits, and parameter conditions. This is a meaningful security upgrade over the all-or-nothing admin macaroon many older guides hand to their web service. Never give your cashier an unrestricted rune.

Create a least-privilege rune

Mint one rune for the deposit/listener role (read invoices, create invoices) and a separate, tightly-scoped rune for the payout role. Separate runes mean a leaked deposit credential cannot drain your node.

# Deposit + listener rune: may create/inspect invoices and offers only
$ lightning-cli createrune restrictions='[["method=invoice","method=listinvoices","method=waitanyinvoice","method=offer","method=decode"]]'
{
  "rune": "k9vY2...deposit_rune...Qb7",
  "unique_id": "0"
}

# Payout rune: may pay, but rate-limited to 60 calls/minute
$ lightning-cli createrune restrictions='[["method=pay","method=listpays"],["rate=60"]]'
{
  "rune": "m4Xz8...payout_rune...Lp2",
  "unique_id": "1"
}

Smoke-test the REST surface

Call clnrest directly with the deposit rune. The Rune header authenticates the request; the body carries RPC parameters as JSON.

$ curl -sk -X POST https://127.0.0.1:3010/v1/getinfo \
    -H "Rune: k9vY2...deposit_rune...Qb7" \
    -H "Content-Type: application/json" -d '{}'
{"id":"03a1b2c3d4...e9f0","alias":"casino-cashier","blockheight":901432, ... }

If you get 401 Unauthorized, your rune is wrong or expired; if you get a TLS error, pass -k for the self-signed cert in dev and pin the real cert in production.

Step 3 – Scaffold the Cashier Microservice

Now build the Node.js service that fronts CLN. Keep dependencies minimal – every package in a money path is attack surface. We use Express 5, pg for Postgres, undici for the HTTP client to clnrest, and zod for request validation.

Project setup

$ mkdir ln-cashier && cd ln-cashier
$ npm init -y
$ npm i express@5 pg undici zod ioredis pino
$ npm i -D [email protected] tsx @types/express @types/node
$ npx tsc --init --target es2023 --module nodenext

The CLN client wrapper

Centralise every node call here. One place to add retries, timeouts, circuit-breaking, and logging. Note the explicit timeout – a hung node call must never hang a player request.

// src/cln.ts
import { request } from "undici";

const BASE = process.env.CLN_URL ?? "https://127.0.0.1:3010";
const RUNE_DEPOSIT = process.env.CLN_RUNE_DEPOSIT!;
const RUNE_PAYOUT = process.env.CLN_RUNE_PAYOUT!;

async function call(method: string, params: object, rune: string) {
  const res = await request(`${BASE}/v1/${method}`, {
    method: "POST",
    headers: { "Rune": rune, "Content-Type": "application/json" },
    body: JSON.stringify(params),
    headersTimeout: 8000,
    bodyTimeout: 8000,
  });
  const json: any = await res.body.json();
  if (res.statusCode >= 400) {
    throw new Error(`CLN ${method} ${res.statusCode}: ${JSON.stringify(json)}`);
  }
  return json;
}

export const cln = {
  invoice: (p: object) => call("invoice", p, RUNE_DEPOSIT),
  listInvoices: (p: object) => call("listinvoices", p, RUNE_DEPOSIT),
  waitAnyInvoice: (p: object) => call("waitanyinvoice", p, RUNE_DEPOSIT),
  offer: (p: object) => call("offer", p, RUNE_DEPOSIT),
  pay: (p: object) => call("pay", p, RUNE_PAYOUT),
};

Step 4 – Generate Idempotent BOLT11 Deposit Invoices

A deposit starts with an invoice. The single most important property here is idempotency: if a player double-taps “deposit,” or the network retries, you must return the same invoice, not mint a second one. CLN gives you this for free through the invoice label – labels are unique, and re-issuing the same label returns an error you can catch and resolve to the existing invoice.

The deposit endpoint

// src/routes/deposit.ts
import { Router } from "express";
import { z } from "zod";
import { cln } from "../cln.js";
import { db } from "../db.js";

export const deposit = Router();

const Body = z.object({
  userId: z.string().uuid(),
  amountSat: z.number().int().positive().max(50_000_000), // 0.5 BTC cap
  idempotencyKey: z.string().min(8).max(80),
});

deposit.post("/deposit/invoice", async (req, res) => {
  const p = Body.parse(req.body);
  const label = `dep:${p.userId}:${p.idempotencyKey}`;

  // Return existing invoice if this key was seen before
  const existing = await cln.listInvoices({ label }).catch(() => null);
  if (existing?.invoices?.length) {
    return res.json({ bolt11: existing.invoices[0].bolt11, label, reused: true });
  }

  const inv = await cln.invoice({
    amount_msat: p.amountSat * 1000,
    label,
    description: `Deposit for player ${p.userId}`,
    expiry: 900, // 15 minutes
  });

  await db.query(
    `INSERT INTO deposits(label, user_id, amount_sat, payment_hash, status)
     VALUES ($1,$2,$3,$4,'pending') ON CONFLICT (label) DO NOTHING`,
    [label, p.userId, p.amountSat, inv.payment_hash]
  );

  res.json({ bolt11: inv.bolt11, label, reused: false });
});

Expected response:

{ "bolt11": "lnbc500u1p...q9x", "label": "dep:8f3...:a1b2c3d4", "reused": false }

Why a 15-minute expiry matters

Short expiries protect your liquidity accounting. An invoice that never expires ties up your mental ledger and confuses reconciliation. Fifteen minutes is long enough for a human to scan and pay, short enough that abandoned invoices clear themselves. Pair this with the autoclean-cycle you set in Step 1 to keep the node’s invoice table tidy.

Step 5 – Add LNURL-pay for One-Tap Deposits

Raw BOLT11 invoices are single-use and ugly in a UI. LNURL-pay wraps the flow so a player scans one static code (or clicks one link) and their wallet fetches a fresh invoice automatically. This is the deposit UX that modern crypto casinos ship, and it is conspicuously absent from every competitor guide.

The LNURL-pay handshake

LNURL-pay is two endpoints. The first returns metadata and a callback URL; the second mints an invoice for the requested amount. Wallets call them in sequence.

// src/routes/lnurlp.ts
import { Router } from "express";
import { cln } from "../cln.js";
export const lnurlp = Router();

const HOST = process.env.PUBLIC_HOST!; // e.g. https://pay.casino.example

// 1) Wallet hits the well-known address for a player
lnurlp.get("/.well-known/lnurlp/:user", (req, res) => {
  const user = req.params.user;
  const metadata = JSON.stringify([["text/plain", `Deposit to ${user}`]]);
  res.json({
    tag: "payRequest",
    callback: `${HOST}/lnurlp/callback/${user}`,
    minSendable: 1000,          // 1 sat, in msat
    maxSendable: 50_000_000_000, // 0.5 BTC, in msat
    metadata,
  });
});

// 2) Wallet requests an invoice for a chosen amount
lnurlp.get("/lnurlp/callback/:user", async (req, res) => {
  const amountMsat = Number(req.query.amount);
  if (!Number.isInteger(amountMsat) || amountMsat < 1000) {
    return res.json({ status: "ERROR", reason: "amount too small" });
  }
  const label = `lnurlp:${req.params.user}:${Date.now()}`;
  const inv = await cln.invoice({
    amount_msat: amountMsat, label,
    description: `LNURL deposit ${req.params.user}`, expiry: 600,
  });
  res.json({ pr: inv.bolt11, routes: [] });
});

Lightning Address compatibility

Because the first endpoint lives at /.well-known/lnurlp/:user, you automatically support Lightning Addresses in the form [email protected] – the human-readable identifier wallets resolve via that path. Players can fund their balance by sending to an email-style address, no invoice copy-paste required. This is the same UX pattern the major crypto cashiers use; if you are comparing it against fiat rails, our breakdown of crypto payment gateways for iGaming covers where managed processors fit alongside a self-hosted node.

Step 6 – Issue BOLT12 Offers for Reusable Deposit Codes

BOLT11 invoices die after one payment. BOLT12 – the offers standard Core Lightning pioneered and ships natively – gives you a single static “offer” a player can pay repeatedly. One QR code on a deposit page, reusable forever, with the payer’s wallet fetching a fresh invoice each time under the hood. No LNURL server round-trip required because the negotiation happens over the Lightning network itself. For broader market context, see our coverage of best sweepstakes casino sites.

Create a reusable offer

# A reusable "any amount" deposit offer for the casino
$ lightning-cli offer any "Casino deposit"
{
  "offer_id": "f00d...beef",
  "active": true,
  "single_use": false,
  "bolt12": "lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua9uve2rlrm9deu7xyfzr...",
  "used": false
}

Render bolt12 as a QR on the deposit page. To credit incoming BOLT12 payments, the same waitanyinvoice listener from Step 9 captures them – offers settle into your invoice stream just like BOLT11.

When to prefer BOLT12 over LNURL

BOLT12 is more private (no LNURL server logs the payer’s IP) and removes a dependency (your callback server can be down and offers still work). The trade-off is wallet support: by 2026 most CLN and Phoenix/Breez-based wallets handle BOLT12, but some custodial wallets still lag. Offer both – BOLT12 for power users, LNURL-pay for the long tail – and let the wallet pick.

Step 7 – Build the LNURL-withdraw Payout Cashier

Payouts are where the money – and the risk – actually leaves your node. LNURL-withdraw is the cleanest cashout UX: the player scans a withdraw code, their wallet generates an invoice for the allowed amount, and your service pays it. Critically, the payout must pass through your compliance gate before the pay call, never after.

The withdraw request and callback

// src/routes/lnurlw.ts
import { Router } from "express";
import { cln } from "../cln.js";
import { db } from "../db.js";
import { complianceGate } from "../compliance.js";
export const lnurlw = Router();
const HOST = process.env.PUBLIC_HOST!;

// 1) Player opens a server-issued withdraw token
lnurlw.get("/withdraw/req/:token", async (req, res) => {
  const w = await db.getWithdrawToken(req.params.token);
  if (!w || w.status !== "open") return res.json({ status: "ERROR", reason: "invalid token" });
  res.json({
    tag: "withdrawRequest",
    callback: `${HOST}/withdraw/cb/${w.token}`,
    k1: w.token,
    defaultDescription: "Casino cashout",
    minWithdrawable: w.amount_msat,
    maxWithdrawable: w.amount_msat, // fixed amount = no over-withdraw
  });
});

// 2) Wallet posts an invoice; we pay it – once
lnurlw.get("/withdraw/cb/:token", async (req, res) => {
  const token = req.params.token;
  const pr = String(req.query.pr);
  const locked = await db.lockWithdraw(token); // SELECT ... FOR UPDATE, set 'paying'
  if (!locked) return res.json({ status: "ERROR", reason: "already processed" });

  const gate = await complianceGate(locked.user_id, locked.amount_sat);
  if (!gate.ok) {
    await db.setWithdraw(token, "blocked");
    return res.json({ status: "ERROR", reason: gate.reason });
  }

  try {
    const paid = await cln.pay({ bolt11: pr, maxfeepercent: 0.5, retry_for: 60 });
    await db.setWithdraw(token, "paid", paid.payment_preimage);
    res.json({ status: "OK" });
  } catch (e) {
    await db.setWithdraw(token, "open"); // safe to retry; payment did not complete
    res.json({ status: "ERROR", reason: "payment failed, try again" });
  }
});

The fixed-amount safety rail

Setting minWithdrawable === maxWithdrawable means the player cannot present an invoice for more than you authorised. Combined with the FOR UPDATE row lock, a player who scans the same code twice gets “already processed” on the second attempt rather than a double payout. Pair this with KYC: jurisdictions that require identity verification before cashout – covered in our guide to casino KYC API integration – should fail the gate when verification is incomplete.

Step 8 – Hold Invoices for Bet Escrow and Refunds

Standard invoices settle the instant they are paid. For some products – a deposit that should be refundable if a game is voided, or a wager held until a match resolves – you want to accept the incoming payment but delay settling it. That is a hold (HODL) invoice: funds are locked in the HTLC, and you choose later whether to settle (claim) or cancel (refund) by controlling when you reveal the preimage.

How hold invoices change the flow

With a normal invoice the preimage is revealed automatically on payment. With a hold invoice you generate the preimage yourself, hand out only its hash, and the payment sits in an accepted state until you call settle or cancel. In Core Lightning this is provided by the holdinvoice plugin; LND ships it natively as addholdinvoice. The escrow logic is identical.

// src/escrow.ts – pseudocode over the holdinvoice plugin
import { randomBytes, createHash } from "crypto";
import { cln } from "./cln.js";

export async function openEscrow(userId: string, amountSat: number) {
  const preimage = randomBytes(32);
  const hash = createHash("sha256").update(preimage).digest("hex");
  const inv = await cln.call("holdinvoice", {
    amount_msat: amountSat * 1000,
    label: `escrow:${userId}:${Date.now()}`,
    description: "Wager escrow",
    payment_hash: hash,
  });
  // store preimage server-side, NEVER expose it until you decide
  return { bolt11: inv.bolt11, preimageHidden: preimage.toString("hex"), hash };
}

// Game resolved in the house's favour -> settle (claim the funds)
export const settle = (hash: string, preimage: string) =>
  cln.call("holdinvoicesettle", { payment_hash: hash });
// preimage revealed internally; player's payment finalises

// Game voided -> cancel (player is refunded automatically)
export const cancel = (hash: string) =>
  cln.call("holdinvoicecancel", { payment_hash: hash });

The expiry trap

Hold invoices are powerful but dangerous: while a payment is held, it occupies an HTLC slot and counts against channel capacity, and if you hold past the HTLC’s CLTV expiry the channel can force-close. Always settle or cancel well before expiry, and cap how long any escrow can stay open (minutes, not hours). We return to this in Pitfall 4.

Step 9 – The Idempotent Settlement Ledger

This is the step that separates a toy from a cashier. When a deposit invoice is paid, you must credit the player’s balance exactly once, even if your service restarts mid-write, the websocket reconnects, or CLN replays an event. The pattern: a long-lived listener calls waitanyinvoice, and every credit is written inside a database transaction keyed on the payment hash with a unique constraint.

The settlement listener

// src/listener.ts
import { cln } from "./cln.js";
import { db } from "./db.js";
import { log } from "./log.js";

export async function runListener() {
  // lastpay_index lets us resume exactly where we left off after a restart
  let lastIndex = await db.getCursor("invoice_index");
  for (;;) {
    try {
      const ev = await cln.waitAnyInvoice({ lastpay_index: lastIndex });
      if (ev.status === "paid") {
        await creditOnce(ev.payment_hash, ev.label, ev.amount_received_msat);
      }
      lastIndex = ev.pay_index;
      await db.setCursor("invoice_index", lastIndex);
    } catch (e) {
      log.error({ e }, "listener error, backing off");
      await new Promise(r => setTimeout(r, 2000));
    }
  }
}

async function creditOnce(hash: string, label: string, msat: number) {
  const c = await db.connect();
  try {
    await c.query("BEGIN");
    // unique (payment_hash) makes the credit idempotent
    const ins = await c.query(
      `INSERT INTO settlements(payment_hash, label, amount_sat)
       VALUES ($1,$2,$3) ON CONFLICT (payment_hash) DO NOTHING RETURNING id`,
      [hash, label, Math.floor(msat / 1000)]
    );
    if (ins.rowCount === 1) {
      await c.query(
        `UPDATE balances SET sat = sat + $2 WHERE user_id =
           (SELECT user_id FROM deposits WHERE label = $1)`,
        [label, Math.floor(msat / 1000)]
      );
    }
    await c.query("COMMIT");
  } catch (e) {
    await c.query("ROLLBACK"); throw e;
  } finally { c.release(); }
}

Why the cursor and the unique constraint together

The pay_index cursor means a restart resumes from the last processed event instead of replaying from zero or missing events in the gap. The ON CONFLICT (payment_hash) DO NOTHING means that even if the same event is reprocessed, the balance only moves once. Belt and braces: the cursor minimises duplicate work, the constraint guarantees correctness regardless.

Step 10 – Reconciliation Sweeper and Double-Credit Protection

Listeners drop. Networks partition. A reconciliation sweeper is the safety net that compares your ledger against the node’s ground truth on a schedule and repairs drift. Run it every minute; it should be boring and find nothing 99% of the time, and be a lifesaver the other 1%.

The sweep job

// src/reconcile.ts – run on a 60s cron
import { cln } from "./cln.js";
import { db } from "./db.js";

export async function reconcile() {
  // Pull recently paid invoices the node knows about
  const { invoices } = await cln.listInvoices({});
  const paid = invoices.filter((i: any) => i.status === "paid");

  for (const inv of paid) {
    const known = await db.query(
      "SELECT 1 FROM settlements WHERE payment_hash = $1", [inv.payment_hash]);
    if (known.rowCount === 0) {
      // Node says paid, ledger missed it -> credit via the same idempotent path
      await creditOnce(inv.payment_hash, inv.label, inv.amount_received_msat);
      await db.alert("reconcile_recovered", inv.label);
    }
  }

  // Inverse check: ledger says paid but node has no record -> investigate, never auto-debit
  const orphans = await db.query(
    `SELECT s.payment_hash FROM settlements s
     LEFT JOIN unnest($1::text[]) k(h) ON k.h = s.payment_hash
     WHERE k.h IS NULL AND s.created_at > now() - interval '24 hours'`,
    [paid.map((i: any) => i.payment_hash)]);
  for (const o of orphans.rows) await db.alert("reconcile_orphan", o.payment_hash);
}

The cardinal rule: never auto-debit

The sweeper may automatically credit a missed payment because the node is the source of truth for received funds. It must never automatically debit a balance, because an apparent orphan is more often a sync timing issue than a real error. Orphans raise an alert for a human, not an automatic reversal – auto-debiting on a false positive is how you anger a player who genuinely deposited.

Step 11 – Channel Liquidity and Inbound Capacity

A casino has an asymmetric liquidity profile: it needs inbound capacity to receive deposits and outbound capacity to send payouts, and the two drain in opposite directions. Get this wrong and deposits fail with “no route” or payouts stall – the single most common reason a Lightning cashier looks “down” to players.

Sizing inbound and outbound

Inbound capacity caps how much players can deposit before your channels are full on the receive side; outbound caps how much you can pay out before you run dry. With average channel capacity around 9 million sats (~$9,000 at early-2026 rates) you need many well-funded channels to well-connected routing nodes. Buy inbound liquidity from a marketplace, or use a provider that rebalances for you. Monitor the ratio continuously.

# Check the receive/spend balance of each channel
$ lightning-cli listpeerchannels | jq '.channels[] |
    {peer: .peer_id[0:12], to_us_msat, total_msat,
     inbound: (.total_msat - .to_us_msat)}'

# Liquidity health rule of thumb for a cashier:
#   keep >= 40% capacity inbound (deposits) and >= 40% outbound (payouts)

Rebalancing and just-in-time channels

When a channel skews, rebalance by circular-routing a payment out and back, or lean on providers offering just-in-time (JIT) channels that open inbound capacity on demand when a deposit arrives. Splicing (Step 22) lets you resize a channel without closing it. For high volume, a managed node that auto-rebalances is worth the fee – liquidity operations are a full-time job at scale.

Step 12 – AML Travel Rule and KYC Gating on Payouts

Lightning’s speed does not exempt a licensed operator from compliance – it makes compliance harder, because money moves in 1.86 seconds and you cannot claw it back. Your payout engine must gate on identity and AML checks before the pay call, and log an immutable evidence trail. This is the dimension every consumer-facing competitor article ignores entirely. For broader market context, see our coverage of leading online casino sites.

The compliance gate

// src/compliance.ts
import { db } from "./db.js";
import { screenAml } from "./aml.js"; // wraps your AML provider

const TRAVEL_RULE_THRESHOLD_SAT = 1_000_000; // ~1,000 USD-equivalent policy trigger

export async function complianceGate(userId: string, amountSat: number) {
  const u = await db.getUser(userId);
  if (u.kyc_status !== "verified")
    return { ok: false, reason: "KYC verification required before withdrawal" };
  if (u.self_excluded)
    return { ok: false, reason: "account self-excluded" };

  // FATF Travel Rule: collect/transmit originator + beneficiary data above threshold
  if (amountSat >= TRAVEL_RULE_THRESHOLD_SAT) {
    const aml = await screenAml({ userId, amountSat });
    if (aml.risk === "high")
      return { ok: false, reason: "manual AML review" };
    await db.writeTravelRuleRecord(userId, amountSat, aml.reference);
  }
  await db.writeEvidence(userId, "payout_gate_pass", amountSat);
  return { ok: true };
}

Why “fail closed” is non-negotiable

If your KYC provider times out or your AML screen errors, the gate must return ok: false – never default to allowing the payout. A regulator will forgive a delayed cashout; it will not forgive paying a sanctioned address because a dependency was down. The same fail-closed discipline applies to geolocation and licensing checks; if you operate across jurisdictions, layer in identity tooling like the providers compared in our iGaming KYC provider comparison and keep the verdict logic inside this gate.

Step 13 – Degraded Mode, Circuit Breaker and Fail-Closed Withdrawals

Your node will have bad minutes – a peer goes offline, liquidity dries up, the gRPC surface lags. A casino cashier must degrade predictably instead of failing in random ways that confuse players and accountants. The pattern is a circuit breaker around the pay path with an explicit degraded mode.

The circuit breaker

// src/breaker.ts
import { redis } from "./redis.js";

const KEY = "breaker:payout";
const THRESHOLD = 5;      // consecutive failures
const COOLDOWN = 60;      // seconds open

export async function withBreaker<T>(fn: () => Promise<T>): Promise<T> {
  if (await redis.get(`${KEY}:open`))
    throw new Error("DEGRADED: payouts paused, retry shortly");
  try {
    const out = await fn();
    await redis.del(`${KEY}:fails`);
    return out;
  } catch (e) {
    const fails = await redis.incr(`${KEY}:fails`);
    if (fails >= THRESHOLD) {
      await redis.set(`${KEY}:open`, "1", "EX", COOLDOWN);
      await redis.del(`${KEY}:fails`);
    }
    throw e;
  }
}

What degraded mode shows players

When the breaker is open, deposits can often continue (receiving needs only inbound capacity) while payouts queue with a clear “processing, typically under a minute” message. Never silently drop a payout request – write it to a durable queue and drain it when the breaker closes. A queued, auditable payout is recoverable; a dropped one is a support ticket and a trust hit.

Step 14 – Observability, SLOs and Go-Live Verification

You cannot operate what you cannot see. Before go-live, instrument the four golden signals for each money flow and define SLOs you will actually page on. The benchmark to beat is the pilot’s 1.86s settlement and 99.94% success – set your alerts a notch tighter so you catch drift early.

Metrics that matter

SLO Target Page when
Deposit credit latency (paid → balance) < 3 s p99 > 10 s for 5 min
Payout success rate ≥ 99.5% < 98% over 15 min
Payout settlement time < 5 s p95 > 15 s p95
Reconciliation drift 0 unrecovered any orphan > 10 min
Inbound liquidity headroom ≥ 40% < 20%

First-transaction go-live checklist

On launch day, run a real end-to-end transaction on mainnet with a small amount: issue a deposit invoice, pay it from an external wallet, confirm the balance credits exactly once, issue an LNURL-withdraw token, cash it out, and confirm the ledger, the node, and the compliance log all agree. Only after that triple-reconciliation passes do you open the cashier to players. Export /metrics for Prometheus and wire the SLOs above into your alerting before – not after – the first player deposits.

Cost Model: Lightning vs Cards vs On-Chain at Scale

The business case is the easiest part of the pitch. Model your real monthly volume and the savings are stark. Assume a mid-size operator processing $10 million/month in deposits and payouts combined.

Rail Effective fee Monthly cost on $10M Chargeback exposure Payout speed
Card processor 3.5% blended ~$350,000 High (disputes, fraud) 1–5 days
Bitcoin on-chain ~$1.50 avg/tx (50k tx) ~$75,000 None 10–60 min
Lightning ~0.0029% + node ops ~$290 routing + infra None ~1.86 s

The honest caveat on the Lightning line

That ~$290 routing figure is real, but it is not your total cost – node operations, liquidity capital (channels lock up BTC you could otherwise deploy), monitoring, and compliance staff are the actual spend. The fair comparison is not “$290 vs $350,000” but “low-five-figures of operations vs $350,000 of fees plus chargeback losses.” Even loaded with operations, Lightning is decisively cheaper at volume, and it eliminates chargeback fraud entirely – a category that has no analogue on irreversible rails.

Where cards still belong

Lightning does not replace cards for every player; mainstream users still expect a card option. The winning architecture runs Lightning as the low-cost, instant default for crypto-native players and keeps a licensed card rail for the rest. If you are building that fiat side in parallel, our comparison of licensed gambling card processors maps the approval rates and gambling-MCC support you will need.

5 Critical Pitfalls and How to Avoid Them

These are the mistakes that turn a working demo into a 3 a.m. incident. Each maps to a specific defence already built into the steps above.

Pitfall 1 – Treating the node as your ledger

If you read balances from CLN instead of Postgres, a node restart or pruned invoice silently changes a player’s balance. Fix: Postgres is the source of truth for balances; the node is the source of truth only for received funds, reconciled into your ledger via Step 10.

Pitfall 2 – Non-idempotent credits

Crediting on a websocket event without a unique constraint double-credits on reconnect or replay. Fix: the UNIQUE(payment_hash) settlement row from Step 9 makes every credit exactly-once, regardless of how many times the event fires.

Pitfall 3 – Paying before the compliance gate

Calling pay and then checking KYC is too late – the money is gone irreversibly. Fix: the gate in Step 12 returns before any pay call, and fails closed on dependency errors.

Pitfall 4 – Hold invoices held too long

An escrow held past its HTLC CLTV expiry can force-close the channel, costing on-chain fees and locking capital for days. Fix: cap escrow lifetime to minutes, alert on any escrow approaching expiry, and prefer settle/cancel decisions driven by game events, not timers.

Pitfall 5 – Ignoring liquidity direction

Plenty of total capacity but all of it outbound means deposits fail with “no route.” Fix: monitor inbound and outbound separately (Step 11), keep ≥40% headroom each way, and automate rebalancing or use JIT channels.

8 Troubleshooting Scenarios

When the cashier misbehaves, work down this table before escalating. Most “Lightning is broken” reports are liquidity or auth issues, not protocol failures.

Symptom Likely cause Fix
Deposit invoice never pays No inbound liquidity Open/buy inbound channels; check listpeerchannels inbound
Payout fails “no route” Insufficient outbound or fee cap too low Raise maxfeepercent; rebalance; add outbound capacity
401 from clnrest Wrong/expired rune or method not allowed Re-mint rune with correct method restrictions (Step 2)
Balance credited twice Missing unique constraint Add UNIQUE(payment_hash); backfill ledger
Listener stops crediting Websocket dropped, cursor stale Auto-reconnect with lastpay_index; sweeper backfills
Payment stuck “pending” In-flight HTLC, slow route Wait for retry_for window; check stuck HTLCs
Channel force-closed unexpectedly Hold invoice past CLTV expiry Cap escrow time; monitor pending HTLC expiries
Withdraw paid but token still “open” Crash between pay and DB write Reconcile via listpays; idempotent token lock prevents re-pay

Diagnosing a stuck payment

Use lightning-cli listpays and lightning-cli paystatus to inspect a hung payout. A payment in pending with attempts still routing is normal for a few seconds; one stuck for minutes usually means a route with a frozen HTLC. CLN will resolve or fail it within the retry_for window – never re-issue a duplicate pay for the same invoice while one is in flight, or you risk paying twice.

Advanced Tips: Taproot Assets Stablecoins, Keysend and Splicing

Once the core cashier is solid, three 2026-era capabilities meaningfully upgrade the player experience and your operations. Each is production-viable today with the right wallet and node support.

Stablecoins over Lightning via Taproot Assets

The single biggest player objection to Bitcoin deposits is volatility. In 2026, stablecoins issued through Taproot Assets run live over Lightning, letting players deposit and withdraw in dollar- or euro-denominated assets while still settling at Lightning speed and cost. Your cashier can accept a USD-stablecoin deposit, credit a USD balance, and pay out the same – eliminating the “my deposit lost 4% overnight” complaint without giving up instant, chargeback-free rails.

Keysend and spontaneous payouts

For promotions, rebates, or affiliate payments where you do not have an invoice, keysend lets you push a spontaneous payment to a known node public key. Use it sparingly and with the same compliance gate – a push payment is still a payout – but it removes the invoice round-trip for system-initiated disbursements like loyalty rewards.

Splicing for zero-downtime liquidity

Older nodes had to close and reopen a channel to resize it, interrupting flow and paying on-chain fees twice. Splicing, now mature in Core Lightning, lets you add or remove funds from a live channel without closing it. For a cashier, that means topping up outbound capacity ahead of a big sports weekend without taking the channel offline – a direct uptime win. Pair fairness primitives like a provably fair RNG implementation with instant Lightning settlement and you have the two pillars crypto-native players actually check for.

Complete Project Example: A Minimal Cashier Service

Here is the wiring that ties every step into a runnable service. This is the skeleton – production hardening (TLS pinning, secrets management, full error taxonomy) layers on top – but it boots, takes deposits, and pays withdrawals against a real CLN node.

The Postgres schema

-- schema.sql
CREATE TABLE balances (
  user_id   uuid PRIMARY KEY,
  sat       bigint NOT NULL DEFAULT 0 CHECK (sat >= 0)
);
CREATE TABLE deposits (
  label        text PRIMARY KEY,
  user_id      uuid NOT NULL,
  amount_sat   bigint NOT NULL,
  payment_hash text NOT NULL,
  status       text NOT NULL DEFAULT 'pending',
  created_at   timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE settlements (
  id           bigserial PRIMARY KEY,
  payment_hash text UNIQUE NOT NULL,        -- idempotency anchor
  label        text NOT NULL,
  amount_sat   bigint NOT NULL,
  created_at   timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE withdraw_tokens (
  token       text PRIMARY KEY,
  user_id     uuid NOT NULL,
  amount_sat  bigint NOT NULL,
  amount_msat bigint NOT NULL,
  status      text NOT NULL DEFAULT 'open', -- open|paying|paid|blocked
  preimage    text,
  created_at  timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE cursors (name text PRIMARY KEY, value bigint NOT NULL);

The server entrypoint

// src/index.ts
import express from "express";
import { deposit } from "./routes/deposit.js";
import { lnurlp } from "./routes/lnurlp.js";
import { lnurlw } from "./routes/lnurlw.js";
import { runListener } from "./listener.js";
import { reconcile } from "./reconcile.js";
import { metrics } from "./metrics.js";

const app = express();
app.use(express.json());
app.use(deposit);
app.use(lnurlp);
app.use(lnurlw);
app.get("/metrics", metrics);          // Prometheus scrape
app.get("/healthz", (_req, res) => res.json({ ok: true }));

app.listen(8080, () => console.log("cashier on :8080"));

runListener();                          // settlement listener (Step 9)
setInterval(reconcile, 60_000);         // reconciliation sweeper (Step 10)

Running it end to end

$ psql casino < schema.sql
$ export CLN_URL=https://127.0.0.1:3010
$ export CLN_RUNE_DEPOSIT=k9vY2...  CLN_RUNE_PAYOUT=m4Xz8...
$ export PUBLIC_HOST=https://pay.casino.example
$ npx tsx src/index.ts
cashier on :8080

# Issue a deposit invoice
$ curl -s localhost:8080/deposit/invoice -H 'content-type: application/json' \
   -d '{"userId":"8f3e...","amountSat":50000,"idempotencyKey":"a1b2c3d4"}'
{"bolt11":"lnbc500u1p...","label":"dep:8f3e...:a1b2c3d4","reused":false}

Pay that invoice from any external Lightning wallet and watch the listener credit the balance exactly once. That is a production-shaped Lightning cashier – instant, cheap, irreversible, and compliant – in roughly 300 lines.

Frequently Asked Questions

Is it legal for a licensed casino to pay out winnings over Lightning in 2026?

Yes, where the operator holds a license that permits crypto settlement and satisfies AML/KYC obligations. Lightning is a settlement rail, not a regulatory bypass – you still apply the same identity verification, FATF Travel Rule data collection above threshold, and jurisdictional checks you would for any payout. Build the compliance gate (Step 12) before the cashier touches a player.

Should I run LND or Core Lightning for a casino cashier?

Both work; this tutorial uses Core Lightning for its rune-based least-privilege auth, native BOLT12 offers, and the clean clnrest REST surface. LND is equally production-grade and ships native hold invoices. Many large operators run both implementations for redundancy. Pick one, master its failure modes, and keep your cashier abstraction so you can add the other later.

How much inbound liquidity do I need for deposits?

Enough to cover your peak un-reconciled deposit window – typically a multiple of your largest expected single deposit plus daily volume headroom. Start by buying inbound from a liquidity marketplace or using a managed node that provisions JIT channels, and keep at least 40% of channel capacity inbound. Monitor it continuously; “no route” deposit failures are almost always a liquidity-direction problem.

What happens if a payment routes but my service crashes mid-write?

Nothing is lost. The settlement listener resumes from its pay_index cursor after restart, and the unique payment_hash constraint guarantees the credit lands exactly once. If even the listener missed it, the 60-second reconciliation sweeper compares the node’s paid invoices against your ledger and backfills the gap. Crash-safety is the whole point of Steps 9 and 10.

Do I still need KYC if players deposit with Lightning?

For a licensed real-money operator, yes – Lightning does not change your obligations. Deposits may be lighter-touch in some jurisdictions, but withdrawals almost universally require verified identity before funds leave. Gate every payout on KYC status and self-exclusion, and fail closed if your verification provider is unavailable.

How do hold invoices help with bet settlement?

A hold (HODL) invoice lets you accept a payment but delay settling it, holding the funds in-flight until a game resolves. If the house wins, you settle (claim); if the game is voided, you cancel and the player is automatically refunded – no separate refund transaction. Keep escrow windows short to avoid HTLC-expiry force-closes.

Can players deposit dollars instead of volatile Bitcoin?

Yes. In 2026, stablecoins issued via Taproot Assets run live over Lightning, so a player can deposit and withdraw a USD- or EUR-denominated asset while still settling at Lightning speed and sub-cent cost. Your cashier credits a fiat-denominated balance and removes the volatility objection without giving up instant, irreversible rails.

What does a Lightning payout actually cost versus a card processor?

Routing fees in the 2026 Voltage pilot ran under $0.01 per transaction – about 0.0029% of value, roughly 1,000× cheaper than a 3.5% card blend. The honest full cost adds node operations, liquidity capital, and compliance staff, but even fully loaded Lightning is decisively cheaper at volume and eliminates chargeback fraud entirely, which has no equivalent on reversible card rails.

Casey Mitchell

Casey Mitchell

Crypto Casino Editor

Casey Mitchell tracks crypto casinos, on-chain gaming, provably fair RNG, and the regulatory gray zones around US-facing crypto gambling platforms. Reviews wallets, deposit flows, and KYC policies across BTC, ETH, and stablecoin operators.

View all articles