Add membership & LNbits integration documentation

Feature specification:
- Membership/loyalty system with tiered discounts (Bronze/Silver/Gold/Platinum)
- Lightning wallet integration via Lightning Addresses
- Simplified cash-in flow (no wallet QR needed for members)
- Database schema, API design, machine state changes

LNbits integration:
- Full TypeScript client implementation
- REST API reference
- Deployment architecture options
- NixOS configuration examples
- Monitoring & error handling patterns

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-01-22 14:33:04 -05:00
commit a5e6b32c24
2 changed files with 1852 additions and 0 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,825 @@
---
title: LNbits Integration
created: 2026-01-22
updated: 2026-01-22
tags:
- integration
- lightning
- lnbits
- api
status: reference
---
# LNbits Integration
> [!abstract] Summary
> LNbits serves as the Lightning Network backend for Lamassu ATMs, abstracting the underlying Lightning node implementation and providing a clean REST API for payments.
## Quick Links
- [[#Why LNbits]]
- [[#API Reference]]
- [[#Deployment Architecture]]
- [[#Configuration]]
---
## Why LNbits
> [!decision] Choice of Lightning Backend
> LNbits was chosen over direct LND/CLN integration for these reasons:
| Feature | LNbits | Direct LND/CLN |
|---------|--------|----------------|
| Backend Abstraction | 30+ implementations | Single implementation |
| API Complexity | Simple REST | gRPC/REST varies |
| Multi-wallet | Built-in | Custom implementation |
| User Management | Built-in | None |
| Extensions | Rich ecosystem | None |
| Self-hostable | Yes | Yes |
| Open Source | MIT License | Varies |
**Supported Backends:**
- LND (lndrest, lndgrpc)
- Core Lightning (CLN, CLNRest)
- Eclair
- LNPay, OpenNode, Alby
- Breez, Phoenix
- NWC (Nostr Wallet Connect)
- And 20+ more...
#lnbits #lightning
---
## API Reference
### Authentication
LNbits uses API keys for authentication:
| Key Type | Header | Permissions |
|----------|--------|-------------|
| Admin Key | `X-API-KEY: {adminkey}` | Full wallet control |
| Invoice Key | `X-API-KEY: {invoicekey}` | Create invoices, view payments |
```typescript
const headers = {
'X-API-KEY': config.adminKey,
'Content-Type': 'application/json',
}
```
### Core Endpoints
#### Get Wallet Info
```http
GET /api/v1/wallet
X-API-KEY: {adminkey}
```
**Response:**
```json
{
"id": "wallet-uuid",
"name": "ATM Wallet",
"balance": 1500000
}
```
> [!note] Balance Units
> All amounts in LNbits API are in **millisatoshis (msat)**. Divide by 1000 for satoshis.
#### Create Invoice (Receive)
```http
POST /api/v1/payments
X-API-KEY: {invoicekey}
Content-Type: application/json
{
"out": false,
"amount": 50000,
"memo": "ATM Cash-out",
"expiry": 600,
"webhook": "https://lamassu.example.com/api/webhook/payment"
}
```
**Response:**
```json
{
"payment_hash": "abc123...",
"payment_request": "lnbc500u1p...",
"checking_id": "xyz789..."
}
```
| Field | Description |
|-------|-------------|
| `out` | `false` for receiving, `true` for sending |
| `amount` | Amount in **satoshis** |
| `memo` | Invoice description |
| `expiry` | Seconds until expiration (default: 3600) |
| `webhook` | Optional callback URL |
#### Pay Invoice (Send)
```http
POST /api/v1/payments
X-API-KEY: {adminkey}
Content-Type: application/json
{
"out": true,
"bolt11": "lnbc500u1p..."
}
```
**Response:**
```json
{
"payment_hash": "abc123...",
"checking_id": "xyz789...",
"fee": 5
}
```
#### Check Payment Status
```http
GET /api/v1/payments/{checking_id}
X-API-KEY: {invoicekey}
```
**Response:**
```json
{
"paid": true,
"pending": false,
"preimage": "def456...",
"payment_hash": "abc123...",
"amount": 50000,
"fee": 5,
"memo": "ATM Cash-out",
"time": 1706018400,
"bolt11": "lnbc500u1p..."
}
```
#### LNURL Scan
```http
GET /api/v1/lnurlscan/{code}
X-API-KEY: {invoicekey}
```
Decodes LNURL or Lightning Address and returns metadata.
**Response (Lightning Address):**
```json
{
"kind": "pay",
"domain": "walletofsatoshi.com",
"callback": "https://walletofsatoshi.com/lnurlp/user/callback",
"minSendable": 1000,
"maxSendable": 100000000000,
"metadata": "[['text/plain', 'Sats for user']]",
"allowsNostr": true,
"commentAllowed": 255
}
```
#### Pay to LNURL
```http
POST /api/v1/payments/lnurl
X-API-KEY: {adminkey}
Content-Type: application/json
{
"callback": "https://walletofsatoshi.com/lnurlp/user/callback",
"amount": 50000,
"comment": "ATM withdrawal"
}
```
#api #endpoints
---
## TypeScript Client
### Full Implementation
```typescript
// packages/server/lib/lightning/lnbits-client.ts
import { z } from 'zod'
// Schemas
const WalletInfoSchema = z.object({
id: z.string(),
name: z.string(),
balance: z.number(), // msat
})
const CreateInvoiceResponseSchema = z.object({
payment_hash: z.string(),
payment_request: z.string(),
checking_id: z.string(),
})
const PaymentResponseSchema = z.object({
payment_hash: z.string(),
checking_id: z.string(),
fee: z.number().optional(),
})
const PaymentStatusSchema = z.object({
paid: z.boolean(),
pending: z.boolean(),
preimage: z.string().nullable(),
payment_hash: z.string(),
amount: z.number(),
fee: z.number(),
memo: z.string().nullable(),
time: z.number(),
bolt11: z.string(),
})
const LnurlPayResponseSchema = z.object({
kind: z.literal('pay'),
callback: z.string(),
minSendable: z.number(),
maxSendable: z.number(),
metadata: z.string(),
commentAllowed: z.number().optional(),
})
// Types
export interface LNbitsConfig {
baseUrl: string
adminKey: string
invoiceKey: string
walletId: string
timeout?: number
}
export interface Invoice {
bolt11: string
paymentHash: string
checkingId: string
expiresAt: Date
}
export interface PaymentResult {
success: boolean
paymentHash: string
checkingId: string
feeSats?: number
error?: string
}
export interface PaymentStatus {
paid: boolean
pending: boolean
preimage: string | null
amountSats: number
feeSats: number
}
// Client Implementation
export class LNbitsClient {
private baseUrl: string
private adminKey: string
private invoiceKey: string
private timeout: number
constructor(config: LNbitsConfig) {
this.baseUrl = config.baseUrl.replace(/\/$/, '')
this.adminKey = config.adminKey
this.invoiceKey = config.invoiceKey
this.timeout = config.timeout ?? 30000
}
private async request<T>(
method: string,
path: string,
key: 'admin' | 'invoice',
body?: unknown
): Promise<T> {
const apiKey = key === 'admin' ? this.adminKey : this.invoiceKey
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), this.timeout)
try {
const response = await fetch(`${this.baseUrl}${path}`, {
method,
headers: {
'X-API-KEY': apiKey,
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
})
if (!response.ok) {
const error = await response.text()
throw new Error(`LNbits API error: ${response.status} - ${error}`)
}
return response.json()
} finally {
clearTimeout(timeoutId)
}
}
// Wallet Operations
async getWalletInfo(): Promise<{ id: string; name: string; balanceSats: number }> {
const data = await this.request('GET', '/api/v1/wallet', 'admin')
const parsed = WalletInfoSchema.parse(data)
return {
id: parsed.id,
name: parsed.name,
balanceSats: Math.floor(parsed.balance / 1000),
}
}
async getBalance(): Promise<number> {
const info = await this.getWalletInfo()
return info.balanceSats
}
// Invoice Operations
async createInvoice(
amountSats: number,
memo: string,
options?: {
expiry?: number
webhook?: string
}
): Promise<Invoice> {
const data = await this.request('POST', '/api/v1/payments', 'invoice', {
out: false,
amount: amountSats,
memo,
expiry: options?.expiry ?? 600,
webhook: options?.webhook,
})
const parsed = CreateInvoiceResponseSchema.parse(data)
return {
bolt11: parsed.payment_request,
paymentHash: parsed.payment_hash,
checkingId: parsed.checking_id,
expiresAt: new Date(Date.now() + (options?.expiry ?? 600) * 1000),
}
}
async getPaymentStatus(checkingId: string): Promise<PaymentStatus> {
const data = await this.request('GET', `/api/v1/payments/${checkingId}`, 'invoice')
const parsed = PaymentStatusSchema.parse(data)
return {
paid: parsed.paid,
pending: parsed.pending,
preimage: parsed.preimage,
amountSats: parsed.amount,
feeSats: parsed.fee,
}
}
async waitForPayment(
checkingId: string,
timeoutMs: number = 600000
): Promise<PaymentStatus> {
const startTime = Date.now()
const pollInterval = 2000
while (Date.now() - startTime < timeoutMs) {
const status = await this.getPaymentStatus(checkingId)
if (status.paid) return status
if (!status.pending) throw new Error('Payment failed or expired')
await new Promise(resolve => setTimeout(resolve, pollInterval))
}
throw new Error('Payment timeout')
}
// Payment Operations
async payInvoice(bolt11: string): Promise<PaymentResult> {
try {
const data = await this.request('POST', '/api/v1/payments', 'admin', {
out: true,
bolt11,
})
const parsed = PaymentResponseSchema.parse(data)
return {
success: true,
paymentHash: parsed.payment_hash,
checkingId: parsed.checking_id,
feeSats: parsed.fee,
}
} catch (error) {
return {
success: false,
paymentHash: '',
checkingId: '',
error: error instanceof Error ? error.message : 'Unknown error',
}
}
}
// Lightning Address Operations
async resolveLightningAddress(address: string): Promise<{
callback: string
minSats: number
maxSats: number
commentAllowed: number
}> {
const [name, domain] = address.split('@')
if (!name || !domain) {
throw new Error('Invalid Lightning Address format')
}
const response = await fetch(
`https://${domain}/.well-known/lnurlp/${name}`,
{ signal: AbortSignal.timeout(10000) }
)
if (!response.ok) {
throw new Error(`Failed to resolve Lightning Address: ${response.status}`)
}
const data = LnurlPayResponseSchema.parse(await response.json())
return {
callback: data.callback,
minSats: Math.ceil(data.minSendable / 1000),
maxSats: Math.floor(data.maxSendable / 1000),
commentAllowed: data.commentAllowed ?? 0,
}
}
async payToLightningAddress(
address: string,
amountSats: number,
comment?: string
): Promise<PaymentResult> {
// Step 1: Resolve address to LNURL-pay endpoint
const resolved = await this.resolveLightningAddress(address)
// Validate amount
if (amountSats < resolved.minSats || amountSats > resolved.maxSats) {
return {
success: false,
paymentHash: '',
checkingId: '',
error: `Amount must be between ${resolved.minSats} and ${resolved.maxSats} sats`,
}
}
// Step 2: Get invoice from callback
const callbackUrl = new URL(resolved.callback)
callbackUrl.searchParams.set('amount', (amountSats * 1000).toString())
if (comment && resolved.commentAllowed > 0) {
callbackUrl.searchParams.set('comment', comment.slice(0, resolved.commentAllowed))
}
const invoiceResponse = await fetch(callbackUrl.toString(), {
signal: AbortSignal.timeout(10000),
})
if (!invoiceResponse.ok) {
return {
success: false,
paymentHash: '',
checkingId: '',
error: 'Failed to get invoice from Lightning Address',
}
}
const { pr: bolt11 } = await invoiceResponse.json()
// Step 3: Pay the invoice
return this.payInvoice(bolt11)
}
}
```
### Usage Examples
```typescript
// Initialize client
const lnbits = new LNbitsClient({
baseUrl: 'https://lnbits.example.com',
adminKey: process.env.LNBITS_ADMIN_KEY!,
invoiceKey: process.env.LNBITS_INVOICE_KEY!,
walletId: process.env.LNBITS_WALLET_ID!,
})
// Check balance
const balance = await lnbits.getBalance()
console.log(`Wallet balance: ${balance} sats`)
// Cash-out: Create invoice for customer to pay
const invoice = await lnbits.createInvoice(50000, 'ATM Cash-out')
console.log(`Invoice: ${invoice.bolt11}`)
// Wait for payment
const status = await lnbits.waitForPayment(invoice.checkingId, 600000)
if (status.paid) {
console.log('Payment received!')
}
// Cash-in: Pay to customer's Lightning Address
const result = await lnbits.payToLightningAddress(
'user@walletofsatoshi.com',
50000,
'ATM withdrawal'
)
if (result.success) {
console.log(`Payment sent! Hash: ${result.paymentHash}`)
}
```
#typescript #implementation
---
## Deployment Architecture
### Single LNbits Instance (Recommended)
```mermaid
graph TB
subgraph "ATM Fleet"
atm1[ATM Berlin]
atm2[ATM Paris]
atm3[ATM Amsterdam]
end
subgraph "Lamassu Server"
api[REST API]
lightning[Lightning Service]
end
subgraph "LNbits"
lnbits_api[LNbits API]
wallet[Shared Wallet]
end
subgraph "Lightning Node"
lnd[LND / CLN]
end
atm1 --> api
atm2 --> api
atm3 --> api
api --> lightning
lightning --> lnbits_api
lnbits_api --> wallet
wallet --> lnd
```
**Pros:**
- Single point of management
- Shared liquidity
- Simpler monitoring
**Cons:**
- Single point of failure
- Requires robust HA setup
### Per-ATM Wallets
```mermaid
graph TB
subgraph "LNbits"
lnbits_api[LNbits API]
subgraph "Wallets"
wallet1[ATM-Berlin Wallet]
wallet2[ATM-Paris Wallet]
wallet3[ATM-Amsterdam Wallet]
end
end
atm1[ATM Berlin] -->|adminkey_1| wallet1
atm2[ATM Paris] -->|adminkey_2| wallet2
atm3[ATM Amsterdam] -->|adminkey_3| wallet3
```
**Pros:**
- Isolated balances
- Per-ATM accounting
- Granular key management
**Cons:**
- More complex setup
- Fragmented liquidity
#architecture #deployment
---
## Configuration
### Environment Variables
```bash
# .env (packages/server)
# LNbits Connection
LNBITS_URL=https://lnbits.example.com
LNBITS_ADMIN_KEY=abc123...
LNBITS_INVOICE_KEY=def456...
LNBITS_WALLET_ID=wallet-uuid
# Lightning Settings
LIGHTNING_PROVIDER=lnbits
LIGHTNING_TIMEOUT_MS=30000
LIGHTNING_MAX_FEE_PERCENT=1.0
```
### NixOS Configuration
```nix
# /etc/nixos/lnbits.nix
{ config, pkgs, ... }:
{
services.lnbits = {
enable = true;
host = "127.0.0.1";
port = 5000;
settings = {
LNBITS_BACKEND_WALLET_CLASS = "LndRestWallet";
LND_REST_ENDPOINT = "https://localhost:8080";
LND_REST_CERT = "/var/lib/lnd/tls.cert";
LND_REST_MACAROON = "/var/lib/lnd/admin.macaroon";
LNBITS_DATABASE_URL = "postgres://lnbits:password@localhost/lnbits";
LNBITS_SITE_TITLE = "Lamassu Lightning";
};
};
# Reverse proxy with Caddy
services.caddy.virtualHosts."lnbits.example.com" = {
extraConfig = ''
reverse_proxy localhost:5000
'';
};
}
```
### sops-nix Secrets
```yaml
# secrets/lnbits.yaml
lnbits_admin_key: ENC[AES256_GCM,data:...,type:str]
lnbits_invoice_key: ENC[AES256_GCM,data:...,type:str]
```
```nix
# NixOS module
sops.secrets.lnbits_admin_key = {
sopsFile = ./secrets/lnbits.yaml;
owner = "lamassu";
};
sops.secrets.lnbits_invoice_key = {
sopsFile = ./secrets/lnbits.yaml;
owner = "lamassu";
};
```
#configuration #nixos #secrets
---
## Monitoring & Alerts
### Health Checks
```typescript
// Health check endpoint
async function checkLNbitsHealth(): Promise<HealthStatus> {
try {
const balance = await lnbits.getBalance()
return {
healthy: true,
balance,
timestamp: new Date(),
}
} catch (error) {
return {
healthy: false,
error: error.message,
timestamp: new Date(),
}
}
}
```
### Balance Alerts
```typescript
const LOW_BALANCE_THRESHOLD = 100000 // 100k sats
async function checkBalanceAlerts() {
const balance = await lnbits.getBalance()
if (balance < LOW_BALANCE_THRESHOLD) {
await sendAlert({
level: 'warning',
message: `Low LNbits balance: ${balance} sats`,
action: 'Top up Lightning wallet',
})
}
}
```
### Prometheus Metrics
```typescript
// Expose metrics for Prometheus
import { Counter, Gauge } from 'prom-client'
const lnbitsBalance = new Gauge({
name: 'lnbits_wallet_balance_sats',
help: 'Current LNbits wallet balance in satoshis',
})
const lnbitsPayments = new Counter({
name: 'lnbits_payments_total',
help: 'Total LNbits payments',
labelNames: ['direction', 'status'],
})
// Update metrics
setInterval(async () => {
const balance = await lnbits.getBalance()
lnbitsBalance.set(balance)
}, 60000)
```
#monitoring #alerts #prometheus
---
## Error Handling
### Common Errors
| Error | Cause | Resolution |
|-------|-------|------------|
| `INSUFFICIENT_BALANCE` | Wallet balance too low | Top up wallet |
| `INVOICE_EXPIRED` | Invoice not paid in time | Create new invoice |
| `PAYMENT_FAILED` | Route not found | Check node connectivity |
| `RATE_LIMITED` | Too many requests | Implement backoff |
| `UNAUTHORIZED` | Invalid API key | Check key configuration |
### Retry Strategy
```typescript
import pRetry from 'p-retry'
async function payWithRetry(bolt11: string): Promise<PaymentResult> {
return pRetry(
async () => {
const result = await lnbits.payInvoice(bolt11)
if (!result.success && result.error?.includes('ROUTE')) {
throw new Error('Retryable: No route found')
}
return result
},
{
retries: 3,
minTimeout: 1000,
maxTimeout: 10000,
onFailedAttempt: (error) => {
console.log(`Payment attempt ${error.attemptNumber} failed`)
},
}
)
}
```
#errors #retry
---
## Related Documents
- [[membership-lightning-integration]] - Membership feature using LNbits
- [[modernization-plan]] - Overall modernization roadmap
- [[API Key Authentication]] - Server authentication