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:
- Recipient creates invoice (generates preimage, hashes it to get payment_hash)
- Payer pays invoice
- Recipient's node automatically reveals preimage to claim funds
A hold invoice flow:
- Escrow service generates preimage and payment_hash
- Escrow creates invoice using the payment_hash (but keeps the preimage secret)
- Payer pays invoice — sats are locked, not delivered
- When conditions are met, escrow calls
settle_hold_invoicewith the preimage — sats go to recipient - 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
- Alby Hub running with LND or LDK backend
- An NWC connection string with
make_hold_invoice,settle_hold_invoice, andcancel_hold_invoicepermissions - Node.js 18+
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
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();
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
- Freelance marketplaces — lock payment when job is accepted, release on delivery
- E-commerce — hold payment until shipping confirmation
- Bug bounties — lock bounty when PR is submitted, release on merge
- P2P trades — atomic escrow for any peer-to-peer exchange
- Subscriptions with trial — hold first payment, cancel if user opts out during trial
Production Considerations
- Preimage storage: Use encrypted database storage, not in-memory maps. If the server crashes before settling, those preimages are gone.
- Timeout handling: Set up a cron job to auto-cancel escrows that aren't settled within your business logic window.
- Channel liquidity: Hold invoices lock up channel capacity. Don't create hold invoices with very long expiry times.
- Error handling: If
settleHoldInvoicefails (e.g., the hold invoice timed out), catch the error and notify both parties. - Idempotency: Check escrow status before settling/canceling to prevent double-settlement.
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
- @getalby/sdk — NWC client with hold invoice support
- NIP-47 — Nostr Wallet Connect protocol spec
- Alby Developer Guide — NWC setup and configuration
- Alby Hub — self-hosted Lightning node with NWC support
- Building a Lightning Service with NWC — NWC basics tutorial
- Add Lightning Tipping in 60 Seconds — simple-boost tutorial
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.