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:lightordark. 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:
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.