my2sats
← Back to posts
Build an LNURL Server with Coco (Part 1)

Build an LNURL Server with Coco (Part 1)

egge·

Build an LNURL Server with Coco

Part 1: A basic LNURL server that issues real invoices

LNURL-Servers are the bridge between the Lightning Network and many web-applications. They allow us to get a Lightning Invoice from someone without communicating with their node directly. A bit like a proxy. But in this tutorial our server will talk to a Cashu mint instead.

The goal for Part 1 is simple: Serve a valid LNURL-Pay endpoint that returns real Lightning invoices, backed by Cashu.


Project setup

I’m using Bun. At this point it’s my default for small servers like this. Fast startup, native TypeScript, and nothing to configure.

mkdir lnurl-coco
cd lnurl-coco
bun init

Install the Coco dependencies:

bun i coco-cashu-core@rc coco-cashu-sqlite3@rc

We also need to get a crypto library to convert our mnemonic to a bip39 seed

bun i @scure/bip39

Environment variables

Bun loads .env automatically, which helps, but LNURL is unforgiving when it comes to configuration. If your hostname or mint setup is wrong, many wallets won’t explain why they’ll just fail.

Here’s the full set:

HOSTNAME=http://localhost:8181
MIN_AMOUNT_SATS=1
MAX_AMOUNT_SATS=21000
MINT_URL=https://mint.minibits.cash/Bitcoin
MNEMONIC=BACON,BACON,BACON,BACON,BACON,BACON,BACON,BACON,BACON,BACON,BACON,BACON
  • HOSTNAME This must be the public URL wallets can reach. Do not derive it from the request. Proxies and load balancers will betray you.

  • MIN / MAX_AMOUNT_SATS These are your payment amount boundaries. Setting the min to 1 makes sense.

  • MINT_URL This is the URL of the Cashu mint that will back your LNURL server. Choose wisely

  • MNEMONIC This is your Cashu wallet's secret. Don’t reuse the example. Don’t commit it.


LNURL base response

This part never changes much. Wallets hit your LNURL endpoint with no parameters and expect instructions.

function createLnurlResponse() {
  return {
    callback: `${process.env.HOSTNAME}/.well-known/lnurlp/egge`,
    maxSendable: `${Number(process.env.MAX_AMOUNT_SATS) * 1000}`,
    minSendable: `${Number(process.env.MIN_AMOUNT_SATS) * 1000}`,
    metadata: '[["text/plain","Powered by Coco!"]]',
    tag: "payRequest",
  };
}

A minimal Bun server

I don’t use frameworks here on purpose. Bun includes a server that is more than enough for our simple LNURL server.

Bun.serve({
  port: 8181,
  routes: {
    "/.well-known/lnurlp/egge": lnurlBase,
  },
});

This runs a Bun HTTP server on port 8181 with a single route. When the route is hit it will invoke the lnurlBase function and return the result. The .well-known/lnurlp/<name> path isn’t optional. Wallets expect it as per LNURL spec.


The LNURL handler (before invoices)

At first, I like to confirm the wallet flow before generating invoices. LNURL-Pay always happens in two steps:

  1. Wallet asks for payment parameters
  2. Wallet comes back with an amount query parameter

Here’s the baseline version:

async function lnurlBase(req: BunRequest): Promise<Response> {
  const amountParam = Number(new URL(req.url).searchParams.get("amount"));
  if (amountParam) {
    // This will be the code path where we generate an invoice using Coco
    return Response.json({ type: typeof amountParam, value: amountParam });
  }
  return Response.json(createLnurlResponse());
}

Setting up Coco

This is where things get interesting.

Coco is a batteries-included Cashu framework that I have been working on for a couple of months now. I have worked with lower-level Cashu libraries before, and while they’re powerful, they also make you earn every line of code. Coco trades some flexibility for speed.

import { Database } from "bun:sqlite";
import { initializeCoco, SqliteRepositories } from "coco-cashu-core";
import { mnemonicToSeed } from "@scure/bip39";

const mintUrl = process.env.MINT_URL!;

const repo = new SqliteRepositories({ database: new Database("coco") });
const seed = mnemonicToSeed(process.env.MNEMONIC!.split(",").join(" "));
const coco = await initializeCoco({ repo, seedGetter: async () => seed });

await coco.mint.addMint(mintUrl, { trusted: true });

What this gives you:

  • Persistent state via SQLite
  • Deterministic wallet via mnemonic
  • A live connection to your mint

At this point, Coco will also open a WebSocket to the mint automatically. Other than Node Bun does include WebSockets natively. You don’t have to manage that yourself and that’s a big deal.


Wiring Coco into LNURL

Now we replace the placeholder response with a real invoice.

async function lnurlBase(req: BunRequest): Promise<Response> {
  const amountParam = Number(new URL(req.url).searchParams.get("amount"));
  if (amountParam) {
    const satAmount = Math.floor(amountParam / 1000);

    if (!Number.isSafeInteger(satAmount) || satAmount < 1) {
      throw new Error("Invalid amount received");
    }

    const quote = await coco.quotes.createMintQuote(mintUrl, satAmount);
    return Response.json({ pr: quote.request, routes: [] });
  }

  return Response.json(createLnurlResponse());
}

A few deliberate choices here:

  • I floor millisats → sats. LNURL values can be any msat integer. We can only handle full Satoshi values.
  • I fail hard on invalid amounts.
  • I return exactly what wallets expect: { pr, routes }

At this point, wallets will show a Lightning invoice and let users pay it. There is a lot more validation that we could add here, but I'll leave that for a later part.


Getting payment notifications

This is one of the nicer parts of using Bun + Coco together.

Because Bun has native WebSocket support, Coco will just listen to the mint and handle state changes as expected (e.g. redeeming a Cashu quote after a payment was made)

coco.on("mint-quote:redeemed", (p) => {
  console.log("Received payment: ", p.quote.amount);
});

When a payment settles, you know immediately.


Wrapping up Part 1

At this stage, we have:

  • A working LNURL-Pay endpoint
  • Real Lightning invoices issued via Cashu
  • Automatic payment notifications
  • Persistent state backed by SQLite

It’s not fancy, but it’s solid and that’s the point.

In the next parts, I’ll build on this by:

  • Adding Nostr Zaps and LUD-21
  • Exposing a small API for balance checks and withdrawals
  • Turning this into something you can actually run long-term