Skip to content

Commit 469b670

Browse files
authored
Merge pull request #4 from Fewsats/verify-webhok
Verify webhook
2 parents 4822d8a + f6c8531 commit 469b670

File tree

5 files changed

+188
-13
lines changed

5 files changed

+188
-13
lines changed

fewsats/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "0.0.19"
1+
__version__ = "0.0.20"

fewsats/_modidx.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
'fewsats.core.Fewsats.pay_offer_str': ('core.html#fewsats.pay_offer_str', 'fewsats/core.py'),
2424
'fewsats.core.Fewsats.payment_info': ('core.html#fewsats.payment_info', 'fewsats/core.py'),
2525
'fewsats.core.Fewsats.payment_methods': ('core.html#fewsats.payment_methods', 'fewsats/core.py'),
26+
'fewsats.core.Fewsats.verify_webhook': ('core.html#fewsats.verify_webhook', 'fewsats/core.py'),
27+
'fewsats.core.FewsatsWebhookEvent': ('core.html#fewsatswebhookevent', 'fewsats/core.py'),
2628
'fewsats.core.L402Offers': ('core.html#l402offers', 'fewsats/core.py'),
2729
'fewsats.core.L402Offers.__init__': ('core.html#l402offers.__init__', 'fewsats/core.py'),
2830
'fewsats.core.L402Offers.__repr__': ('core.html#l402offers.__repr__', 'fewsats/core.py'),

fewsats/core.py

Lines changed: 72 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,27 @@
33
# AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/00_core.ipynb.
44

55
# %% auto 0
6-
__all__ = ['Fewsats', 'Offer', 'L402Offers']
6+
__all__ = ['Fewsats', 'FewsatsWebhookEvent', 'Offer', 'L402Offers']
77

88
# %% ../nbs/00_core.ipynb 3
99
from fastcore.utils import *
1010
import os
11+
import hashlib
12+
import hmac
1113
import httpx
12-
from typing import Dict, Any, List
14+
import time
1315
import json
16+
from dataclasses import dataclass
1417
from fastcore.basics import BasicRepr
1518
from fastcore.utils import store_attr
1619
from typing import List, Dict, Any
1720

1821
# %% ../nbs/00_core.ipynb 8
1922
class Fewsats:
23+
24+
WEBHOOK_VERSION = "v1"
25+
WEBHOOK_SIGNATURE_HEADER = "Fewsats-Signature"
26+
2027
"Client for interacting with the Fewsats API"
2128
def __init__(self,
2229
api_key: str = None, # The API key for the Fewsats account
@@ -114,6 +121,63 @@ def add_webhook(self:Fewsats,
114121
return self._request("POST", f"v0/users/webhook/add", json={"webhook_url": webhook_url})
115122

116123
# %% ../nbs/00_core.ipynb 39
124+
@dataclass
125+
class FewsatsWebhookEvent:
126+
offer_id: str
127+
payment_context_token: str
128+
amount: int
129+
currency: str
130+
status: str
131+
timestamp: str
132+
133+
@patch(cls_method=True)
134+
def verify_webhook(cls:Fewsats,
135+
data: bytes,
136+
signature: str,
137+
webhook_secret: str,
138+
) -> dict:
139+
"""
140+
Verify and parse a webhook that comes from Fewsats
141+
Args:
142+
data: bytes
143+
signature: str
144+
webhook_secret: str
145+
Returns:
146+
FewsatsWebhookEvent
147+
"""
148+
if not isinstance(data, bytes):
149+
raise TypeError(f"'data' should be bytes, got {type(data)}")
150+
151+
timestamp_str, signature_str = signature.split(",")
152+
timestamp = timestamp_str.split("=")[1]
153+
signature_version, signature = signature_str.split("=")
154+
155+
payload_str = data.decode("utf-8")
156+
157+
if signature_version != cls.WEBHOOK_VERSION:
158+
raise ValueError("Unsupported signature version")
159+
160+
if timestamp.isdigit():
161+
timestamp = int(timestamp)
162+
else:
163+
raise ValueError("Invalid timestamp")
164+
165+
if timestamp - time.time() > 300:
166+
raise ValueError("Timestamp is older than 5 minutes")
167+
168+
signed_payload = f"{timestamp}.{payload_str}"
169+
170+
# Generate the signature
171+
expected_signature = hmac.new(webhook_secret.encode(), signed_payload.encode(), hashlib.sha256).hexdigest()
172+
173+
if signature.lower() != expected_signature.lower():
174+
raise ValueError("Webhook message hash does not match signature")
175+
176+
event = json.loads(payload_str)
177+
return FewsatsWebhookEvent(**event)
178+
179+
180+
# %% ../nbs/00_core.ipynb 42
117181
@patch
118182
def pay_lightning(self: Fewsats,
119183
invoice: str, # lightning invoice
@@ -129,7 +193,7 @@ def pay_lightning(self: Fewsats,
129193
}
130194
return self._request("POST", "v0/l402/purchases/lightning", json=data)
131195

132-
# %% ../nbs/00_core.ipynb 42
196+
# %% ../nbs/00_core.ipynb 45
133197
class Offer(BasicRepr):
134198
"Represents a single L402 offer"
135199
def __init__(self,
@@ -184,7 +248,7 @@ def from_dict(cls, d: Dict[str, Any]) -> 'L402Offers':
184248
)
185249

186250

187-
# %% ../nbs/00_core.ipynb 46
251+
# %% ../nbs/00_core.ipynb 49
188252
@patch
189253
def pay_offer(self:Fewsats,
190254
offer_id : str, # the offer id to pay for
@@ -213,7 +277,7 @@ def pay_offer(self:Fewsats,
213277
return self._request("POST", "v0/l402/purchases/from-offer", json=data)
214278

215279

216-
# %% ../nbs/00_core.ipynb 49
280+
# %% ../nbs/00_core.ipynb 52
217281
@patch
218282
def pay_offer_str(self:Fewsats,
219283
offer_id : str, # the offer id to pay for
@@ -250,7 +314,7 @@ def pay_offer_str(self:Fewsats,
250314

251315
return self._request("POST", "v0/l402/purchases/from-offer", timeout=20, json=data)
252316

253-
# %% ../nbs/00_core.ipynb 52
317+
# %% ../nbs/00_core.ipynb 55
254318
@patch
255319
def pay_link(self:Fewsats,
256320
url: str, # URL to purchase from
@@ -280,14 +344,14 @@ def pay_link(self:Fewsats,
280344
}
281345
return self._request("POST", "v0/l402/purchases/from-link", json=data)
282346

283-
# %% ../nbs/00_core.ipynb 55
347+
# %% ../nbs/00_core.ipynb 58
284348
@patch
285349
def payment_info(self:Fewsats,
286350
pid:str): # purchase id
287351
"Retrieve the details of a payment."
288352
return self._request("GET", f"v0/l402/outgoing-payments/{pid}")
289353

290-
# %% ../nbs/00_core.ipynb 58
354+
# %% ../nbs/00_core.ipynb 61
291355
@patch
292356
def as_tools(self:Fewsats):
293357
"Return list of available tools for AI agents"

nbs/00_core.ipynb

Lines changed: 112 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,12 @@
3434
"#| export\n",
3535
"from fastcore.utils import *\n",
3636
"import os\n",
37+
"import hashlib\n",
38+
"import hmac\n",
3739
"import httpx\n",
38-
"from typing import Dict, Any, List\n",
40+
"import time\n",
3941
"import json\n",
42+
"from dataclasses import dataclass\n",
4043
"from fastcore.basics import BasicRepr\n",
4144
"from fastcore.utils import store_attr\n",
4245
"from typing import List, Dict, Any"
@@ -94,6 +97,10 @@
9497
"source": [
9598
"#| export\n",
9699
"class Fewsats:\n",
100+
"\n",
101+
" WEBHOOK_VERSION = \"v1\"\n",
102+
" WEBHOOK_SIGNATURE_HEADER = \"Fewsats-Signature\"\n",
103+
"\n",
97104
" \"Client for interacting with the Fewsats API\"\n",
98105
" def __init__(self,\n",
99106
" api_key: str = None, # The API key for the Fewsats account\n",
@@ -224,7 +231,7 @@
224231
" 'phone': None})"
225232
]
226233
},
227-
"execution_count": 47,
234+
"execution_count": null,
228235
"metadata": {},
229236
"output_type": "execute_result"
230237
}
@@ -552,7 +559,7 @@
552559
"cell_type": "markdown",
553560
"metadata": {},
554561
"source": [
555-
"## Set webhook\n",
562+
"## Webhooks\n",
556563
"\n",
557564
"Use this as a vendor to get notified when someone pays for your offer. Currently only 1 webhook is supported per user."
558565
]
@@ -596,6 +603,108 @@
596603
"r, r.json()"
597604
]
598605
},
606+
{
607+
"cell_type": "markdown",
608+
"metadata": {},
609+
"source": [
610+
"To verify and parse a webhook that comes from Fewsats you can use this utility"
611+
]
612+
},
613+
{
614+
"cell_type": "code",
615+
"execution_count": null,
616+
"metadata": {},
617+
"outputs": [],
618+
"source": [
619+
"#| export\n",
620+
"\n",
621+
"@dataclass\n",
622+
"class FewsatsWebhookEvent:\n",
623+
" offer_id: str\n",
624+
" payment_context_token: str\n",
625+
" amount: int\n",
626+
" currency: str\n",
627+
" status: str\n",
628+
" timestamp: str\n",
629+
"\n",
630+
"@patch(cls_method=True)\n",
631+
"def verify_webhook(cls:Fewsats,\n",
632+
" data: bytes,\n",
633+
" signature: str,\n",
634+
" webhook_secret: str,\n",
635+
" ) -> dict:\n",
636+
" \"\"\"\n",
637+
" Verify and parse a webhook that comes from Fewsats\n",
638+
" Args:\n",
639+
" data: bytes\n",
640+
" signature: str\n",
641+
" webhook_secret: str\n",
642+
" Returns:\n",
643+
" FewsatsWebhookEvent\n",
644+
" \"\"\"\n",
645+
" if not isinstance(data, bytes):\n",
646+
" raise TypeError(f\"'data' should be bytes, got {type(data)}\")\n",
647+
"\n",
648+
" timestamp_str, signature_str = signature.split(\",\")\n",
649+
" timestamp = timestamp_str.split(\"=\")[1]\n",
650+
" signature_version, signature = signature_str.split(\"=\")\n",
651+
"\n",
652+
" payload_str = data.decode(\"utf-8\")\n",
653+
"\n",
654+
" if signature_version != cls.WEBHOOK_VERSION:\n",
655+
" raise ValueError(\"Unsupported signature version\")\n",
656+
"\n",
657+
" if timestamp.isdigit():\n",
658+
" timestamp = int(timestamp)\n",
659+
" else:\n",
660+
" raise ValueError(\"Invalid timestamp\")\n",
661+
"\n",
662+
" if timestamp - time.time() > 300:\n",
663+
" raise ValueError(\"Timestamp is older than 5 minutes\")\n",
664+
"\n",
665+
" signed_payload = f\"{timestamp}.{payload_str}\"\n",
666+
"\n",
667+
" # Generate the signature\n",
668+
" expected_signature = hmac.new(webhook_secret.encode(), signed_payload.encode(), hashlib.sha256).hexdigest()\n",
669+
"\n",
670+
" if signature.lower() != expected_signature.lower():\n",
671+
" raise ValueError(\"Webhook message hash does not match signature\")\n",
672+
"\n",
673+
" event = json.loads(payload_str)\n",
674+
" return FewsatsWebhookEvent(**event)\n"
675+
]
676+
},
677+
{
678+
"cell_type": "code",
679+
"execution_count": null,
680+
"metadata": {},
681+
"outputs": [
682+
{
683+
"data": {
684+
"text/plain": [
685+
"FewsatsWebhookEvent(offer_id='offer-501040', payment_context_token='a0b1caf3-3e3b-488f-ae9c-18d64f690894', amount=812319, currency='USD', status='failed', timestamp='2025-04-16T08:51:12Z')"
686+
]
687+
},
688+
"execution_count": null,
689+
"metadata": {},
690+
"output_type": "execute_result"
691+
}
692+
],
693+
"source": [
694+
"data = bytes(json.dumps({\n",
695+
" \"offer_id\": \"offer-501040\",\n",
696+
" \"payment_context_token\": \"a0b1caf3-3e3b-488f-ae9c-18d64f690894\",\n",
697+
" \"amount\": 812319,\n",
698+
" \"currency\": \"USD\",\n",
699+
" \"status\": \"failed\",\n",
700+
" \"timestamp\": \"2025-04-16T08:51:12Z\"\n",
701+
"}, sort_keys=True, separators=(',', ':')), \"utf-8\")\n",
702+
"signature = \"t=1744793472,v1=89a491b8f3f8e72b75896faa24cb1cfade27bea12bbdfe333759809b8a573ad3\"\n",
703+
"\n",
704+
"event = Fewsats.verify_webhook(data, signature, \"whsec_bIi4m3by9sJ_KNfY5PFWb2YmAqm2WVAvwq5wGuphayE\")\n",
705+
"event"
706+
]
707+
},
599708
{
600709
"cell_type": "markdown",
601710
"metadata": {},

settings.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
[DEFAULT]
22
repo = fewsats-python
33
lib_name = fewsats
4-
version = 0.0.19
4+
version = 0.0.20
55
min_python = 3.7
66
license = apache2
77
black_formatting = False

0 commit comments

Comments
 (0)