← All Posts

Build a Lightning Escrow with NWC Hold Invoices

2026-02-09 — lightning, tutorial, alby, nwc

The Problem

Normal Lightning invoices are atomic: you pay, the recipient gets the sats, done. There's no way to say "hold this payment until the work is delivered" or "refund if the buyer disputes." For marketplaces, freelance platforms, and any transaction where trust is an issue, this is a blocker.

Hold invoices fix this. The payer locks sats into the invoice, but the recipient doesn't receive them until they reveal a secret (the preimage). If something goes wrong, the invoice is canceled and the payer's sats return automatically.

Alby Hub now supports hold invoices via NWC. This tutorial shows you how to use them with the @getalby/sdk to build a simple escrow service.

How Hold Invoices Work

A normal Lightning payment flow:

  1. Recipient creates invoice (generates preimage, hashes it to get payment_hash)
  2. Payer pays invoice
  3. Recipient's node automatically reveals preimage to claim funds

A hold invoice flow:

  1. Escrow service generates preimage and payment_hash
  2. Escrow creates invoice using the payment_hash (but keeps the preimage secret)
  3. Payer pays invoice — sats are locked, not delivered
  4. When conditions are met, escrow calls settle_hold_invoice with the preimage — sats go to recipient
  5. If conditions fail, escrow calls cancel_hold_invoice — sats return to payer

The key difference: the entity that creates the preimage controls whether the payment settles or cancels. This is the escrow primitive.

Prerequisites

Step 1: Generate Preimage and Payment Hash

The preimage is a random 32-byte secret. The payment hash is its SHA-256 hash. Whoever holds the preimage can settle the invoice.

import crypto from "crypto";

function generateHoldInvoiceKeys() {
  // 32 random bytes = 256-bit preimage
  const preimageBytes = crypto.randomBytes(32);
  const preimage = preimageBytes.toString("hex");

  // SHA-256 hash of the preimage
  const paymentHash = crypto
    .createHash("sha256")
    .update(preimageBytes)
    .digest("hex");

  return { preimage, paymentHash };
}

const { preimage, paymentHash } = generateHoldInvoiceKeys();
console.log("Preimage:", preimage);   // Keep secret until settlement
console.log("Hash:", paymentHash);     // Used to create the invoice
Security: The preimage is the key to the money. Store it encrypted. If it leaks, anyone can settle the invoice. If you lose it, the invoice eventually expires and sats return to the payer.

Step 2: Connect to Your Wallet

import { nwc } from "@getalby/sdk";

const client = new nwc.NWCClient({
  nostrWalletConnectUrl: process.env.NWC_URL,
});

// Verify connection
const info = await client.getInfo();
console.log("Connected to:", info.alias);
console.log("Methods:", info.methods);

Check that info.methods includes make_hold_invoice. If it doesn't, your wallet doesn't support hold invoices yet. Update Alby Hub to the latest version.

Step 3: Create the Hold Invoice

const holdInvoice = await client.makeHoldInvoice({
  amount: 50000 * 1000,       // 50,000 sats in millisats
  payment_hash: paymentHash,   // From step 1
  description: "Escrow: Logo design job #42",
  expiry: 3600,                // 1 hour to pay
});

console.log("Invoice:", holdInvoice.invoice);
console.log("Expires:", new Date(holdInvoice.expires_at * 1000));

Send holdInvoice.invoice to the payer. This is a standard BOLT11 invoice — any Lightning wallet can pay it. The payer doesn't need to know it's a hold invoice.

Step 4: Listen for Payment

When the payer pays, your wallet receives a hold_invoice_accepted notification. The sats are now locked — not yet in your balance, but committed by the payer.

// Subscribe to notifications
const unsub = await client.subscribeNotifications(
  (notification) => {
    if (notification.notification_type === "hold_invoice_accepted") {
      console.log("Payment locked!");
      console.log("Amount:", notification.notification.amount, "msats");
      console.log("Hash:", notification.notification.payment_hash);

      // Now decide: settle or cancel
      handleEscrowDecision(notification.notification.payment_hash);
    }
  }
);

// Clean up when done
// unsub();
Timing: Once a hold invoice is accepted, you have a limited window to settle or cancel (typically a few minutes, depending on CLTV delta). Don't leave hold invoices hanging — they lock up channel liquidity for both parties.

Step 5: Settle or Cancel

This is where the escrow logic lives. Your application decides whether to release the funds.

async function handleEscrowDecision(invoicePaymentHash) {
  // Your business logic here
  const workDelivered = await checkDelivery(invoicePaymentHash);

  if (workDelivered) {
    // Release funds to recipient
    await client.settleHoldInvoice({ preimage });
    console.log("Settled! Sats delivered.");
  } else {
    // Refund payer
    await client.cancelHoldInvoice({
      payment_hash: invoicePaymentHash,
    });
    console.log("Canceled. Sats returned to payer.");
  }
}

Putting It Together: A Freelance Escrow

Here's a minimal Express server that implements escrow for freelance jobs:

import express from "express";
import crypto from "crypto";
import { nwc } from "@getalby/sdk";

const app = express();
app.use(express.json());

const client = new nwc.NWCClient({
  nostrWalletConnectUrl: process.env.NWC_URL,
});

// In-memory store (use a database in production)
const escrows = new Map();

// Create escrow
app.post("/escrow", async (req, res) => {
  const { amount_sats, description } = req.body;

  const preimageBytes = crypto.randomBytes(32);
  const preimage = preimageBytes.toString("hex");
  const paymentHash = crypto
    .createHash("sha256")
    .update(preimageBytes)
    .digest("hex");

  const invoice = await client.makeHoldInvoice({
    amount: amount_sats * 1000,
    payment_hash: paymentHash,
    description: description || "Escrow payment",
    expiry: 3600,
  });

  escrows.set(paymentHash, {
    preimage,
    amount_sats,
    status: "pending",    // pending -> locked -> settled | canceled
    created_at: Date.now(),
  });

  res.json({
    payment_hash: paymentHash,
    invoice: invoice.invoice,
    expires_at: invoice.expires_at,
  });
});

// Release escrow (called when work is delivered)
app.post("/escrow/:hash/settle", async (req, res) => {
  const escrow = escrows.get(req.params.hash);
  if (!escrow) return res.status(404).json({ error: "Not found" });
  if (escrow.status !== "locked")
    return res.status(400).json({ error: "Not locked" });

  await client.settleHoldInvoice({ preimage: escrow.preimage });
  escrow.status = "settled";
  res.json({ status: "settled", amount_sats: escrow.amount_sats });
});

// Cancel escrow (refund payer)
app.post("/escrow/:hash/cancel", async (req, res) => {
  const escrow = escrows.get(req.params.hash);
  if (!escrow) return res.status(404).json({ error: "Not found" });
  if (escrow.status !== "locked")
    return res.status(400).json({ error: "Not locked" });

  await client.cancelHoldInvoice({ payment_hash: req.params.hash });
  escrow.status = "canceled";
  res.json({ status: "canceled" });
});

// Listen for hold invoice acceptance
client.subscribeNotifications((notification) => {
  if (notification.notification_type === "hold_invoice_accepted") {
    const hash = notification.notification.payment_hash;
    const escrow = escrows.get(hash);
    if (escrow) {
      escrow.status = "locked";
      console.log("Escrow locked:", hash);
    }
  }
});

app.listen(3000, () => console.log("Escrow service on :3000"));

Usage

# Create an escrow for 50,000 sats
curl -X POST http://localhost:3000/escrow \
  -H "Content-Type: application/json" \
  -d '{"amount_sats": 50000, "description": "Logo design"}'

# Pay the returned invoice with any Lightning wallet
# Wait for "Escrow locked" in the server logs

# Release funds when work is delivered
curl -X POST http://localhost:3000/escrow/<payment_hash>/settle

# Or cancel to refund
curl -X POST http://localhost:3000/escrow/<payment_hash>/cancel

Use Cases

Production Considerations

Browser-Side: Web Crypto API

If you need to generate the preimage in the browser (e.g., for a client-side escrow UI):

async function generateHoldInvoiceKeys() {
  const preimageBytes = crypto.getRandomValues(new Uint8Array(32));
  const preimage = Array.from(preimageBytes)
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");

  const hashBuffer = await crypto.subtle.digest("SHA-256", preimageBytes);
  const paymentHash = Array.from(new Uint8Array(hashBuffer))
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");

  return { preimage, paymentHash };
}

This is exactly the pattern from issue #523 in the Alby JS SDK. A utility function for this is planned but not yet shipped.

Resources

Hold invoices turn Lightning into a programmable escrow layer. Combine them with NWC and you get conditional payments that any app can integrate without running a node.

Found this useful?

Send a tip via Lightning. One click, no account needed.

Tip 100 sats ⚡