paystack-webhook 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- paystack_webhook-0.1.1.dist-info/METADATA +189 -0
- paystack_webhook-0.1.1.dist-info/RECORD +15 -0
- paystack_webhook-0.1.1.dist-info/WHEEL +5 -0
- paystack_webhook-0.1.1.dist-info/licenses/LICENSE +21 -0
- paystack_webhook-0.1.1.dist-info/top_level.txt +1 -0
- paystack_webhooks/__init__.py +28 -0
- paystack_webhooks/contrib/__init__.py +1 -0
- paystack_webhooks/contrib/fastapi.py +46 -0
- paystack_webhooks/contrib/flask.py +38 -0
- paystack_webhooks/errors.py +39 -0
- paystack_webhooks/event.py +96 -0
- paystack_webhooks/flutterwave.py +71 -0
- paystack_webhooks/idempotency.py +66 -0
- paystack_webhooks/paystack.py +72 -0
- paystack_webhooks/signature.py +28 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: paystack-webhook
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Verify Paystack and Flutterwave webhooks in Python: HMAC-SHA512, idempotency, replay-safe retries. Zero dependencies.
|
|
5
|
+
Author: mgkvibez
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/mgkvibez/paystack-webhooks
|
|
8
|
+
Project-URL: Issues, https://github.com/mgkvibez/paystack-webhooks/issues
|
|
9
|
+
Keywords: paystack,flutterwave,webhooks,payments,africa,nigeria,hmac,fastapi
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Framework :: FastAPI
|
|
14
|
+
Classifier: Framework :: Flask
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
License-File: LICENSE
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
23
|
+
Requires-Dist: fastapi>=0.110; extra == "dev"
|
|
24
|
+
Requires-Dist: httpx>=0.27; extra == "dev"
|
|
25
|
+
Requires-Dist: flask>=3.0; extra == "dev"
|
|
26
|
+
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
27
|
+
Requires-Dist: build>=1.0; extra == "dev"
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# paystack-webhooks
|
|
31
|
+
|
|
32
|
+
**Verify Paystack and Flutterwave webhooks in Python, including the part the docs skip: retries.**
|
|
33
|
+
|
|
34
|
+
[](https://github.com/mgkvibez/paystack-webhooks/actions/workflows/ci.yml)
|
|
35
|
+
[](LICENSE)
|
|
36
|
+
[](pyproject.toml)
|
|
37
|
+
[](pyproject.toml)
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from paystack_webhooks import PaystackWebhook
|
|
41
|
+
|
|
42
|
+
paystack = PaystackWebhook("sk_live_...")
|
|
43
|
+
|
|
44
|
+
event = paystack.process(raw_body, headers) # None means you already handled it
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
That is the whole library. The rest of this page is why `process` exists and the
|
|
48
|
+
four-line snippet on Stack Overflow is not enough.
|
|
49
|
+
|
|
50
|
+
## The snippet everyone copies, and what it misses
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
if hmac.new(key, request.body, hashlib.sha512).hexdigest() != request.headers["x-paystack-signature"]:
|
|
54
|
+
abort(401)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
The signature check is right. Two other things are missing, and both cost money.
|
|
58
|
+
|
|
59
|
+
**1. Retries.** Paystack and Flutterwave both retry when your endpoint does not return
|
|
60
|
+
a 2xx: a timeout, a 500, a deploy that restarts the worker mid-request. Your handler
|
|
61
|
+
then runs twice for one payment. If the second run grants value again, you have given
|
|
62
|
+
away product, and no amount of signature checking prevents it.
|
|
63
|
+
|
|
64
|
+
**2. Verifying after parsing.** If you call `request.json()` first, the bytes you
|
|
65
|
+
hash are not the bytes that arrived: key order and whitespace change, the digest never
|
|
66
|
+
matches, and the usual "fix" is to drop the check.
|
|
67
|
+
|
|
68
|
+
This library handles the raw bytes, derives a stable event id, and gives you a
|
|
69
|
+
single call that is safe to repeat.
|
|
70
|
+
|
|
71
|
+
## Install
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
pipx install paystack-webhook
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The distribution is called `paystack-webhook`, singular, because PyPI refused
|
|
78
|
+
`paystack-webhooks` as too similar to a project that already exists. Nothing a user
|
|
79
|
+
types changes: the import is `paystack_webhooks` and the repo keeps its name.
|
|
80
|
+
|
|
81
|
+
Python 3.9+, **zero runtime dependencies**. FastAPI and Flask are optional extras that
|
|
82
|
+
only the adapters import.
|
|
83
|
+
|
|
84
|
+
## FastAPI
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from fastapi import FastAPI
|
|
88
|
+
from paystack_webhooks import MemoryStore, PaystackWebhook
|
|
89
|
+
from paystack_webhooks.contrib.fastapi import webhook_route
|
|
90
|
+
|
|
91
|
+
app = FastAPI()
|
|
92
|
+
|
|
93
|
+
def on_event(event):
|
|
94
|
+
if event.name == "charge.success":
|
|
95
|
+
fulfil(event.data["reference"], event.data["amount"])
|
|
96
|
+
|
|
97
|
+
app.include_router(webhook_route(
|
|
98
|
+
PaystackWebhook("sk_live_..."),
|
|
99
|
+
store=MemoryStore(), # dedupe across retries
|
|
100
|
+
on_event=on_event,
|
|
101
|
+
path="/webhooks/paystack",
|
|
102
|
+
))
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Duplicates return **200**, not 409. Both providers retry on non-2xx, so answering
|
|
106
|
+
409 to something you already handled starts a retry storm.
|
|
107
|
+
|
|
108
|
+
## Flask
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
from flask import Flask
|
|
112
|
+
from paystack_webhooks import MemoryStore, PaystackWebhook
|
|
113
|
+
from paystack_webhooks.contrib.flask import webhook_view
|
|
114
|
+
|
|
115
|
+
app = Flask(__name__)
|
|
116
|
+
app.add_url_rule("/webhooks/paystack", methods=["POST"], view_func=webhook_view(
|
|
117
|
+
PaystackWebhook("sk_live_..."), store=MemoryStore(), on_event=on_event))
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Anywhere else, the core is framework-agnostic: pass the raw bytes and a header mapping
|
|
121
|
+
you can read case-insensitively.
|
|
122
|
+
|
|
123
|
+
## Flutterwave, and the weaker guarantee it actually offers
|
|
124
|
+
|
|
125
|
+
```python
|
|
126
|
+
from paystack_webhooks import FlutterwaveWebhook
|
|
127
|
+
|
|
128
|
+
flutterwave = FlutterwaveWebhook("your_verif_hash")
|
|
129
|
+
event = flutterwave.process(raw_body, headers)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Flutterwave does not sign the body. It sends your static secret in a `verif-hash`
|
|
133
|
+
header, which proves the sender knows the hash and nothing else. A valid header can
|
|
134
|
+
accompany a tampered payload if the hash leaks. This library says so instead of
|
|
135
|
+
implying parity with Paystack.
|
|
136
|
+
|
|
137
|
+
## The security model, stated plainly
|
|
138
|
+
|
|
139
|
+
| | Paystack | Flutterwave |
|
|
140
|
+
|---|---|---|
|
|
141
|
+
| What is checked | HMAC-SHA512 over the raw body | static `verif-hash` header |
|
|
142
|
+
| Does it cover the body | yes | **no** |
|
|
143
|
+
| Signed timestamp | none | none |
|
|
144
|
+
| Replay defence | dedupe on `event_id` | dedupe, plus compare amount and currency |
|
|
145
|
+
|
|
146
|
+
Neither provider sends a signed timestamp, so a "5 minute replay window" is theatre:
|
|
147
|
+
there is nothing authentic to compare a clock against. Deduplication is the only
|
|
148
|
+
honest defence available, which is why `event_id` is computed for you and why the store
|
|
149
|
+
is part of the API.
|
|
150
|
+
|
|
151
|
+
For Flutterwave, always compare `event.data["amount"]` and `currency` against your own
|
|
152
|
+
record before fulfilling. The header cannot tell you the amount is right.
|
|
153
|
+
|
|
154
|
+
## What this deliberately does not do
|
|
155
|
+
|
|
156
|
+
- **It is not an API client.** Use `paystackapi` or `rave-python` to charge cards. This
|
|
157
|
+
library only consumes webhooks.
|
|
158
|
+
- **It does not validate amounts for you.** That requires your records, not the payload.
|
|
159
|
+
- **`MemoryStore` is single-process.** It is correct for one worker. On several
|
|
160
|
+
instances implement the four-line `Store` protocol against Redis or a table with a
|
|
161
|
+
unique constraint; a check-then-insert will race and double-charge.
|
|
162
|
+
- **It does not decide your response codes for unknown events.** The adapter returns
|
|
163
|
+
200 and calls your handler; ignore event types you do not care about.
|
|
164
|
+
|
|
165
|
+
## Errors map to status codes
|
|
166
|
+
|
|
167
|
+
| Exception | Meaning | Status |
|
|
168
|
+
|---|---|---|
|
|
169
|
+
| `SignatureError` | wrong secret, forged header, tampered body | 401 |
|
|
170
|
+
| `MalformedPayload` | valid signature, unusable body | 400 |
|
|
171
|
+
| `RawBodyRequired` | you passed text or a dict instead of bytes | 500, and it is a bug in your code |
|
|
172
|
+
|
|
173
|
+
## Tests
|
|
174
|
+
|
|
175
|
+
The suite runs offline: signatures are computed in the tests with `hmac`/`hashlib`
|
|
176
|
+
directly, so a bug in this library cannot make its own tests pass. It covers valid and
|
|
177
|
+
forged signatures, tampered bodies, non-ASCII headers, text bodies, validly signed
|
|
178
|
+
garbage, idempotency including a 50-thread race on one event id, TTL and capacity
|
|
179
|
+
eviction, both adapters, and a check that importing the core pulls in no web framework.
|
|
180
|
+
|
|
181
|
+
```bash
|
|
182
|
+
pip install -e ".[dev]"
|
|
183
|
+
python -m pytest -q
|
|
184
|
+
bash scripts/verify.sh
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
## License
|
|
188
|
+
|
|
189
|
+
MIT.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
paystack_webhook-0.1.1.dist-info/licenses/LICENSE,sha256=e-0Bks6hOWW2m30Xgh8w6CENzPJoV246vIq2lz_9Ing,1065
|
|
2
|
+
paystack_webhooks/__init__.py,sha256=1JnHwmX0HaIC8qesYapY9V2kH81anVVKFNs46tt_-4A,740
|
|
3
|
+
paystack_webhooks/errors.py,sha256=2qo0cRGZzoaphltCFtmyIg7079Y36A2XF_u3HeBCY7g,1501
|
|
4
|
+
paystack_webhooks/event.py,sha256=FPm3HjAtp9MwhqH6c-d71xaH3_3OfezVw04bSdXK2H8,3257
|
|
5
|
+
paystack_webhooks/flutterwave.py,sha256=yOq2Cuw-25F5zQbwqAJgn9imBSz16URvvRWXQ5HDjek,3120
|
|
6
|
+
paystack_webhooks/idempotency.py,sha256=NNquNqtLcq8FWR8y3faKf2Syf0Edsp11my3ccdhatEQ,2318
|
|
7
|
+
paystack_webhooks/paystack.py,sha256=vBV8izLFGhBaJ0zIAqLNC-kSBBw_YIbX9XFmqlbwyeU,3209
|
|
8
|
+
paystack_webhooks/signature.py,sha256=g2ZOO-4VJI4J0SngRp1ARFfwETEPu-G18tenSFaOmM8,1051
|
|
9
|
+
paystack_webhooks/contrib/__init__.py,sha256=8a3TnD92BD2I4ixq4ywotd34Na9Om6tW81-n5sN9N98,73
|
|
10
|
+
paystack_webhooks/contrib/fastapi.py,sha256=tyidtkMRSlQZR-B8c_id8igXfq4v8fJKsjGa9d-9vnA,1703
|
|
11
|
+
paystack_webhooks/contrib/flask.py,sha256=8N2-SsCuPHfdTWYwUeP1zzTcTPSUupbj_tAiBU-jcVM,1283
|
|
12
|
+
paystack_webhook-0.1.1.dist-info/METADATA,sha256=1we0dP3972v_DNC7IRCYOwiD3P-9jQ1SpnUmU49NqUE,7234
|
|
13
|
+
paystack_webhook-0.1.1.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
14
|
+
paystack_webhook-0.1.1.dist-info/top_level.txt,sha256=C5etboHWn3owvC4ZYmZR3fCCRTaTr-hUujmjBGSiQJ4,18
|
|
15
|
+
paystack_webhook-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mgkvibez
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
paystack_webhooks
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Verify Paystack and Flutterwave webhooks without getting it wrong.
|
|
2
|
+
|
|
3
|
+
from paystack_webhooks import PaystackWebhook, MemoryStore
|
|
4
|
+
|
|
5
|
+
paystack = PaystackWebhook("sk_live_...")
|
|
6
|
+
event = paystack.process(raw_body, headers) # None means already handled
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from .errors import MalformedPayload, RawBodyRequired, SignatureError, WebhookError
|
|
10
|
+
from .event import Event
|
|
11
|
+
from .flutterwave import FlutterwaveWebhook
|
|
12
|
+
from .idempotency import MemoryStore, Store
|
|
13
|
+
from .paystack import PaystackWebhook
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.1"
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"Event",
|
|
19
|
+
"FlutterwaveWebhook",
|
|
20
|
+
"MalformedPayload",
|
|
21
|
+
"MemoryStore",
|
|
22
|
+
"PaystackWebhook",
|
|
23
|
+
"RawBodyRequired",
|
|
24
|
+
"SignatureError",
|
|
25
|
+
"Store",
|
|
26
|
+
"WebhookError",
|
|
27
|
+
"__version__",
|
|
28
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Framework adapters. Importing this package pulls in nothing heavy."""
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Mount a verified webhook route in four lines.
|
|
2
|
+
|
|
3
|
+
app.include_router(webhook_route(PaystackWebhook(secret), store=MemoryStore()))
|
|
4
|
+
|
|
5
|
+
Duplicates return 200 on purpose. Paystack retries on any non-2xx, so answering
|
|
6
|
+
409 to a duplicate starts a retry storm for an event you already handled.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import inspect
|
|
10
|
+
from typing import Awaitable, Callable, Optional, Union
|
|
11
|
+
|
|
12
|
+
from fastapi import APIRouter, HTTPException, Request
|
|
13
|
+
|
|
14
|
+
from ..errors import MalformedPayload, RawBodyRequired, SignatureError
|
|
15
|
+
from ..event import Event
|
|
16
|
+
from ..idempotency import Store
|
|
17
|
+
|
|
18
|
+
Handler = Callable[[Event], Union[None, Awaitable[None]]]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def webhook_route(verifier, *, store: Optional[Store] = None,
|
|
22
|
+
on_event: Optional[Handler] = None, path: str = "/webhooks/paystack"):
|
|
23
|
+
router = APIRouter()
|
|
24
|
+
|
|
25
|
+
@router.post(path)
|
|
26
|
+
async def receive(request: Request) -> dict:
|
|
27
|
+
body = await request.body()
|
|
28
|
+
try:
|
|
29
|
+
event = verifier.parse(body, request.headers)
|
|
30
|
+
except RawBodyRequired as exc:
|
|
31
|
+
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
|
32
|
+
except SignatureError as exc:
|
|
33
|
+
raise HTTPException(status_code=401, detail="invalid signature") from exc
|
|
34
|
+
except MalformedPayload as exc:
|
|
35
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
36
|
+
|
|
37
|
+
if store is not None and store.seen(event.event_id):
|
|
38
|
+
return {"status": "duplicate", "event_id": event.event_id}
|
|
39
|
+
|
|
40
|
+
if on_event is not None:
|
|
41
|
+
result = on_event(event)
|
|
42
|
+
if inspect.isawaitable(result):
|
|
43
|
+
await result
|
|
44
|
+
return {"status": "accepted", "event": event.name, "event_id": event.event_id}
|
|
45
|
+
|
|
46
|
+
return router
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Flask view factory for verified webhooks.
|
|
2
|
+
|
|
3
|
+
app.add_url_rule("/webhooks/paystack", view_func=webhook_view(verifier), methods=["POST"])
|
|
4
|
+
|
|
5
|
+
Flask gives you `request.get_data()` for the raw bytes. Do not use `request.json`:
|
|
6
|
+
by then the bytes are gone and the digest cannot match.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
from ..errors import MalformedPayload, RawBodyRequired, SignatureError
|
|
14
|
+
from ..idempotency import Store
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def webhook_view(verifier, *, store: Optional[Store] = None, on_event=None):
|
|
18
|
+
from flask import jsonify, request
|
|
19
|
+
|
|
20
|
+
def view():
|
|
21
|
+
body = request.get_data(cache=True)
|
|
22
|
+
try:
|
|
23
|
+
event = verifier.parse(body, request.headers)
|
|
24
|
+
except RawBodyRequired as exc:
|
|
25
|
+
return jsonify({"error": str(exc)}), 500
|
|
26
|
+
except SignatureError:
|
|
27
|
+
return jsonify({"error": "invalid signature"}), 401
|
|
28
|
+
except MalformedPayload as exc:
|
|
29
|
+
return jsonify({"error": str(exc)}), 400
|
|
30
|
+
|
|
31
|
+
if store is not None and store.seen(event.event_id):
|
|
32
|
+
return jsonify({"status": "duplicate", "event_id": event.event_id}), 200
|
|
33
|
+
|
|
34
|
+
if on_event is not None:
|
|
35
|
+
on_event(event)
|
|
36
|
+
return jsonify({"status": "accepted", "event": event.name}), 200
|
|
37
|
+
|
|
38
|
+
return view
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Typed failures, so a caller can tell a forged request from a broken one.
|
|
2
|
+
|
|
3
|
+
A 401 and a 400 are different problems for whoever is on call: one means someone
|
|
4
|
+
is posting to your endpoint with the wrong secret, the other means the payload is
|
|
5
|
+
not what the provider promised. Collapsing them into one exception makes that
|
|
6
|
+
distinction impossible to recover.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class WebhookError(Exception):
|
|
11
|
+
"""Base class for everything this library raises."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class SignatureError(WebhookError):
|
|
15
|
+
"""The request did not carry a valid signature for the configured secret.
|
|
16
|
+
|
|
17
|
+
Treat this as hostile until proven otherwise. Never retry-and-hope on it, and
|
|
18
|
+
never log the provided header value at info level: a wrong secret is usually a
|
|
19
|
+
deployment mistake, but a valid-looking header with a bad digest is a forgery
|
|
20
|
+
attempt and its contents are attacker-controlled.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class MalformedPayload(WebhookError):
|
|
25
|
+
"""The signature was valid but the body is not a usable webhook event.
|
|
26
|
+
|
|
27
|
+
A valid signature over garbage still means the provider sent garbage, so this
|
|
28
|
+
is a 400, not a 401.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RawBodyRequired(WebhookError, TypeError):
|
|
33
|
+
"""The body arrived as text instead of bytes, so it cannot be verified.
|
|
34
|
+
|
|
35
|
+
Re-serialising parsed JSON changes key order and whitespace, which changes the
|
|
36
|
+
digest. This is the single most common way a correct signature check gets
|
|
37
|
+
written and still fails in production, so it is a named error rather than a
|
|
38
|
+
silent coercion.
|
|
39
|
+
"""
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""One incoming event, verified, with a stable id for deduplication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, Mapping
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _digest(raw: bytes) -> str:
|
|
12
|
+
return hashlib.sha256(raw).hexdigest()[:32]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Event:
|
|
17
|
+
"""A verified webhook delivery.
|
|
18
|
+
|
|
19
|
+
``event_id`` is what you store to make retries harmless. Both providers retry
|
|
20
|
+
on any non-2xx response, and neither sends a signed timestamp, so a delivery
|
|
21
|
+
cannot be proven fresh by cryptography. Deduplicating on this id is the only
|
|
22
|
+
honest replay defence available, which is why it is computed here rather than
|
|
23
|
+
left to the caller.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
provider: str
|
|
27
|
+
name: str
|
|
28
|
+
data: Mapping[str, Any]
|
|
29
|
+
event_id: str
|
|
30
|
+
raw: bytes = field(repr=False, default=b"")
|
|
31
|
+
headers: Mapping[str, str] = field(repr=False, default_factory=dict)
|
|
32
|
+
|
|
33
|
+
def __post_init__(self) -> None:
|
|
34
|
+
if not self.name:
|
|
35
|
+
raise ValueError("an event needs a name")
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> dict:
|
|
38
|
+
return {
|
|
39
|
+
"provider": self.provider,
|
|
40
|
+
"name": self.name,
|
|
41
|
+
"event_id": self.event_id,
|
|
42
|
+
"data": dict(self.data),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def build_event(provider: str, payload: Mapping[str, Any], raw: bytes,
|
|
47
|
+
headers: Mapping[str, str]) -> Event:
|
|
48
|
+
"""Turn a parsed payload into an Event, deriving a dedupe id you can trust.
|
|
49
|
+
|
|
50
|
+
Preference order for the id: the provider's own record id, then a hash of the
|
|
51
|
+
exact bytes. The hash fallback means an event with no id is still deduplicated
|
|
52
|
+
rather than silently allowed to double-process.
|
|
53
|
+
"""
|
|
54
|
+
name = str(payload.get("event") or payload.get("event_type") or "").strip()
|
|
55
|
+
data = payload.get("data")
|
|
56
|
+
if not isinstance(data, Mapping):
|
|
57
|
+
data = {}
|
|
58
|
+
|
|
59
|
+
record_id = data.get("id")
|
|
60
|
+
if record_id is None:
|
|
61
|
+
record_id = payload.get("id")
|
|
62
|
+
if record_id is not None and str(record_id).strip():
|
|
63
|
+
event_id = "%s:%s:%s" % (provider, name or "event", record_id)
|
|
64
|
+
else:
|
|
65
|
+
event_id = "%s:%s:sha256:%s" % (provider, name or "event", _digest(raw))
|
|
66
|
+
|
|
67
|
+
return Event(provider=provider, name=name, data=data, event_id=event_id,
|
|
68
|
+
raw=raw, headers=dict(headers or {}))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def header(headers: Mapping[str, str], name: str) -> str:
|
|
72
|
+
"""Case-insensitive header lookup that works for dicts and Starlette's Headers."""
|
|
73
|
+
if not headers:
|
|
74
|
+
return ""
|
|
75
|
+
try:
|
|
76
|
+
value = headers.get(name) # type: ignore[union-attr]
|
|
77
|
+
if value is not None:
|
|
78
|
+
return str(value)
|
|
79
|
+
except AttributeError:
|
|
80
|
+
pass
|
|
81
|
+
lowered = name.lower()
|
|
82
|
+
for key, value in headers.items():
|
|
83
|
+
if str(key).lower() == lowered:
|
|
84
|
+
return "" if value is None else str(value)
|
|
85
|
+
return ""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def load_json(raw: bytes) -> Mapping[str, Any]:
|
|
89
|
+
"""Parse the body into an object, or raise MalformedPayload via the caller."""
|
|
90
|
+
try:
|
|
91
|
+
payload = json.loads(raw)
|
|
92
|
+
except (ValueError, UnicodeDecodeError) as exc:
|
|
93
|
+
raise ValueError("body is not JSON: %s" % exc.__class__.__name__) from exc
|
|
94
|
+
if not isinstance(payload, Mapping):
|
|
95
|
+
raise ValueError("body is JSON but not an object")
|
|
96
|
+
return payload
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Flutterwave webhooks.
|
|
2
|
+
|
|
3
|
+
Flutterwave does not sign the payload. It sends your configured secret hash in the
|
|
4
|
+
``verif-hash`` header, and the only thing that proves is that the sender knows the
|
|
5
|
+
hash. It does **not** cover the body, so a valid header can accompany a tampered
|
|
6
|
+
payload if the hash ever leaks.
|
|
7
|
+
|
|
8
|
+
That is a weaker guarantee than Paystack's, and pretending otherwise would be
|
|
9
|
+
dishonest. Practical implications, documented rather than hidden:
|
|
10
|
+
|
|
11
|
+
* Rotate the hash if it is exposed anywhere (logs, a public repo, a screenshot).
|
|
12
|
+
* Deduplicate on ``event_id``; the hash is static, so it cannot prevent replay.
|
|
13
|
+
* Compare the transaction amount and currency against your own record before
|
|
14
|
+
fulfilling. The signature cannot tell you the amount is right, only that the
|
|
15
|
+
sender knows the secret.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Mapping, Optional
|
|
21
|
+
|
|
22
|
+
from .errors import MalformedPayload, RawBodyRequired, SignatureError
|
|
23
|
+
from .event import Event, build_event, header, load_json
|
|
24
|
+
from .idempotency import Store
|
|
25
|
+
from .signature import constant_time_equals
|
|
26
|
+
|
|
27
|
+
VERIF_HASH_HEADER = "verif-hash"
|
|
28
|
+
PROVIDER = "flutterwave"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class FlutterwaveWebhook:
|
|
32
|
+
"""Verify and decode Flutterwave webhook deliveries."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, secret_hash: str, *, store: Optional[Store] = None) -> None:
|
|
35
|
+
if not secret_hash or not str(secret_hash).strip():
|
|
36
|
+
raise ValueError(
|
|
37
|
+
"secret_hash is required: pass the hash you set in the Flutterwave dashboard")
|
|
38
|
+
self._hash = str(secret_hash).strip()
|
|
39
|
+
self._store = store
|
|
40
|
+
|
|
41
|
+
def parse(self, body: bytes, headers: Mapping[str, str]) -> Event:
|
|
42
|
+
if isinstance(body, str):
|
|
43
|
+
raise RawBodyRequired(
|
|
44
|
+
"body arrived as str, which cannot be verified: re-encoding changes the "
|
|
45
|
+
"digest. Pass the raw bytes instead (request.body(), request.get_data()).")
|
|
46
|
+
if isinstance(body, (bytearray, memoryview)):
|
|
47
|
+
body = bytes(body)
|
|
48
|
+
if not isinstance(body, bytes):
|
|
49
|
+
raise RawBodyRequired(
|
|
50
|
+
"body must be raw bytes; pass request.body() before parsing JSON")
|
|
51
|
+
provided = header(headers, VERIF_HASH_HEADER)
|
|
52
|
+
# Deliberately no body in this comparison: Flutterwave's hash is static.
|
|
53
|
+
if not constant_time_equals(self._hash, provided):
|
|
54
|
+
raise SignatureError("verif-hash did not match the configured secret hash")
|
|
55
|
+
try:
|
|
56
|
+
payload = load_json(body)
|
|
57
|
+
except ValueError as exc:
|
|
58
|
+
raise MalformedPayload(str(exc)) from exc
|
|
59
|
+
if not str(payload.get("event") or payload.get("event_type") or "").strip():
|
|
60
|
+
raise MalformedPayload("payload has no 'event' field")
|
|
61
|
+
return build_event(PROVIDER, payload, body, headers or {})
|
|
62
|
+
|
|
63
|
+
def process(self, body: bytes, headers: Mapping[str, str],
|
|
64
|
+
*, store: Optional[Store] = None) -> Optional[Event]:
|
|
65
|
+
event = self.parse(body, headers)
|
|
66
|
+
target = store if store is not None else self._store
|
|
67
|
+
if target is not None and target.seen(event.event_id):
|
|
68
|
+
return None
|
|
69
|
+
return event
|
|
70
|
+
|
|
71
|
+
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Deduplication, because both providers retry on any non-2xx.
|
|
2
|
+
|
|
3
|
+
The store is deliberately tiny and in-memory: it is correct for one process and
|
|
4
|
+
honest about it. A multi-instance deployment needs a shared store (Redis, a table
|
|
5
|
+
with a unique key), which is why the protocol exists rather than a hard-coded dict.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import threading
|
|
11
|
+
import time
|
|
12
|
+
from typing import Callable, Dict, Protocol
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Store(Protocol):
|
|
16
|
+
"""Anything that can answer 'have I seen this key before?' atomically."""
|
|
17
|
+
|
|
18
|
+
def seen(self, key: str) -> bool:
|
|
19
|
+
"""Record the key and return True if it was already present."""
|
|
20
|
+
...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MemoryStore:
|
|
24
|
+
"""Thread-safe, TTL-bounded, capped. Single process only.
|
|
25
|
+
|
|
26
|
+
``seen`` both checks and records in one locked operation, so two concurrent
|
|
27
|
+
deliveries of the same event cannot both be told it is new. A check-then-record
|
|
28
|
+
API would race, and that race is a double charge.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, ttl_seconds: int = 86400, max_entries: int = 10_000,
|
|
32
|
+
clock: Callable[[], float] = time.monotonic) -> None:
|
|
33
|
+
if ttl_seconds <= 0:
|
|
34
|
+
raise ValueError("ttl_seconds must be positive")
|
|
35
|
+
if max_entries <= 0:
|
|
36
|
+
raise ValueError("max_entries must be positive")
|
|
37
|
+
self._ttl = float(ttl_seconds)
|
|
38
|
+
self._max = int(max_entries)
|
|
39
|
+
self._clock = clock
|
|
40
|
+
self._lock = threading.Lock()
|
|
41
|
+
self._seen: Dict[str, float] = {}
|
|
42
|
+
|
|
43
|
+
def seen(self, key: str) -> bool:
|
|
44
|
+
if not key:
|
|
45
|
+
raise ValueError("an idempotency key is required")
|
|
46
|
+
now = self._clock()
|
|
47
|
+
with self._lock:
|
|
48
|
+
self._evict(now)
|
|
49
|
+
if key in self._seen:
|
|
50
|
+
return True
|
|
51
|
+
self._seen[key] = now
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
def __len__(self) -> int:
|
|
55
|
+
with self._lock:
|
|
56
|
+
self._evict(self._clock())
|
|
57
|
+
return len(self._seen)
|
|
58
|
+
|
|
59
|
+
def _evict(self, now: float) -> None:
|
|
60
|
+
expired = [k for k, at in self._seen.items() if now - at > self._ttl]
|
|
61
|
+
for key in expired:
|
|
62
|
+
del self._seen[key]
|
|
63
|
+
if len(self._seen) > self._max:
|
|
64
|
+
overflow = len(self._seen) - self._max
|
|
65
|
+
for key in sorted(self._seen, key=lambda k: self._seen[k])[:overflow]:
|
|
66
|
+
del self._seen[key]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Paystack webhooks.
|
|
2
|
+
|
|
3
|
+
Paystack signs the **raw request body** with HMAC-SHA512 using your secret key and
|
|
4
|
+
sends the hex digest in ``x-paystack-signature``. Two consequences that trip up
|
|
5
|
+
most integrations:
|
|
6
|
+
|
|
7
|
+
* You must verify the bytes that arrived, before any JSON parsing. Re-encoding a
|
|
8
|
+
parsed dict changes whitespace and key order, so the digest no longer matches.
|
|
9
|
+
* The signature covers the body only. Paystack sends no signed timestamp, so a
|
|
10
|
+
captured delivery stays valid forever. Freshness cannot be proven cryptographically;
|
|
11
|
+
deduplicate on ``event_id`` and treat that as your replay defence.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Mapping, Optional
|
|
17
|
+
|
|
18
|
+
from .errors import MalformedPayload, RawBodyRequired, SignatureError
|
|
19
|
+
from .event import Event, build_event, header, load_json
|
|
20
|
+
from .idempotency import Store
|
|
21
|
+
from .signature import constant_time_equals, hmac_sha512_hex
|
|
22
|
+
|
|
23
|
+
SIGNATURE_HEADER = "x-paystack-signature"
|
|
24
|
+
PROVIDER = "paystack"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PaystackWebhook:
|
|
28
|
+
"""Verify and decode Paystack webhook deliveries."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, secret_key: str, *, store: Optional[Store] = None) -> None:
|
|
31
|
+
if not secret_key or not str(secret_key).strip():
|
|
32
|
+
raise ValueError("secret_key is required: pass your Paystack secret key")
|
|
33
|
+
self._secret = str(secret_key).strip()
|
|
34
|
+
self._store = store
|
|
35
|
+
|
|
36
|
+
def expected_signature(self, body: bytes) -> str:
|
|
37
|
+
"""The digest Paystack should have sent for these exact bytes."""
|
|
38
|
+
return hmac_sha512_hex(self._secret, body)
|
|
39
|
+
|
|
40
|
+
def parse(self, body: bytes, headers: Mapping[str, str]) -> Event:
|
|
41
|
+
"""Verify the signature and return the event, or raise."""
|
|
42
|
+
if isinstance(body, str):
|
|
43
|
+
raise RawBodyRequired(
|
|
44
|
+
"body arrived as str, which cannot be verified: re-encoding changes the "
|
|
45
|
+
"digest. Pass the raw bytes instead (request.body(), request.get_data()).")
|
|
46
|
+
if isinstance(body, (bytearray, memoryview)):
|
|
47
|
+
body = bytes(body)
|
|
48
|
+
if not isinstance(body, bytes):
|
|
49
|
+
raise RawBodyRequired(
|
|
50
|
+
"body must be raw bytes; pass request.body() before parsing JSON")
|
|
51
|
+
provided = header(headers, SIGNATURE_HEADER)
|
|
52
|
+
if not constant_time_equals(self.expected_signature(body), provided):
|
|
53
|
+
raise SignatureError(
|
|
54
|
+
"x-paystack-signature did not match the body for the configured secret")
|
|
55
|
+
try:
|
|
56
|
+
payload = load_json(body)
|
|
57
|
+
except ValueError as exc:
|
|
58
|
+
raise MalformedPayload(str(exc)) from exc
|
|
59
|
+
if not str(payload.get("event") or payload.get("event_type") or "").strip():
|
|
60
|
+
raise MalformedPayload("payload has no 'event' field")
|
|
61
|
+
return build_event(PROVIDER, payload, body, headers or {})
|
|
62
|
+
|
|
63
|
+
def process(self, body: bytes, headers: Mapping[str, str],
|
|
64
|
+
*, store: Optional[Store] = None) -> Optional[Event]:
|
|
65
|
+
"""Verify, then return the event once. A repeat delivery returns ``None``."""
|
|
66
|
+
event = self.parse(body, headers)
|
|
67
|
+
target = store if store is not None else self._store
|
|
68
|
+
if target is not None and target.seen(event.event_id):
|
|
69
|
+
return None
|
|
70
|
+
return event
|
|
71
|
+
|
|
72
|
+
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""HMAC helpers. Constant-time comparisons, no shortcuts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def hmac_sha512_hex(secret: str, body: bytes) -> str:
|
|
10
|
+
"""Paystack signs the raw body with HMAC-SHA512 and sends the hex digest."""
|
|
11
|
+
return hmac.new(secret.encode("utf-8"), body, hashlib.sha512).hexdigest()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def hmac_sha256_hex(secret: str, body: bytes) -> str:
|
|
15
|
+
"""Provided for providers that use SHA-256; not used by Paystack."""
|
|
16
|
+
return hmac.new(secret.encode("utf-8"), body, hashlib.sha256).hexdigest()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def constant_time_equals(expected: str, provided: str) -> bool:
|
|
20
|
+
"""Compare two strings without leaking length or content through timing.
|
|
21
|
+
|
|
22
|
+
Both operands are normalised to bytes first: ``compare_digest`` rejects ``str``
|
|
23
|
+
arguments outside the ASCII range, which would otherwise raise on a crafted
|
|
24
|
+
header instead of returning False.
|
|
25
|
+
"""
|
|
26
|
+
if not expected or not provided:
|
|
27
|
+
return False
|
|
28
|
+
return hmac.compare_digest(expected.encode("utf-8"), provided.strip().encode("utf-8"))
|