Skip to content

Commit 181bafb

Browse files
committed
Add L402 paywall support, remove keys.list(), add by-hash lookups
- Add L402Resource (sync) and AsyncL402Resource with create_challenge(), verify(), pay() methods - Add L402 types: L402ChallengeResponse, VerifyL402Response, L402PayResponse - Remove keys.list() from both sync and async clients - Remove ApiKeyResponse type - Support get/watch by payment hash (int | str parameter) - Bump version to 0.5.0 Made-with: Cursor
1 parent 42e2881 commit 181bafb

File tree

5 files changed

+139
-44
lines changed

5 files changed

+139
-44
lines changed

README.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,24 @@ The API key can also be provided via the `LNBOT_API_KEY` environment variable. I
131131

132132
---
133133

134+
## L402 paywalls
135+
136+
Monetize APIs with Lightning-native authentication:
137+
138+
```python
139+
# Create a challenge (server side)
140+
challenge = ln.l402.create_challenge(amount=100, description="API access", expiry_seconds=3600)
141+
142+
# Pay the challenge (client side)
143+
result = ln.l402.pay(www_authenticate=challenge.www_authenticate)
144+
145+
# Verify a token (server side, stateless)
146+
v = ln.l402.verify(authorization=result.authorization)
147+
print(v.valid)
148+
```
149+
150+
---
151+
134152
## API reference
135153

136154
### Wallets
@@ -185,9 +203,16 @@ The API key can also be provided via the `LNBOT_API_KEY` environment variable. I
185203

186204
| Method | Description |
187205
| --- | --- |
188-
| `ln.keys.list()` | List API keys (metadata only) |
189206
| `ln.keys.rotate(slot)` | Rotate a key (0 = primary, 1 = secondary) |
190207

208+
### L402
209+
210+
| Method | Description |
211+
| --- | --- |
212+
| `ln.l402.create_challenge(amount=, ...)` | Create an L402 challenge (invoice + macaroon) |
213+
| `ln.l402.verify(authorization=)` | Verify an L402 token (stateless) |
214+
| `ln.l402.pay(www_authenticate=, ...)` | Pay an L402 challenge, get Authorization header |
215+
191216
### Backup & Restore
192217

193218
| Method | Description |

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "lnbot"
7-
version = "0.4.0"
7+
version = "0.5.0"
88
description = "Official Python SDK for LnBot — Bitcoin for AI Agents. Send and receive sats over Lightning with a few lines of code."
99
readme = "README.md"
1010
license = "MIT"

src/lnbot/__init__.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,16 +17,19 @@
1717
from .types import (
1818
AddressInvoiceResponse,
1919
AddressResponse,
20-
ApiKeyResponse,
2120
BackupPasskeyBeginResponse,
2221
CreateWalletResponse,
2322
CreateWebhookResponse,
2423
InvoiceEvent,
2524
InvoiceResponse,
2625
InvoiceStatus,
26+
L402ChallengeResponse,
27+
L402PayResponse,
28+
L402PaymentStatus,
2729
PaymentEvent,
2830
PaymentResponse,
2931
PaymentStatus,
32+
VerifyL402Response,
3033
WalletEvent,
3134
WalletEventType,
3235
RecoveryBackupResponse,
@@ -53,7 +56,6 @@
5356
"ConflictError",
5457
"WalletResponse",
5558
"CreateWalletResponse",
56-
"ApiKeyResponse",
5759
"RotateApiKeyResponse",
5860
"InvoiceResponse",
5961
"InvoiceStatus",
@@ -75,4 +77,8 @@
7577
"BackupPasskeyBeginResponse",
7678
"RestorePasskeyBeginResponse",
7779
"RestorePasskeyCompleteResponse",
80+
"L402ChallengeResponse",
81+
"VerifyL402Response",
82+
"L402PayResponse",
83+
"L402PaymentStatus",
7884
]

src/lnbot/client.py

Lines changed: 69 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,16 @@
2020
from .types import (
2121
AddressInvoiceResponse,
2222
AddressResponse,
23-
ApiKeyResponse,
2423
BackupPasskeyBeginResponse,
2524
CreateWalletResponse,
2625
CreateWebhookResponse,
2726
InvoiceEvent,
2827
InvoiceResponse,
28+
L402ChallengeResponse,
29+
L402PayResponse,
2930
PaymentEvent,
3031
PaymentResponse,
32+
VerifyL402Response,
3133
WalletEvent,
3234
RecoveryBackupResponse,
3335
RecoveryRestoreResponse,
@@ -108,15 +110,11 @@ def update(self, *, name: str) -> WalletResponse:
108110

109111

110112
class KeysResource:
111-
"""API key listing and rotation."""
113+
"""API key rotation."""
112114

113115
def __init__(self, client: LnBot) -> None:
114116
self._c = client
115117

116-
def list(self) -> list[ApiKeyResponse]:
117-
"""List API keys (metadata only, keys are not returned)."""
118-
return [parse(ApiKeyResponse, item) for item in self._c._get("/v1/keys")]
119-
120118
def rotate(self, slot: int) -> RotateApiKeyResponse:
121119
"""Rotate an API key. *slot* 0 = primary, 1 = secondary."""
122120
return parse(RotateApiKeyResponse, self._c._post(f"/v1/keys/{slot}/rotate"))
@@ -136,9 +134,9 @@ def list(self, *, limit: int | None = None, after: int | None = None) -> list[In
136134
"""List invoices, optionally paginated."""
137135
return [parse(InvoiceResponse, item) for item in self._c._get("/v1/invoices", params=_qs({"limit": limit, "after": after}))]
138136

139-
def get(self, number: int) -> InvoiceResponse:
140-
"""Get a single invoice by its number."""
141-
return parse(InvoiceResponse, self._c._get(f"/v1/invoices/{number}"))
137+
def get(self, number_or_hash: int | str) -> InvoiceResponse:
138+
"""Get a single invoice by its number or payment hash."""
139+
return parse(InvoiceResponse, self._c._get(f"/v1/invoices/{number_or_hash}"))
142140

143141
def create_for_wallet(self, *, wallet_id: str, amount: int, reference: str | None = None, comment: str | None = None) -> AddressInvoiceResponse:
144142
"""Create an invoice for a specific wallet by ID. No authentication required."""
@@ -150,13 +148,13 @@ def create_for_address(self, *, address: str, amount: int, tag: str | None = Non
150148
body = to_camel({"address": address, "amount": amount, "tag": tag, "comment": comment})
151149
return parse(AddressInvoiceResponse, self._c._post("/v1/invoices/for-address", body))
152150

153-
def watch(self, number: int, *, timeout: int | None = None) -> Iterator[InvoiceEvent]:
151+
def watch(self, number_or_hash: int | str, *, timeout: int | None = None) -> Iterator[InvoiceEvent]:
154152
"""Stream SSE events until the invoice is settled or expires."""
155153
params = _qs({"timeout": timeout})
156154
headers = {"Accept": "text/event-stream", "User-Agent": _USER_AGENT}
157155
if self._c._api_key:
158156
headers["Authorization"] = f"Bearer {self._c._api_key}"
159-
with self._c._http.stream("GET", f"{self._c._base_url}/v1/invoices/{number}/events", params=params, headers=headers) as resp:
157+
with self._c._http.stream("GET", f"{self._c._base_url}/v1/invoices/{number_or_hash}/events", params=params, headers=headers) as resp:
160158
_raise_for_status(resp)
161159
event_type = ""
162160
for line in resp.iter_lines():
@@ -188,17 +186,17 @@ def list(self, *, limit: int | None = None, after: int | None = None) -> list[Pa
188186
"""List payments, optionally paginated."""
189187
return [parse(PaymentResponse, item) for item in self._c._get("/v1/payments", params=_qs({"limit": limit, "after": after}))]
190188

191-
def get(self, number: int) -> PaymentResponse:
192-
"""Get a single payment by its number."""
193-
return parse(PaymentResponse, self._c._get(f"/v1/payments/{number}"))
189+
def get(self, number_or_hash: int | str) -> PaymentResponse:
190+
"""Get a single payment by its number or payment hash."""
191+
return parse(PaymentResponse, self._c._get(f"/v1/payments/{number_or_hash}"))
194192

195-
def watch(self, number: int, *, timeout: int | None = None) -> Iterator[PaymentEvent]:
193+
def watch(self, number_or_hash: int | str, *, timeout: int | None = None) -> Iterator[PaymentEvent]:
196194
"""Stream SSE events until the payment settles or fails."""
197195
params = _qs({"timeout": timeout})
198196
headers = {"Accept": "text/event-stream", "User-Agent": _USER_AGENT}
199197
if self._c._api_key:
200198
headers["Authorization"] = f"Bearer {self._c._api_key}"
201-
with self._c._http.stream("GET", f"{self._c._base_url}/v1/payments/{number}/events", params=params, headers=headers) as resp:
199+
with self._c._http.stream("GET", f"{self._c._base_url}/v1/payments/{number_or_hash}/events", params=params, headers=headers) as resp:
202200
_raise_for_status(resp)
203201
event_type = ""
204202
for line in resp.iter_lines():
@@ -336,6 +334,27 @@ def passkey_complete(self, *, session_id: str, assertion: dict[str, Any]) -> Res
336334
return parse(RestorePasskeyCompleteResponse, self._c._post("/v1/restore/passkey/complete", to_camel({"session_id": session_id, "assertion": assertion})))
337335

338336

337+
class L402Resource:
338+
"""L402 paywall authentication."""
339+
340+
def __init__(self, client: LnBot) -> None:
341+
self._c = client
342+
343+
def create_challenge(self, *, amount: int, description: str | None = None, expiry_seconds: int | None = None, caveats: list[str] | None = None) -> L402ChallengeResponse:
344+
"""Create an L402 challenge (invoice + macaroon) for paywall authentication."""
345+
body = to_camel({"amount": amount, "description": description, "expiry_seconds": expiry_seconds, "caveats": caveats})
346+
return parse(L402ChallengeResponse, self._c._post("/v1/l402/challenges", body))
347+
348+
def verify(self, *, authorization: str) -> VerifyL402Response:
349+
"""Verify an L402 authorization token (stateless)."""
350+
return parse(VerifyL402Response, self._c._post("/v1/l402/verify", {"authorization": authorization}))
351+
352+
def pay(self, *, www_authenticate: str, max_fee: int | None = None, reference: str | None = None, wait: bool | None = None, timeout: int | None = None) -> L402PayResponse:
353+
"""Pay an L402 challenge and get a ready-to-use Authorization header."""
354+
body = to_camel({"www_authenticate": www_authenticate, "max_fee": max_fee, "reference": reference, "wait": wait, "timeout": timeout})
355+
return parse(L402PayResponse, self._c._post("/v1/l402/pay", body))
356+
357+
339358
# ---------------------------------------------------------------------------
340359
# Sync client
341360
# ---------------------------------------------------------------------------
@@ -369,6 +388,7 @@ def __init__(
369388
self.events = EventsResource(self)
370389
self.backup = BackupResource(self)
371390
self.restore = RestoreResource(self)
391+
self.l402 = L402Resource(self)
372392

373393
def _get(self, path: str, *, params: dict[str, Any] | None = None) -> Any:
374394
resp = self._http.get(f"{self._base_url}{path}", headers=_headers(self._api_key), params=params)
@@ -429,15 +449,11 @@ async def update(self, *, name: str) -> WalletResponse:
429449

430450

431451
class AsyncKeysResource:
432-
"""API key listing and rotation (async)."""
452+
"""API key rotation (async)."""
433453

434454
def __init__(self, client: AsyncLnBot) -> None:
435455
self._c = client
436456

437-
async def list(self) -> list[ApiKeyResponse]:
438-
"""List API keys (metadata only, keys are not returned)."""
439-
return [parse(ApiKeyResponse, item) for item in await self._c._get("/v1/keys")]
440-
441457
async def rotate(self, slot: int) -> RotateApiKeyResponse:
442458
"""Rotate an API key. *slot* 0 = primary, 1 = secondary."""
443459
return parse(RotateApiKeyResponse, await self._c._post(f"/v1/keys/{slot}/rotate"))
@@ -457,9 +473,9 @@ async def list(self, *, limit: int | None = None, after: int | None = None) -> l
457473
"""List invoices, optionally paginated."""
458474
return [parse(InvoiceResponse, item) for item in await self._c._get("/v1/invoices", params=_qs({"limit": limit, "after": after}))]
459475

460-
async def get(self, number: int) -> InvoiceResponse:
461-
"""Get a single invoice by its number."""
462-
return parse(InvoiceResponse, await self._c._get(f"/v1/invoices/{number}"))
476+
async def get(self, number_or_hash: int | str) -> InvoiceResponse:
477+
"""Get a single invoice by its number or payment hash."""
478+
return parse(InvoiceResponse, await self._c._get(f"/v1/invoices/{number_or_hash}"))
463479

464480
async def create_for_wallet(self, *, wallet_id: str, amount: int, reference: str | None = None, comment: str | None = None) -> AddressInvoiceResponse:
465481
"""Create an invoice for a specific wallet by ID. No authentication required."""
@@ -471,13 +487,13 @@ async def create_for_address(self, *, address: str, amount: int, tag: str | None
471487
body = to_camel({"address": address, "amount": amount, "tag": tag, "comment": comment})
472488
return parse(AddressInvoiceResponse, await self._c._post("/v1/invoices/for-address", body))
473489

474-
async def watch(self, number: int, *, timeout: int | None = None) -> AsyncIterator[InvoiceEvent]:
490+
async def watch(self, number_or_hash: int | str, *, timeout: int | None = None) -> AsyncIterator[InvoiceEvent]:
475491
"""Stream SSE events until the invoice is settled or expires."""
476492
params = _qs({"timeout": timeout})
477493
headers = {"Accept": "text/event-stream", "User-Agent": _USER_AGENT}
478494
if self._c._api_key:
479495
headers["Authorization"] = f"Bearer {self._c._api_key}"
480-
async with self._c._http.stream("GET", f"{self._c._base_url}/v1/invoices/{number}/events", params=params, headers=headers) as resp:
496+
async with self._c._http.stream("GET", f"{self._c._base_url}/v1/invoices/{number_or_hash}/events", params=params, headers=headers) as resp:
481497
_raise_for_status(resp)
482498
event_type = ""
483499
async for line in resp.aiter_lines():
@@ -509,17 +525,17 @@ async def list(self, *, limit: int | None = None, after: int | None = None) -> l
509525
"""List payments, optionally paginated."""
510526
return [parse(PaymentResponse, item) for item in await self._c._get("/v1/payments", params=_qs({"limit": limit, "after": after}))]
511527

512-
async def get(self, number: int) -> PaymentResponse:
513-
"""Get a single payment by its number."""
514-
return parse(PaymentResponse, await self._c._get(f"/v1/payments/{number}"))
528+
async def get(self, number_or_hash: int | str) -> PaymentResponse:
529+
"""Get a single payment by its number or payment hash."""
530+
return parse(PaymentResponse, await self._c._get(f"/v1/payments/{number_or_hash}"))
515531

516-
async def watch(self, number: int, *, timeout: int | None = None) -> AsyncIterator[PaymentEvent]:
532+
async def watch(self, number_or_hash: int | str, *, timeout: int | None = None) -> AsyncIterator[PaymentEvent]:
517533
"""Stream SSE events until the payment settles or fails."""
518534
params = _qs({"timeout": timeout})
519535
headers = {"Accept": "text/event-stream", "User-Agent": _USER_AGENT}
520536
if self._c._api_key:
521537
headers["Authorization"] = f"Bearer {self._c._api_key}"
522-
async with self._c._http.stream("GET", f"{self._c._base_url}/v1/payments/{number}/events", params=params, headers=headers) as resp:
538+
async with self._c._http.stream("GET", f"{self._c._base_url}/v1/payments/{number_or_hash}/events", params=params, headers=headers) as resp:
523539
_raise_for_status(resp)
524540
event_type = ""
525541
async for line in resp.aiter_lines():
@@ -657,6 +673,27 @@ async def passkey_complete(self, *, session_id: str, assertion: dict[str, Any])
657673
return parse(RestorePasskeyCompleteResponse, await self._c._post("/v1/restore/passkey/complete", to_camel({"session_id": session_id, "assertion": assertion})))
658674

659675

676+
class AsyncL402Resource:
677+
"""L402 paywall authentication (async)."""
678+
679+
def __init__(self, client: AsyncLnBot) -> None:
680+
self._c = client
681+
682+
async def create_challenge(self, *, amount: int, description: str | None = None, expiry_seconds: int | None = None, caveats: list[str] | None = None) -> L402ChallengeResponse:
683+
"""Create an L402 challenge (invoice + macaroon) for paywall authentication."""
684+
body = to_camel({"amount": amount, "description": description, "expiry_seconds": expiry_seconds, "caveats": caveats})
685+
return parse(L402ChallengeResponse, await self._c._post("/v1/l402/challenges", body))
686+
687+
async def verify(self, *, authorization: str) -> VerifyL402Response:
688+
"""Verify an L402 authorization token (stateless)."""
689+
return parse(VerifyL402Response, await self._c._post("/v1/l402/verify", {"authorization": authorization}))
690+
691+
async def pay(self, *, www_authenticate: str, max_fee: int | None = None, reference: str | None = None, wait: bool | None = None, timeout: int | None = None) -> L402PayResponse:
692+
"""Pay an L402 challenge and get a ready-to-use Authorization header."""
693+
body = to_camel({"www_authenticate": www_authenticate, "max_fee": max_fee, "reference": reference, "wait": wait, "timeout": timeout})
694+
return parse(L402PayResponse, await self._c._post("/v1/l402/pay", body))
695+
696+
660697
# ---------------------------------------------------------------------------
661698
# Async client
662699
# ---------------------------------------------------------------------------
@@ -690,6 +727,7 @@ def __init__(
690727
self.events = AsyncEventsResource(self)
691728
self.backup = AsyncBackupResource(self)
692729
self.restore = AsyncRestoreResource(self)
730+
self.l402 = AsyncL402Resource(self)
693731

694732
async def _get(self, path: str, *, params: dict[str, Any] | None = None) -> Any:
695733
resp = await self._http.get(f"{self._base_url}{path}", headers=_headers(self._api_key), params=params)

src/lnbot/types.py

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,15 +38,6 @@ class CreateWalletResponse:
3838
# API Keys
3939
# ---------------------------------------------------------------------------
4040

41-
@dataclass(frozen=True)
42-
class ApiKeyResponse:
43-
id: str
44-
name: str
45-
hint: str
46-
created_at: str | None = None
47-
last_used_at: str | None = None
48-
49-
5041
@dataclass(frozen=True)
5142
class RotateApiKeyResponse:
5243
key: str
@@ -198,6 +189,41 @@ class RestorePasskeyCompleteResponse:
198189
secondary_key: str
199190

200191

192+
# ---------------------------------------------------------------------------
193+
# L402
194+
# ---------------------------------------------------------------------------
195+
196+
L402PaymentStatus = Literal["pending", "processing", "settled", "failed"]
197+
198+
199+
@dataclass(frozen=True)
200+
class L402ChallengeResponse:
201+
macaroon: str
202+
invoice: str
203+
payment_hash: str
204+
expires_at: str
205+
www_authenticate: str
206+
207+
208+
@dataclass(frozen=True)
209+
class VerifyL402Response:
210+
valid: bool
211+
payment_hash: str | None = None
212+
caveats: list[str] | None = None
213+
error: str | None = None
214+
215+
216+
@dataclass(frozen=True)
217+
class L402PayResponse:
218+
payment_hash: str
219+
amount: int
220+
payment_number: int
221+
status: L402PaymentStatus
222+
authorization: str | None = None
223+
preimage: str | None = None
224+
fee: int | None = None
225+
226+
201227
# ---------------------------------------------------------------------------
202228
# SSE
203229
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)