Lightspark
Lightspark

LNURLs

The Lightspark SDK provides everything that you need to implement support for receiving payments through LNURLs. This allows you to receive payments through supported services using an email-like address such as username@domain.org.
This page will provide a step by step tutorial implementing the necessary REST APIs to support receiving payments through a LNURL.
You will need a web server able to serve a REST API on the given domain.

Step 1: Implement the .well-known endpoint

When a payer encounters an LNURL address such as username@domain.org, it transforms it to a URL from the domain and username: https://domain.org/.well-known/lnurlp/username. It then makes a GET request to this URL, which returns an LNURL payRequest response. This response is essentially a series of parameters that describe the recipient and instructions for retrieving a Bolt11 invoice.
{
    "callback": string,
    "maxSendable": number,
    "minSendable": number,
    "metadata": string,
    "tag": "payRequest"
}
  • maxSendable represents the maximum amount the recipient is willing to receive, in millisatoshis.
  • minSendable represents the minimum the recipient is willing to receive, in millisatoshis.
  • tag should always be set to "payRequest" to indicate that this is a payRequest response (as opposed to another LNURL operation).
The callback parameter is a URL pointing back to your service that will return a Bolt11 invoice to the payer when requested. We will go more in-depth on this URL in Step 2 (and implement the API), but this is what an example may look like:
https://domain.org/api/lnurl/payreq/4b41ae03-01b8-4974-8d26-26a35d28851b
In general, an LNURL server will serve as a host for a number of users and Lightning nodes. For demonstration purposes, the convention used in this tutorial is to encode a user ID in the callback URL, though you may choose any convention or format that works with your API.
The metadata parameter is a JSON array of arrays serialized as a string. There are a number of parameters that can go in this array, but for the purposes of this exercise we will keep it simple and only insert those that are required:
[
    [
        "text/plain",
        string
    ],
    [
        "text/identifier",
        string
    ]
]
  • "text/plain" should contain a short description that will be displayed when paying and in the transaction log.
  • "text/identifier" should be the internet identifier in standard username@domain.org format.
Below is a rough example of a metadata parameter for https://domain.org/.well-known/lnurlp/username:
[
    [
        "text/plain",
        "Pay to domain.org user <username>"
    ],
    [
        "text/identifier",
        "username@domain.org"
    ]
]
First setup a data model to associate a user with a node:
// user.ts
const LNURL_NODE_UUID = ({}).LIGHTSPARK_LNURL_NODE_UUID;
export const LNURL_USERNAME = ({}).LIGHTSPARK_LNURL_USERNAME || "ls_test";

export type User = {
  uuid: string;
  username: string;
  nodeId: string;
};

export const LS_TEST_USER: User = {
  // Static UUID so that callback URLs are always the same.
  uuid: "4b41ae03-01b8-4974-8d26-26a35d28851b",
  username: LNURL_USERNAME,
  nodeId: `LightsparkNode:${LNURL_NODE_UUID}`,
};
Then write a server that serves a basic .well-known/lnurlp for the user:
// server.ts using Express
import express, { Request, Response } from "express";
import { User, LS_TEST_USER, LNURL_USERNAME } from "./user";

const generateCallbackForUser = (request: Request, user: User): string => {
  return `${request.baseUrl}/api/lnurl/payreq/${user.uuid}`;
};

const generateMetadataForUser = (user: User): string => {
  return JSON.stringify([
    ["text/plain", `Pay ${user.username} on Lightspark`],
    ["text/identifier", `${user.username}@domain.org`],
  ]);
};

const app = express();

app.get("/.well-known/lnurlp/:username", (req, res) => {
  const username = req.params.username;
  if (username !== LNURL_USERNAME) {
    throw new Error("User not found.");
  }
  const callback = generateCallbackForUser(req, LS_TEST_USER);
  const metadata = generateMetadataForUser(LS_TEST_USER);

  res.send({
    callback: callback,
    maxSendable: 10_000_000,
    minSendable: 1_000,
    metadata: metadata,
    tag: "payRequest",
  });
});

// Start the server
app.listen(3000, () => {
  console.log("Server started on port 3000");
});
Now if you run the server and send a request to the endpoint (with the appropriate port), you should get a response (note that metadata has been split for readability, generally it would be all on one line):
$ curl localhost:<port>/.well-known/lnurlp/ls_test | json_pp
{
  "callback" : "http://localhost:<port>/api/lnurl/payreq/4b41ae03-01b8-4974-8d26-26a35d28851b",
  "maxSendable" : 10000000,
  "metadata" : "[
    [\"text/plain\", \"Pay to domain.org user ls_test\"],
    [\"text/identifier\", \"ls_test@domain.org\"]
  ]",
  "minSendable" : 1000,
  "tag" : "payRequest"
} 
Once the payer has confirmed the amount to send to the LNURL, a request will be made to the callback returned in Step 1 to retrieve a proper invoice. The amount for the invoice is appended as a URL parameter:
<callback><?|&>amount=<amount_msats>
As a concrete example, suppose the callback returned in Step 1 was https://domain.org/api/lnurl/payreq/4b41ae03-01b8-4974-8d26-26a35d28851b, and the payer wanted to get an invoice for 10000 millisatoshis, it would make a GET request to https://domain.org/api/lnurl/payreq/4b41ae03-01b8-4974-8d26-26a35d28851b?amount=10000.
This endpoint is where you will use the Lightspark SDK to generate an invoice through the create_lnurl_invoice mutation. This mutation accepts three parameters:
  • node_id corresponds to the Lightning node from which to create the invoice.
  • amount_msats represents the amount for which the invoice should be created, in millisatoshis.
  • metadata_hash is the SHA256 hash of the LNURL metadata payload from Step 1. This is encoded into the resulting invoice and provides a signature the payer can check against.
Most of the Lightspark SDKs provide wrapper methods around create_lnurl_invoice that will hash the metadata for you, so generally you just need to pass in the raw string representation.
The response format of the callback is as follows:
{
    pr: string,
    routes: []
}
  • pr is a bech-32 serialized lightning invoice. This corresponds to the encoded_payment_request in the Invoice Data returned by the create_lnurl_invoice mutation.
  • routes is always an empty array.
Assuming your callback URL follows the example from Step 1, here's how this API might be implemented:
// Still in the same file as Step 1 (server.ts), using the same `app` instance.
import {
  AccountTokenAuthProvider,
  LightsparkClient,
} from "@lightsparkdev/lightspark-sdk";

# Those are available in your account at https://app.lightspark.com/api_config
API_TOKEN_CLIENT_ID = ({}).LIGHTSPARK_API_TOKEN_CLIENT_ID;
API_TOKEN_CLIENT_SECRET = ({}).LIGHTSPARK_API_TOKEN_CLIENT_SECRET;

if (!API_TOKEN_CLIENT_ID || !API_TOKEN_CLIENT_SECRET) {
  throw new Error("Missing API_TOKEN_CLIENT_ID or API_TOKEN_CLIENT_SECRET");
}

app.get("/api/lnurl/payreq/:uuid", async (req, res, next) => {
  const uuid = req.params.uuid;
  if (uuid !== LS_TEST_USER.uuid) {
    return next(new Error("User not found."));
  }
  const client = new LightsparkClient(
    new AccountTokenAuthProvider(
      credentials.apiTokenClientId,
      credentials.apiTokenClientSecret
    ),
    credentials.baseUrl
  );

  const amountMsats = parseInt(req.query.amount as string);
  if (!amountMsats) {
    res.status(400).send(errorMessage("Missing amount query parameter."));
    return;
  }

  const invoice = await client.createLnurlInvoice(
    LS_TEST_USER.nodeId,
    amountMsats,
    generateMetadataForUser(LS_TEST_USER)
  );
  if (!invoice) {
    return next(new Error("Invoice creation failed."));
  }
  res.send({ pr: invoice.data.encodedPaymentRequest, routes: [] });
});
After setting API_TOKEN_CLIENT_ID and API_TOKEN_CLIENT_SECRET, restart the server and make a GET request to the callback returned in Step 1 with an amount parameter:
$ curl http://localhost:<port>/api/lnurl/payreq/4b41ae03-01b8-4974-8d26-26a35d28851b?amount=1000
{
  "pr" : "lnbcrt10n1pjg5dq...",
  "routes" : []
}
If you wish to return an error from any of your LNURL endpoints, it should be returned in the following format:
{
    "status": "ERROR",
    "reason": "error details..."
}
Here's an example implementation of returning a 404 when a user is not found:
app.use((err, req, res, next) => {
  console.error(err.stack);
  if (res.headersSent) {
    return next(err);
  }

  if (err.message === "User not found.") {
    res.status(404).send(errorMessage(err.message));
    return;
  }

  res.status(500).send(errorMessage(`Something broke! ${err.message}`));
});

const errorMessage = (message: string) => {
  return { status: "ERROR", reason: message };
};

// In routes defined earlier, errors thrown lke this will be caught by the middleware above
if (uuid !== LS_TEST_USER.uuid) {
  res.status(404);
  throw new Error("User not found.");
}
Making a request with an invalid user will now return a 404 with an appropriate error message:
$ curl -i localhost:<port>/.well-known/lnurlp/randomuser
HTTP/1.1 404 NOT FOUND
Server: Werkzeug/2.3.0 Python/3.11.3
Date: Wed, 14 Jun 2023 19:07:40 GMT
Content-Type: application/json
Content-Length: 57
Connection: close

{"reason":"User not found: randomuser","status":"ERROR"}
The LNURL protocol is defined in a series of LNURL Documents. This tutorial has predominantly focused on the following two, which can be consulted for more detail: