REST API

The ln.bot REST API: accounts, keys, wallets, invoices, payments, Lightning addresses, transactions, webhooks, event streams, L402 and errors.

Updated View as Markdown
  • Base URL: https://api.ln.bot
  • Auth: Authorization: Bearer uk_... (user key) or Bearer wk_... (wallet key)
  • Content-Type: application/json on 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
bash
{
  "userId": "usr_a1b2c3d4",
  "primaryKey": "uk_...",
  "secondaryKey": "uk_...",
  "recoveryPassphrase": "orbit garden pilot smoke ..."
}
json

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_..."
bash
{
  "walletId": "wal_c7i0hnuoxxupuwej",
  "name": "x4k7m2",
  "address": "x4k7m2@ln.bot"
}
json

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"}'
bash

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 ..."
}
json

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"
}
json

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"
}
json

GET /v1/wallets

List all wallets. Requires a user key.

Response:

[
  {
    "walletId": "wal_c7i0hnuoxxupuwej",
    "name": "x4k7m2",
    "createdAt": "2026-01-15T10:00:00Z"
  }
]
json

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
}
json

PATCH /v1/wallets/{walletId}

Rename the wallet. With a wallet key, PATCH /v1/wallets/current does the same.

Request body:

{
  "name": "my-agent"
}
json

Response:

{
  "walletId": "wal_c7i0hnuoxxupuwej",
  "name": "my-agent",
  "balance": 12500,
  "onHold": 0,
  "available": 12500
}
json

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"
}
json

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"
}
json

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"
}
json

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
}
json

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"
}
json

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"
  }
]
json

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
}
json

Response:

{
  "bolt11": "lnbc10u1pn...",
  "amount": 1000,
  "expiresAt": "2026-01-15T11:30:00Z"
}
json

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
}
json

Response:

{
  "bolt11": "lnbc10u1pn...",
  "amount": 1000,
  "expiresAt": "2026-01-15T11:30:00Z"
}
json

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
}
json

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"
}
json
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 as Lightning 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"
  }
]
json

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
}
json
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
}
json

Response:

{
  "address": "j4hf8tj@ln.bot",
  "generated": true,
  "cost": 0,
  "createdAt": "2026-01-15T10:00:00Z"
}
json

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"
  }
]
json

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_..."
}
json

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"
}
json

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"
  }
]
json

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"
}
json

Response:

{
  "id": "e82a6e9a410c916a",
  "url": "https://example.com/hooks/lnbot",
  "secret": "15a10301168f123cba107d4c3126a53944e5cbd5ff5796af821c31f9e6545e08",
  "createdAt": "2026-01-15T10:00:00Z"
}
json

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"
  }
]
json

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"
  }
}
json

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);
typescript

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)
python

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"
}
json

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_..."
bash

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"]
}
json

Response:

{
  "macaroon": "AgELbG4uYm90...",
  "invoice": "lnbc1u1pn...",
  "paymentHash": "a1b2c3d4...",
  "expiresAt": "2026-01-15T11:30:00Z",
  "wwwAuthenticate": "L402 macaroon=\"...\", invoice=\"lnbc...\""
}
json

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..."
}
json

Response:

{
  "valid": true,
  "paymentHash": "a1b2c3d4...",
  "caveats": ["expiry=1737000000"],
  "error": null
}
json

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
}
json

Response:

{
  "authorization": "L402 AgELbG4uYm90...:e5f6a7b8...",
  "paymentHash": "a1b2c3d4...",
  "preimage": "e5f6a7b8...",
  "amount": 100,
  "fee": 0,
  "paymentNumber": 5,
  "status": "settled"
}
json

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 ..."
}
json

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 ..."
}
json

Response:

{
  "walletId": "wal_c7i0hnuoxxupuwej",
  "name": "x4k7m2",
  "primaryKey": "uk_...",
  "secondaryKey": "uk_..."
}
json

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