Skip to content

Accepting Lightning on a static site

You can accept Lightning payments to your Lexe wallet from your static site without running a server of your own.

Every Lexe wallet with a username is reachable at username@lexe.app as a Lightning Address, which serves LNURL-pay, comments, and LNURL-verify over CORS-enabled GET endpoints. So plain HTML/CSS/JS can request invoices for your wallet and confirm that they were paid, which is enough for tips and donations.

For paywalls and pay-to-submit forms, add a serverless function.

Embeddable payment widget

The <lexe-tip> element requests an invoice, shows it as a QR code, and confirms payment. Here is a live example paying to max@lexe.app. This documentation site is itself a static site, so the widget below works exactly as it would on yours:

To embed it in your own site, paste this where the widget should appear, with your Lexe username:

<script src="https://lexe.app/widgets/lexe-widgets.js"></script>
<lexe-tip username="YOUR_LEXE_USERNAME"
          amounts="100,1000"
          title="Leave a tip"
          description="Tips help fund my open-source work and are greatly appreciated."
          thanks="Thanks for the tip!"
></lexe-tip>
  • amounts: preset amounts in sats, shown before Custom.
  • title: the heading.
  • description: optional text under the address.
  • thanks: the message shown once paid.
  • theme: light or dark. By default the widget follows the visitor's system color scheme.

Once paid, the element fires a paid event with { sats, hash, preimage } in event.detail, so your page can react, e.g. by showing a thank-you message. Anything that must be enforced belongs in a serverless function instead.

The script is MIT licensed, so you are also welcome to copy lexe-widgets.js and qrcode.js next to each other on your own site, and to modify them as you like. Its source is in the Lexe monorepo.

Checking payment status with LNURL-Verify

When requesting an invoice from the LNURL-pay callback, the response includes a verify URL unique to that invoice:

{
  "pr": "lnbc10n1p4w8fn7...",
  "routes": [],
  "verify": "https://lexe.app/public/v1/lnurl_verify/APowfabi..."
}

Store it alongside the invoice, then GET it to check payment status:

{
  "status": "OK",
  "settled": true,
  "preimage": "f0e1d2c3...",
  "pr": "lnbc10n1p4w8fn7..."
}

Paywalls via serverless functions

The same endpoints work from a serverless function (e.g. Cloudflare Workers or AWS Lambda), which enables paywalls and pay-to-submit forms. The function requests the invoice and stores its verify URL under a random token. It serves the content only once that URL reports the invoice paid. The browser fetches /invoice, shows pr as a QR code, polls verify to learn when it is paid, then loads /content?token=.... Anyone with the /content URL can read the content, so the examples expire tokens after an hour. A pay-to-submit form is the same, with the paid route accepting the submission instead.

Cloudflare Workers

A paywall Worker, with tokens in a KV namespace bound as TOKENS in wrangler.toml:

const USERNAME = "max";
const SATS = 1000;
const CONTENT = "The paid content.";

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

    // Request an invoice and remember its verify URL under a fresh token.
    if (url.pathname === "/invoice") {
      const { callback } = await getJson(
        `https://lexe.app/.well-known/lnurlp/${USERNAME}`,
      );
      const { pr, verify } = await getJson(`${callback}?amount=${SATS * 1000}`);
      const token = crypto.randomUUID();
      await env.TOKENS.put(token, verify, { expirationTtl: 3600 });
      return Response.json({ token, pr, verify });
    }

    // Serve the content once the token's invoice is paid.
    if (url.pathname === "/content") {
      const token = url.searchParams.get("token");
      const verify = token && (await env.TOKENS.get(token));
      if (!verify) return new Response("Unknown token", { status: 403 });
      const { settled } = await getJson(verify);
      if (!settled) return new Response("Unpaid", { status: 402 });
      return new Response(CONTENT);
    }

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

async function getJson(url) {
  const json = await (await fetch(url)).json();
  // LNURL errors are `{"status": "ERROR", "reason": "..."}`.
  if (json.status === "ERROR") throw new Error(json.reason);
  return json;
}

AWS Lambda

The same behind a function URL, with tokens in a DynamoDB table tokens keyed by token and TTL enabled on ttl:

import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import {
  DynamoDBDocumentClient, GetCommand, PutCommand,
} from "@aws-sdk/lib-dynamodb";

const USERNAME = "max";
const SATS = 1000;
const CONTENT = "The paid content.";
const TABLE = "tokens";
const db = DynamoDBDocumentClient.from(new DynamoDBClient({}));

export const handler = async (event) => {
  const path = event.rawPath;

  // Request an invoice and remember its verify URL under a fresh token.
  if (path === "/invoice") {
    const { callback } = await getJson(
      `https://lexe.app/.well-known/lnurlp/${USERNAME}`,
    );
    const { pr, verify } = await getJson(`${callback}?amount=${SATS * 1000}`);
    const token = crypto.randomUUID();
    const ttl = Math.floor(Date.now() / 1000) + 3600;
    await db.send(new PutCommand({ TableName: TABLE, Item: { token, verify, ttl } }));
    return json(200, { token, pr, verify });
  }

  // Serve the content once the token's invoice is paid.
  if (path === "/content") {
    const token = event.queryStringParameters?.token;
    const { Item } = token
      ? await db.send(new GetCommand({ TableName: TABLE, Key: { token } }))
      : {};
    if (!Item) return text(403, "Unknown token");
    const { settled } = await getJson(Item.verify);
    if (!settled) return text(402, "Unpaid");
    return text(200, CONTENT);
  }

  return text(404, "Not found");
};

async function getJson(url) {
  const json = await (await fetch(url)).json();
  // LNURL errors are `{"status": "ERROR", "reason": "..."}`.
  if (json.status === "ERROR") throw new Error(json.reason);
  return json;
}

const json = (statusCode, body) => ({
  statusCode,
  headers: { "content-type": "application/json" },
  body: JSON.stringify(body),
});

const text = (statusCode, body) => ({ statusCode, body });

Other hosts

Both examples port to other hosts with few changes. Netlify Functions use Lambda's event handler, while Vercel Edge Functions, Netlify Edge Functions, Supabase Edge Functions, and Deno Deploy use the same fetch(request) handler as Workers. Beyond that, only the token store changes, to whichever key-value store the host provides.