← All Posts

Building a Lightning-Powered Service with NWC

2026-01-10 — lightning, nwc, tutorial

Introduction

I built a NIP-05 verification service that charges 100 sats per registration. It runs on Cloudflare Workers for free and accepts Lightning payments through Nostr Wallet Connect. Here's the full walkthrough.

What you'll learn:

Prerequisites: An Alby Hub instance (or any NWC-compatible wallet), basic JavaScript knowledge, a free Cloudflare account.

What is Nostr Wallet Connect?

NWC (defined in NIP-47) is a protocol that lets applications communicate with Lightning wallets over Nostr relays. Instead of running your own Lightning node or managing REST APIs, NWC gives you a single connection string that enables:

The connection string format:

nostr+walletconnect://<wallet-pubkey>?relay=<relay-url>&secret=<secret>

Your app signs NIP-47 request events, sends them to the relay, and the wallet responds with results. No REST endpoints, no API keys, no server infrastructure to maintain.

Why NWC Over Other Approaches?

I've used LND direct, LNbits API, and LNURL. Each has tradeoffs:

The tradeoff with NWC is relay availability. If the relay goes down, your wallet communication breaks. For a side project, that's fine. For critical infrastructure, you'd want fallback relays.

Step 1: Set Up Your Wallet

Using Alby Hub

  1. Install Alby Hub — it runs as a self-contained Lightning node
  2. Go to Settings → Wallet Connections
  3. Create a new app connection with permissions: make_invoice, lookup_invoice
  4. Copy the NWC connection string

For Testing: Developer Sandbox

Don't want to set up a real wallet yet? Use the NWC test faucet:

curl -X POST "https://faucet.nwc.dev?balance=10000"

This returns a connection string you can use immediately. Test wallets can pay each other but don't interact with the real Lightning network.

Step 2: Build the Payment Backend

Install the Alby SDK:

npm install @getalby/sdk

Here's the Cloudflare Worker. Two functions handle all wallet communication through NWC.

Creating an Invoice

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

async function createInvoice(env, amount, description) {
  const client = new NWCClient({
    nostrWalletConnectUrl: env.NWC_URL,
  });
  const response = await client.makeInvoice({
    amount: amount * 1000, // NWC uses millisats
    description,
  });
  return {
    payment_request: response.invoice,
    payment_hash: response.payment_hash,
  };
}

makeInvoice sends a NIP-47 make_invoice request to your wallet via the relay. The wallet creates the BOLT11 invoice and returns it. Note that NWC amounts are in millisatoshis, so multiply sats by 1000.

Checking Payment Status

async function checkPayment(env, paymentHash) {
  const client = new NWCClient({
    nostrWalletConnectUrl: env.NWC_URL,
  });
  try {
    const invoice = await client.lookupInvoice({
      payment_hash: paymentHash,
    });
    return invoice.settled_at != null;
  } catch (e) {
    return false;
  }
}

lookupInvoice queries your wallet for the invoice status. If settled_at is set, the payment has been received.

The Complete Worker

Wire it together into a registration flow: client submits a name and pubkey, server creates an invoice via NWC, client pays, server verifies via NWC and registers.

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

async function createInvoice(env, amount, description) {
  const client = new NWCClient({
    nostrWalletConnectUrl: env.NWC_URL,
  });
  const response = await client.makeInvoice({
    amount: amount * 1000,
    description,
  });
  return {
    payment_request: response.invoice,
    payment_hash: response.payment_hash,
  };
}

async function checkPayment(env, paymentHash) {
  const client = new NWCClient({
    nostrWalletConnectUrl: env.NWC_URL,
  });
  try {
    const invoice = await client.lookupInvoice({
      payment_hash: paymentHash,
    });
    return invoice.settled_at != null;
  } catch (e) {
    return false;
  }
}

export default {
  async fetch(request, env) {
    const url = new URL(request.url);

    if (url.pathname === '/api/register' && request.method === 'POST') {
      const { name, pubkey } = await request.json();
      if (!name || !/^[a-z0-9._-]+$/.test(name)) {
        return Response.json({ error: 'Invalid name' }, { status: 400 });
      }
      const invoice = await createInvoice(env, 100, 'NIP-05: ' + name);
      await env.DB.prepare(
        'INSERT INTO pending (name, pubkey, payment_hash) VALUES (?, ?, ?)'
      ).bind(name, pubkey, invoice.payment_hash).run();
      return Response.json({
        payment_request: invoice.payment_request,
        payment_hash: invoice.payment_hash
      });
    }

    if (url.pathname.startsWith('/api/check/')) {
      const hash = url.pathname.split('/').pop();
      const paid = await checkPayment(env, hash);
      if (paid) {
        const p = await env.DB.prepare(
          'SELECT name, pubkey FROM pending WHERE payment_hash = ?'
        ).bind(hash).first();
        if (p) {
          await env.DB.prepare(
            'INSERT INTO verified (name, pubkey) VALUES (?, ?)'
          ).bind(p.name, p.pubkey).run();
          await env.DB.prepare(
            'DELETE FROM pending WHERE payment_hash = ?'
          ).bind(hash).run();
        }
      }
      return Response.json({ paid });
    }

    return new Response('Not found', { status: 404 });
  }
};

Step 3: Build the Frontend

The client-side flow: user submits the form, gets an invoice, pays it in their wallet, and the page polls until payment confirms.

async function register() {
  const name = document.getElementById('name').value;
  const pubkey = document.getElementById('pubkey').value;

  const res = await fetch('/api/register', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ name, pubkey })
  });
  const { payment_request, payment_hash } = await res.json();

  // Display the BOLT11 invoice for the user to pay
  document.getElementById('invoice').textContent = payment_request;

  // Poll every 5 seconds for up to 5 minutes
  let attempts = 0;
  const poll = setInterval(async () => {
    attempts++;
    const check = await fetch('/api/check/' + payment_hash);
    const { paid } = await check.json();
    if (paid) {
      clearInterval(poll);
      showSuccess(name + '@yourdomain.com is verified!');
    } else if (attempts >= 60) {
      clearInterval(poll);
      showError('Payment timeout');
    }
  }, 5000);
}

Five minutes of polling at 5-second intervals. If the payment doesn't come through, the user can always retry — the pending record stays in the database.

Step 4: Deploy for Free

The entire service runs on Cloudflare's free tier:

# Create D1 database
wrangler d1 create my-lightning-db

# Deploy schema
wrangler d1 execute my-lightning-db --remote --file=schema.sql

# Set your NWC connection string as a secret
echo "nostr+walletconnect://..." | wrangler secret put NWC_URL

# Deploy
wrangler deploy

The NWC connection string is stored as a Worker secret. It never appears in your source code or wrangler.toml.

Security Considerations

A few things I learned the hard way:

Real-World Example

satoshis.lol is a live NIP-05 verification service built with this architecture. 100 sats per registration, Cloudflare Workers + D1, NWC for payments. Pay the invoice, get your Nostr identity verified, done.

Resources

If you build something with this, let me know. I'm always interested to see what people do with Lightning payments in their projects.

Found this useful?

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

Tip 100 sats ⚡