REST API
The ln.bot REST API: accounts, keys, wallets, invoices, payments, Lightning addresses, transactions, webhooks, event streams, L402 and errors.
- Base URL:
https://api.ln.bot - Auth:
Authorization: Bearer uk_...(user key) orBearer wk_...(wallet key) - Content-Type:
application/jsonon requests with a body
Two key types:
| Key type | Prefix | Reaches |
|---|---|---|
| User key | uk_ |
Every wallet on the account |
| Wallet key | wk_ |
One wallet |
POST /v1/register returns two user keys, a primary and a secondary. A wallet key is created per wallet, at most one each, with a user key. Most routes under /v1/wallets/{walletId}/ take either kind of key. The wallet key routes themselves take a user key, as do creating and listing wallets and rotating user keys; event streams take a wallet key only. There are no read-only keys or scopes.
Getting started
Three calls take you from no account to an invoice. Register first; no key is needed:
curl -X POST https://api.ln.bot/v1/register{
"userId": "usr_a1b2c3d4",
"primaryKey": "uk_...",
"secondaryKey": "uk_...",
"recoveryPassphrase": "orbit garden pilot smoke ..."
}Store that response now: the API never returns these keys again, and the passphrase is how you restore access. Then create a wallet with the primary key:
curl -X POST https://api.ln.bot/v1/wallets \
-H "Authorization: Bearer uk_..."{
"walletId": "wal_c7i0hnuoxxupuwej",
"name": "x4k7m2",
"address": "x4k7m2@ln.bot"
}The walletId goes in every wallet route. Create an invoice on it:
curl -X POST https://api.ln.bot/v1/wallets/wal_c7i0hnuoxxupuwej/invoices \
-H "Authorization: Bearer uk_..." \
-H "Content-Type: application/json" \
-d '{"amount": 1000, "memo": "first invoice"}'The response's bolt11 is the invoice to pay. Anyone can also pay x4k7m2@ln.bot without one.
Units
Amounts are integers in bitcoin's base unit, the sat, which this site writes with ₿ as the draft BIP 177 proposes: "amount": 1000 is ₿1,000, or 0.00001 BTC. Amounts, fees, balances, prices and costs all use that unit. LNURL is the exception: a Lightning address's pay endpoint, https://ln.bot/.well-known/lnurlp/<name>, works in millisatoshis as LNURL requires, and accepts 1,000 to 1,000,000,000 msat (₿1 to ₿1,000,000) per payment.
Errors
A failed request returns a 4xx or 5xx status. The SDKs turn the common ones into typed errors that keep the status and the raw response body (err.status and err.body in TypeScript), and use the body's message field as the error message when there is one.
| Status | Typical cause | TypeScript and Python |
|---|---|---|
| 400 | Invalid input: a zero amount, an invalid target, not enough balance, a network fee above the cap, a generated address in a transfer, an unparseable L402 token, a malformed wallet ID | BadRequestError |
| 401 | Missing or invalid key | UnauthorizedError |
| 403 | The key can't use this resource | ForbiddenError |
| 404 | No such wallet, invoice, payment, address, webhook or wallet key | NotFoundError |
| 409 | A conflict, such as a second wallet key for one wallet | ConflictError |
| 429 | Too many requests to the public invoice routes, which are limited by IP | LnBotError |
Go has BadRequestError and the rest, each wrapping *lnbot.APIError; Rust has LnBotError::BadRequest and the rest; C# has BadRequestException and the rest. Any other status arrives as the base type with its status code: LnBotError in TypeScript and Python, *lnbot.APIError in Go, LnBotError::Api in Rust, LnBotException in C#.
Account
POST /v1/register
Register a new account. No authentication required.
Response:
{
"userId": "usr_a1b2c3d4",
"primaryKey": "uk_...",
"secondaryKey": "uk_...",
"recoveryPassphrase": "orbit garden pilot smoke ..."
}primaryKey and secondaryKey are user keys; either works wherever a user key does. recoveryPassphrase is 12 words.
The API returns these keys and the passphrase only here. Save them now.
GET /v1/me
Get the authenticated identity. Works with both user keys and wallet keys; with a wallet key, walletId is that key's wallet.
Response:
{
"walletId": "wal_c7i0hnuoxxupuwej"
}Wallets
POST /v1/wallets
Create a wallet. Requires a user key and takes no request body. The name is generated and the wallet gets a random Lightning address. The response has no keys: reach the wallet with the account's user key, or create a wallet key for it. There is no endpoint to delete a wallet.
Response:
{
"walletId": "wal_c7i0hnuoxxupuwej",
"name": "x4k7m2",
"address": "x4k7m2@ln.bot"
}GET /v1/wallets
List all wallets. Requires a user key.
Response:
[
{
"walletId": "wal_c7i0hnuoxxupuwej",
"name": "x4k7m2",
"createdAt": "2026-01-15T10:00:00Z"
}
]GET /v1/wallets/{walletId}
Get the wallet's name and balance. available is balance minus onHold, the amount held for payments in flight. With a wallet key, GET /v1/wallets/current returns the key's own wallet. With a wallet key, current works on every wallet route, so /v1/wallets/current/invoices lists that wallet's invoices; with a user key it returns 400.
Response:
{
"walletId": "wal_c7i0hnuoxxupuwej",
"name": "x4k7m2",
"balance": 12500,
"onHold": 0,
"available": 12500
}PATCH /v1/wallets/{walletId}
Rename the wallet. With a wallet key, PATCH /v1/wallets/current does the same.
Request body:
{
"name": "my-agent"
}Response:
{
"walletId": "wal_c7i0hnuoxxupuwej",
"name": "my-agent",
"balance": 12500,
"onHold": 0,
"available": 12500
}Wallet keys
A wallet key (wk_) reaches one wallet, and each wallet has at most one. These routes take either key, so a wallet key can also rotate or revoke itself.
POST /v1/wallets/{walletId}/key
Create the wallet's key. If it already has one, the API returns 409.
Response:
{
"key": "wk_...",
"hint": "wk_...a1b2"
}The key is shown once and cannot be retrieved again.
GET /v1/wallets/{walletId}/key
Get wallet key info (no plaintext key).
Response:
{
"hint": "wk_...a1b2",
"createdAt": "2026-01-15T10:00:00Z",
"lastUsedAt": "2026-01-16T08:00:00Z"
}DELETE /v1/wallets/{walletId}/key
Revoke the wallet key immediately.
Returns 204 No Content on success.
POST /v1/wallets/{walletId}/key/rotate
Rotate the wallet key. The old key stops working immediately.
Response:
{
"key": "wk_...",
"hint": "wk_...c3d4"
}Invoices
POST /v1/wallets/{walletId}/invoices
Create a BOLT11 invoice, valid for 60 minutes. reference is for your own tracking; memo is embedded in the invoice. A zero amount returns 400.
Request body:
{
"amount": 1000,
"memo": "AI task payment",
"reference": null
}Response:
{
"number": 1,
"status": "pending",
"amount": 1000,
"bolt11": "lnbc10u1pn...",
"reference": null,
"memo": "AI task payment",
"preimage": null,
"txNumber": null,
"createdAt": "2026-01-15T10:30:00Z",
"settledAt": null,
"expiresAt": "2026-01-15T11:30:00Z"
}GET /v1/wallets/{walletId}/invoices
List invoices, newest first.
| Parameter | In | Type | Description |
|---|---|---|---|
limit |
query | int | Max results |
after |
query | int | Return invoices numbered below this one (the last number on the previous page) |
Response:
[
{
"number": 2,
"status": "settled",
"amount": 500,
"bolt11": "lnbc5u1pn...",
"reference": null,
"memo": null,
"preimage": "d4e5f6...",
"txNumber": 3,
"createdAt": "2026-01-15T11:00:00Z",
"settledAt": "2026-01-15T11:02:00Z",
"expiresAt": "2026-01-15T12:00:00Z"
}
]GET /v1/wallets/{walletId}/invoices/{number}
Get invoice by number.
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
number |
path | int | yes | Invoice number |
Response: Same shape as POST, with settlement data populated.
GET /v1/wallets/{walletId}/invoices/{paymentHash}
Get invoice by payment hash.
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
paymentHash |
path | string | yes | Payment hash |
Same response as GET by number.
GET /v1/wallets/{walletId}/invoices/{number}/events
Wait for an invoice over Server-Sent Events. Works with either key. The stream sends one event, settled or expired, with the invoice as JSON in its data, then closes. If the wait runs out first, the event is timeout and data is the invoice's current state. The JSON uses snake_case keys (created_at, tx_number), as webhooks do; the SDKs convert them to the field names their REST methods use.
expired isn't always final: an invoice paid just before it expired can turn settled afterwards. Before treating it as unpaid, read it again, or keep listening for invoice.settled on the wallet's event stream or a webhook.
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
number |
path | int | yes | Invoice number |
timeout |
query | int | no | Max wait in seconds (default 60, max 300) |
Also available as /v1/wallets/{walletId}/invoices/{paymentHash}/events. Keepalive comments every 30s.
POST /v1/invoices/for-wallet
Create an invoice for a wallet from its ID (wal_...) alone, without a key. Rate limited by IP. The invoice is not saved until payment settles. A malformed wallet ID returns 400.
Request body:
{
"walletId": "wal_c7i0hnuoxxupuwej",
"amount": 1000,
"reference": null,
"comment": null
}Response:
{
"bolt11": "lnbc10u1pn...",
"amount": 1000,
"expiresAt": "2026-01-15T11:30:00Z"
}No auth required.
POST /v1/invoices/for-address
Create an invoice for the wallet that owns a Lightning address. No authentication required; rate limited by IP. ln.bot's own Lightning address endpoint calls this route: it passes the +tag part of name+tag@ln.bot as tag, which the invoice keeps as its reference, and the payer's note as comment.
Request body:
{
"address": "x4k7m2@ln.bot",
"amount": 1000,
"tag": null,
"comment": null
}Response:
{
"bolt11": "lnbc10u1pn...",
"amount": 1000,
"expiresAt": "2026-01-15T11:30:00Z"
}No auth required.
Payments
POST /v1/wallets/{walletId}/payments
Send a payment. target is a Lightning address (user@domain), an LNURL (lnurl1...) or a BOLT11 invoice. amount is required for addresses and LNURLs; a BOLT11 invoice uses its own amount, rounded up to a whole sat if it has a fraction of one. A retry with the same idempotencyKey returns the original payment instead of paying again; keys are scoped to the wallet. Not enough balance returns 400.
Transfers between ln.bot wallets are free. A payment out to Lightning pays a service fee of 0.25% (at least ₿1) and the network fee: 0.25% plus Lightning routing. maxFee caps the network fee. Leave it out: it defaults to 1% of the amount, at least ₿20, and accepts 1 up to the larger of 10,000 and 5% of the amount. The fee is quoted when the payment is created; a quote above the cap rejects the payment, and only the quote plus 15% (at least ₿2, never more than the cap) is held while it's in flight. Price the payment first to show the fees before sending.
Request body:
{
"target": "alice@example.com",
"amount": 250000,
"maxFee": null,
"idempotencyKey": null,
"reference": null
}Response:
{
"number": 1,
"status": "settled",
"amount": 250000,
"maxFee": 805,
"serviceFee": 625,
"actualFee": 698,
"address": "alice@example.com",
"reference": null,
"preimage": "e5f6a7b8...",
"txNumber": 2,
"failureReason": null,
"createdAt": "2026-01-15T10:35:00Z",
"settledAt": "2026-01-15T10:35:02Z"
}| Field | Meaning |
|---|---|
maxFee |
Network fee held for this payment: the quote plus the buffer. The unused part is released at settlement. |
actualFee |
Network fee charged, null until the payment settles |
serviceFee |
0.25%, at least ₿1; 0 between ln.bot wallets |
failureReason |
Why the payment failed, null otherwise |
While the payment is in flight the wallet's onHold includes amount + maxFee + serviceFee. When it settles the wallet is debited amount + actualFee + serviceFee, the rest of the hold is released, and the receiver gets exactly amount.
The response can come back pending or processing while the payment is in flight. Read it again with GET .../payments/{number}, or wait on its events, until the status is settled or failed. Payments between ln.bot wallets settle within about half a second. One that can't be routed yet stays pending and is retried: it fails if its invoice expires first, and after 24 hours at most. A failing payment can stay processing for a few seconds before it turns failed.
failureReason is one of:
Invoice expired before it could be paid.Routing fee estimate (X sats) exceeds the maximum fee (Y sats).Invalid Lightning invoice.Lightning payment failed (<STATUS>)., such asLightning payment failed (LIGHTNING_PAYMENT_FAILED).Payment expired after 24 hours
These requests return 400 with { "error": "..." } and send nothing:
error |
When |
|---|---|
Network fee estimate (X sats) is above the default limit for this amount (Y sats). Set maxFee to allow a higher fee. |
maxFee left out and the quote is above the default cap |
Network fee estimate (X sats) exceeds maxFee (Y sats). |
The quote is above your maxFee |
MaxFee must be between 1 and N sats. |
maxFee out of range; N depends on the amount |
Recipient returned an invoice without an amount. |
A Lightning address or LNURL server sent a bad invoice |
Recipient returned an invoice for X sats, but Y sats were requested. |
The same, for the wrong amount |
GET /v1/wallets/{walletId}/payments
List payments, newest first.
| Parameter | In | Type | Description |
|---|---|---|---|
limit |
query | int | Max results |
after |
query | int | Return payments numbered below this one (the last number on the previous page) |
Response:
[
{
"number": 1,
"status": "settled",
"amount": 500,
"maxFee": 0,
"serviceFee": 0,
"actualFee": 0,
"address": "r7vq3np@ln.bot",
"reference": null,
"preimage": "e5f6a7b8...",
"txNumber": 2,
"failureReason": null,
"createdAt": "2026-01-15T10:35:00Z",
"settledAt": "2026-01-15T10:35:01Z"
}
]GET /v1/wallets/{walletId}/payments/{number}
Get payment by number.
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
number |
path | int | yes | Payment number |
Same response shape as POST.
GET /v1/wallets/{walletId}/payments/{paymentHash}
Get payment by payment hash.
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
paymentHash |
path | string | yes | Payment hash |
Same response shape as POST.
GET /v1/wallets/{walletId}/payments/resolve
Inspect and price a payment without sending it: what the target accepts, the network and service fees, and the total. Nothing is sent or held.
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
target |
query | string | yes | Lightning address, LNURL, or BOLT11 |
amount |
query | int | no | The amount to price. For a BOLT11 invoice it must equal the invoice's amount. |
Response for a BOLT11 invoice:
{
"type": "bolt11",
"min": null,
"max": null,
"fixed": true,
"amount": 250000,
"internal": false,
"networkFeeEstimate": 700,
"serviceFee": 625,
"maxFee": 805,
"total": 251325,
"maxSendable": null,
"feeWarning": null
}| Field | Meaning |
|---|---|
type |
lightning_address, lnurl or bolt11 |
min, max |
The amounts the target accepts; null for a BOLT11 invoice |
fixed |
true when the target takes one amount only, such as a BOLT11 invoice with an amount |
amount |
The amount priced: the invoice's for BOLT11, otherwise the amount you passed; null without one |
internal |
true when the recipient is an ln.bot wallet: a free transfer |
networkFeeEstimate |
The network fee quote, 0 if internal; null without an amount or when no quote is available |
serviceFee |
0.25%, at least ₿1, 0 if internal; null without an amount |
maxFee |
What would be held for the network fee with the default maxFee; null without an amount or when feeWarning is set |
total |
amount + networkFeeEstimate + serviceFee, using the held limit when there's no quote; null without an amount |
maxSendable |
For a Lightning address or LNURL asked without amount: the most this wallet can send there now, after fees. 0 if it can't cover the recipient's minimum, null otherwise. |
feeWarning |
Set when sending without maxFee would be rejected because the fee is unusually high. The response is still 200. |
To show the fees before paying, call it with the target, and the amount for a Lightning address or LNURL, then display networkFeeEstimate, serviceFee and total. To send everything, call it for a Lightning address or LNURL without amount, then send the payment with amount set to maxSendable and no maxFee.
Pricing a Lightning address or LNURL fetches an invoice from the recipient's server, so it's slower than the other calls: run it once the amount is settled, not on every keystroke. An ln.bot address returns internal: true with min 1 and max 1,000,000,000. An unknown ln.bot address returns 400 Recipient not found., and the wallet's own address returns 400 Cannot transfer to yourself.. An amount outside the recipient's range returns 400 with error, min and max.
GET /v1/wallets/{walletId}/payments/{number}/events
Wait for a payment over Server-Sent Events. Works with either key. The stream sends one event, settled or failed, with the payment as JSON in its data, then closes. If the wait runs out first, the event is timeout and data is the payment's current state. The JSON uses snake_case keys (created_at, tx_number), as webhooks do; the SDKs convert them to the field names their REST methods use.
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
number |
path | int | yes | Payment number |
timeout |
query | int | no | Max wait in seconds (default 60, max 300) |
Also available as /v1/wallets/{walletId}/payments/{paymentHash}/events. Keepalive comments every 30s.
Addresses
POST /v1/wallets/{walletId}/addresses
Create a random address ("address": null) or claim a vanity name ("address": "mybot"). A vanity name costs ₿100 for five characters or more, ₿1,000 for four and ₿10,000 for three, taken from the wallet's balance. Names are 3 to 20 lowercase letters and digits.
Request body:
{
"address": null
}Response:
{
"address": "j4hf8tj@ln.bot",
"generated": true,
"cost": 0,
"createdAt": "2026-01-15T10:00:00Z"
}GET /v1/wallets/{walletId}/addresses
List addresses.
Response:
[
{
"address": "j4hf8tj@ln.bot",
"generated": true,
"cost": 0,
"createdAt": "2026-01-15T10:00:00Z"
},
{
"address": "mybot@ln.bot",
"generated": false,
"cost": 100,
"createdAt": "2026-01-16T08:00:00Z"
}
]DELETE /v1/wallets/{walletId}/addresses/{address}
Delete an address. No refund for vanity addresses.
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
address |
path | string | yes | The full address, such as mybot@ln.bot |
Returns 204 No Content on success.
POST /v1/wallets/{walletId}/addresses/{address}/transfer
Move an address to another wallet. Only vanity (bought) addresses can be transferred; a generated one returns 400.
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
address |
path | string | yes | The full address, such as mybot@ln.bot |
Request body:
{
"targetWalletKey": "wk_..."
}targetWalletKey is the receiving wallet's key. The SDK integration tests and the TypeScript README pass a wallet key (wk_); only the CLI's help text shows a user key here.
Response:
{
"address": "mybot@ln.bot",
"transferredTo": "wal_x8j2knroyyqpvwfh"
}Transactions
GET /v1/wallets/{walletId}/transactions
List transactions. Returns credit and debit transactions in reverse chronological order. Each includes the balance after it was applied.
| Parameter | In | Type | Description |
|---|---|---|---|
limit |
query | int | Max results |
after |
query | int | Return transactions numbered below this one (the last number on the previous page) |
Response:
[
{
"number": 3,
"type": "credit",
"amount": 500,
"balanceAfter": 13000,
"networkFee": 0,
"serviceFee": 0,
"paymentHash": "a1b2c3...",
"preimage": "d4e5f6...",
"reference": null,
"note": null,
"createdAt": "2026-01-15T11:02:00Z"
},
{
"number": 2,
"type": "debit",
"amount": 500,
"balanceAfter": 12500,
"networkFee": 0,
"serviceFee": 0,
"paymentHash": "f6e5d4...",
"preimage": "c3b2a1...",
"reference": null,
"note": null,
"createdAt": "2026-01-15T10:35:01Z"
}
]Webhooks
POST /v1/wallets/{walletId}/webhooks
Create a webhook: ln.bot sends each of the wallet's events to this URL as a POST (what a delivery looks like). The URL must be HTTPS and resolve to a public address. A wallet can have up to 10 webhooks, one per URL: registering the same URL again returns 409. The signing secret is returned only once.
Request body:
{
"url": "https://example.com/hooks/lnbot"
}Response:
{
"id": "e82a6e9a410c916a",
"url": "https://example.com/hooks/lnbot",
"secret": "15a10301168f123cba107d4c3126a53944e5cbd5ff5796af821c31f9e6545e08",
"createdAt": "2026-01-15T10:00:00Z"
}Save the secret: it cannot be retrieved again. It is 64 hex characters, encoding the 32-byte key that signs each delivery.
GET /v1/wallets/{walletId}/webhooks
List webhooks. Secrets are not included in the response.
Response:
[
{
"id": "e82a6e9a410c916a",
"url": "https://example.com/hooks/lnbot",
"active": true,
"createdAt": "2026-01-15T10:00:00Z"
}
]DELETE /v1/wallets/{walletId}/webhooks/{id}
Delete a webhook. Pending deliveries are discarded.
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
id |
path | string | yes | Webhook ID |
Returns 204 No Content on success.
Receiving webhooks
Each event arrives as a POST with a JSON body, the same object the wallet's event stream sends:
{
"event": "invoice.settled",
"created_at": "2026-01-15T10:05:00.1234567+00:00",
"data": {
"number": 42,
"status": "settled",
"amount": 1000,
"bolt11": "lnbc10u1pn...",
"reference": "order-1234",
"memo": "Coffee",
"preimage": "9f2c...",
"tx_number": 87,
"created_at": "2026-01-15T10:00:00.4567890+00:00",
"settled_at": "2026-01-15T10:05:00.1234567+00:00",
"expires_at": "2026-01-15T11:00:00.4567890+00:00"
}
}Keys are snake_case, unlike the rest of the API. For invoice.* events, data is the invoice. For payment.* events it is the payment: number, status, amount, max_fee, service_fee, address, reference and created_at, and once the payment has settled or failed, actual_fee, preimage, tx_number, failure_reason and settled_at as well.
Two headers come with it:
| Header | Value |
|---|---|
X-Webhook-Event |
The event name, as in the body |
X-Webhook-Signature |
sha256= followed by the hex HMAC-SHA256 of the raw body, keyed with the secret's 32 bytes |
To check a delivery, decode the secret from hex, compute the HMAC over the body exactly as it arrived, before parsing it, and compare in constant time:
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
// The secret is hex: the HMAC key is the 32 bytes it encodes, not the text
const secret = Buffer.from(process.env.LNBOT_WEBHOOK_SECRET!, "hex");
const app = express();
// express.raw keeps the body as it arrived, which is what was signed
app.post("/hooks/lnbot", express.raw({ type: "application/json" }), (req, res) => {
const expected = Buffer.from("sha256=" + createHmac("sha256", secret).update(req.body).digest("hex"));
const given = Buffer.from(req.get("X-Webhook-Signature") ?? "");
if (given.length !== expected.length || !timingSafeEqual(given, expected)) {
return res.sendStatus(401);
}
const { event, data } = JSON.parse(req.body.toString("utf8"));
if (event === "invoice.settled") console.log("invoice", data.number, "paid", data.amount);
res.sendStatus(200);
});
app.listen(3000);The same check in Python:
import hashlib
import hmac
import os
# The secret is hex: the HMAC key is the 32 bytes it encodes
secret = bytes.fromhex(os.environ["LNBOT_WEBHOOK_SECRET"])
def from_lnbot(body: bytes, signature: str) -> bool:
"""body is the raw request body, before any JSON parsing."""
expected = "sha256=" + hmac.new(secret, body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)Answer with a 2xx status within 15 seconds. Any other answer, or none, is a failure, and the delivery is tried again after 10 seconds, 30 seconds, 1 minute, 5, 15 and 30 minutes, then 1, 2 and 4 hours: 10 attempts over about 8 hours. After the tenth failure the event is dropped; the invoice or payment is still in the invoice and payment lists.
A retry sends the same body, and deliveries can arrive more than once or out of order, so act on each event once: event and data.number together identify it within a wallet. The signature covers only the body, so a recorded delivery would also verify if someone sent it again; acting on each event once covers that too.
A delivery doesn't say which wallet it is for. If one endpoint serves several wallets, register a different URL on each, such as https://example.com/hooks/lnbot/wal_c7i0hnuoxxupuwej, and keep each webhook's secret with its wallet ID.
User keys
POST /v1/keys/{slot}/rotate
Rotate a user key. Generates a new secret for the slot (0 = primary, 1 = secondary). The old key is immediately invalidated.
| Parameter | In | Type | Required | Description |
|---|---|---|---|---|
slot |
path | int | yes | 0 = primary, 1 = secondary |
Response:
{
"key": "uk_newrotated...",
"name": "primary"
}The new key is returned once and cannot be retrieved again.
Events
GET /v1/wallets/{walletId}/events
A Server-Sent Events stream of everything that happens on the wallet. Works with either key.
curl -N https://api.ln.bot/v1/wallets/wal_c7i0hnuoxxupuwej/events \
-H "Authorization: Bearer wk_..."Events: invoice.created, invoice.settled, payment.created, payment.settled, payment.failed. Each arrives as a data: line holding a JSON object with event, created_at and data (the invoice or the payment), the same object, with the same snake_case keys, that a webhook delivers. Keepalive comments every 30s.
In payment.* events, max_fee is the network fee held, actual_fee the network fee charged, and failure_reason one of the reasons a payment fails. An invoice paid just before it expired still sends invoice.settled.
L402
POST /v1/wallets/{walletId}/l402/challenges
Create an L402 challenge: a Lightning invoice and a macaroon. Returns the WWW-Authenticate value to send with an HTTP 402. caveats is an array of up to 10 key=value strings that go into the macaroon; expirySeconds adds an expiry caveat.
Request body:
{
"amount": 100,
"description": "API access",
"expirySeconds": 3600,
"caveats": ["tier=pro"]
}Response:
{
"macaroon": "AgELbG4uYm90...",
"invoice": "lnbc1u1pn...",
"paymentHash": "a1b2c3d4...",
"expiresAt": "2026-01-15T11:30:00Z",
"wwwAuthenticate": "L402 macaroon=\"...\", invoice=\"lnbc...\""
}POST /v1/wallets/{walletId}/l402/verify
Verify an L402 token: the macaroon's signature, the preimage (proof that the invoice was paid) and the caveats, such as expiry. Verification is stateless, with no database lookup, so a valid token keeps verifying until it expires, however many times it is presented. If each payment should buy one request, the paywall has to remember spent tokens; the 1.1 L402 middleware does. A token that can't be parsed returns 400 rather than "valid": false.
Request body:
{
"authorization": "L402 AgELbG4uYm90...:a1b2c3d4..."
}Response:
{
"valid": true,
"paymentHash": "a1b2c3d4...",
"caveats": ["expiry=1737000000"],
"error": null
}POST /v1/wallets/{walletId}/l402/pay
Pay an L402 challenge from this wallet. Parses the WWW-Authenticate header, pays the invoice it contains and returns a ready-to-use Authorization header. With wait (the default), it waits up to timeout seconds (1 to 120, default 60) for settlement; if the payment hasn't settled by then, authorization is null and status says where it is. It pays whatever the invoice asks, so check the price first if that matters: the TypeScript and C# clients read it from the invoice. maxFee caps the network fee, with the same default and limits as payments; leave it out. fee in the response is the network fee charged.
Request body:
{
"wwwAuthenticate": "L402 macaroon=\"...\", invoice=\"lnbc...\"",
"maxFee": null,
"reference": null,
"wait": true,
"timeout": 60
}Response:
{
"authorization": "L402 AgELbG4uYm90...:e5f6a7b8...",
"paymentHash": "a1b2c3d4...",
"preimage": "e5f6a7b8...",
"amount": 100,
"fee": 0,
"paymentNumber": 5,
"status": "settled"
}Backup and restore
POST /v1/backup/recovery
Generate a new 12-word recovery passphrase, with a user key. The previous passphrase stops working.
Response:
{
"passphrase": "copper rival tunnel fiscal ..."
}POST /v1/restore/recovery
Get back into an account with its recovery passphrase. No authentication required. Restoring rotates the account's API keys: the response carries the new ones, and the old ones stop working.
Request body:
{
"passphrase": "orbit garden pilot smoke ..."
}Response:
{
"walletId": "wal_c7i0hnuoxxupuwej",
"name": "x4k7m2",
"primaryKey": "uk_...",
"secondaryKey": "uk_..."
}Passkeys
A WebAuthn passkey is the other way back in. These routes run in a browser, as the terminal on the ln.bot home page does: each begin returns a sessionId and the options to pass to navigator.credentials.create() (backup) or navigator.credentials.get() (restore), and complete takes the sessionId with the authenticator's response.
| Route | Auth | Does |
|---|---|---|
POST /v1/backup/passkey/begin |
User key | Start registering a passkey |
POST /v1/backup/passkey/complete |
User key | Finish it, with the attestation |
POST /v1/restore/passkey/begin |
None | Start restoring |
POST /v1/restore/passkey/complete |
None | Finish, with the assertion; returns the same fields as the recovery route |