paystack-webhook 0.1.1__tar.gz
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/LICENSE +21 -0
- paystack_webhook-0.1.1/PKG-INFO +189 -0
- paystack_webhook-0.1.1/README.md +160 -0
- paystack_webhook-0.1.1/paystack_webhook.egg-info/PKG-INFO +189 -0
- paystack_webhook-0.1.1/paystack_webhook.egg-info/SOURCES.txt +24 -0
- paystack_webhook-0.1.1/paystack_webhook.egg-info/dependency_links.txt +1 -0
- paystack_webhook-0.1.1/paystack_webhook.egg-info/requires.txt +8 -0
- paystack_webhook-0.1.1/paystack_webhook.egg-info/top_level.txt +1 -0
- paystack_webhook-0.1.1/paystack_webhooks/__init__.py +28 -0
- paystack_webhook-0.1.1/paystack_webhooks/contrib/__init__.py +1 -0
- paystack_webhook-0.1.1/paystack_webhooks/contrib/fastapi.py +46 -0
- paystack_webhook-0.1.1/paystack_webhooks/contrib/flask.py +38 -0
- paystack_webhook-0.1.1/paystack_webhooks/errors.py +39 -0
- paystack_webhook-0.1.1/paystack_webhooks/event.py +96 -0
- paystack_webhook-0.1.1/paystack_webhooks/flutterwave.py +71 -0
- paystack_webhook-0.1.1/paystack_webhooks/idempotency.py +66 -0
- paystack_webhook-0.1.1/paystack_webhooks/paystack.py +72 -0
- paystack_webhook-0.1.1/paystack_webhooks/signature.py +28 -0
- paystack_webhook-0.1.1/pyproject.toml +45 -0
- paystack_webhook-0.1.1/setup.cfg +4 -0
- paystack_webhook-0.1.1/tests/test_adapters.py +115 -0
- paystack_webhook-0.1.1/tests/test_event.py +54 -0
- paystack_webhook-0.1.1/tests/test_flutterwave.py +74 -0
- paystack_webhook-0.1.1/tests/test_idempotency.py +83 -0
- paystack_webhook-0.1.1/tests/test_paystack.py +141 -0
- paystack_webhook-0.1.1/tests/test_signature.py +40 -0
|
@@ -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,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,160 @@
|
|
|
1
|
+
# paystack-webhooks
|
|
2
|
+
|
|
3
|
+
**Verify Paystack and Flutterwave webhooks in Python, including the part the docs skip: retries.**
|
|
4
|
+
|
|
5
|
+
[](https://github.com/mgkvibez/paystack-webhooks/actions/workflows/ci.yml)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
[](pyproject.toml)
|
|
8
|
+
[](pyproject.toml)
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from paystack_webhooks import PaystackWebhook
|
|
12
|
+
|
|
13
|
+
paystack = PaystackWebhook("sk_live_...")
|
|
14
|
+
|
|
15
|
+
event = paystack.process(raw_body, headers) # None means you already handled it
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
That is the whole library. The rest of this page is why `process` exists and the
|
|
19
|
+
four-line snippet on Stack Overflow is not enough.
|
|
20
|
+
|
|
21
|
+
## The snippet everyone copies, and what it misses
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
if hmac.new(key, request.body, hashlib.sha512).hexdigest() != request.headers["x-paystack-signature"]:
|
|
25
|
+
abort(401)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
The signature check is right. Two other things are missing, and both cost money.
|
|
29
|
+
|
|
30
|
+
**1. Retries.** Paystack and Flutterwave both retry when your endpoint does not return
|
|
31
|
+
a 2xx: a timeout, a 500, a deploy that restarts the worker mid-request. Your handler
|
|
32
|
+
then runs twice for one payment. If the second run grants value again, you have given
|
|
33
|
+
away product, and no amount of signature checking prevents it.
|
|
34
|
+
|
|
35
|
+
**2. Verifying after parsing.** If you call `request.json()` first, the bytes you
|
|
36
|
+
hash are not the bytes that arrived: key order and whitespace change, the digest never
|
|
37
|
+
matches, and the usual "fix" is to drop the check.
|
|
38
|
+
|
|
39
|
+
This library handles the raw bytes, derives a stable event id, and gives you a
|
|
40
|
+
single call that is safe to repeat.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pipx install paystack-webhook
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The distribution is called `paystack-webhook`, singular, because PyPI refused
|
|
49
|
+
`paystack-webhooks` as too similar to a project that already exists. Nothing a user
|
|
50
|
+
types changes: the import is `paystack_webhooks` and the repo keeps its name.
|
|
51
|
+
|
|
52
|
+
Python 3.9+, **zero runtime dependencies**. FastAPI and Flask are optional extras that
|
|
53
|
+
only the adapters import.
|
|
54
|
+
|
|
55
|
+
## FastAPI
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
from fastapi import FastAPI
|
|
59
|
+
from paystack_webhooks import MemoryStore, PaystackWebhook
|
|
60
|
+
from paystack_webhooks.contrib.fastapi import webhook_route
|
|
61
|
+
|
|
62
|
+
app = FastAPI()
|
|
63
|
+
|
|
64
|
+
def on_event(event):
|
|
65
|
+
if event.name == "charge.success":
|
|
66
|
+
fulfil(event.data["reference"], event.data["amount"])
|
|
67
|
+
|
|
68
|
+
app.include_router(webhook_route(
|
|
69
|
+
PaystackWebhook("sk_live_..."),
|
|
70
|
+
store=MemoryStore(), # dedupe across retries
|
|
71
|
+
on_event=on_event,
|
|
72
|
+
path="/webhooks/paystack",
|
|
73
|
+
))
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Duplicates return **200**, not 409. Both providers retry on non-2xx, so answering
|
|
77
|
+
409 to something you already handled starts a retry storm.
|
|
78
|
+
|
|
79
|
+
## Flask
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from flask import Flask
|
|
83
|
+
from paystack_webhooks import MemoryStore, PaystackWebhook
|
|
84
|
+
from paystack_webhooks.contrib.flask import webhook_view
|
|
85
|
+
|
|
86
|
+
app = Flask(__name__)
|
|
87
|
+
app.add_url_rule("/webhooks/paystack", methods=["POST"], view_func=webhook_view(
|
|
88
|
+
PaystackWebhook("sk_live_..."), store=MemoryStore(), on_event=on_event))
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Anywhere else, the core is framework-agnostic: pass the raw bytes and a header mapping
|
|
92
|
+
you can read case-insensitively.
|
|
93
|
+
|
|
94
|
+
## Flutterwave, and the weaker guarantee it actually offers
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from paystack_webhooks import FlutterwaveWebhook
|
|
98
|
+
|
|
99
|
+
flutterwave = FlutterwaveWebhook("your_verif_hash")
|
|
100
|
+
event = flutterwave.process(raw_body, headers)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Flutterwave does not sign the body. It sends your static secret in a `verif-hash`
|
|
104
|
+
header, which proves the sender knows the hash and nothing else. A valid header can
|
|
105
|
+
accompany a tampered payload if the hash leaks. This library says so instead of
|
|
106
|
+
implying parity with Paystack.
|
|
107
|
+
|
|
108
|
+
## The security model, stated plainly
|
|
109
|
+
|
|
110
|
+
| | Paystack | Flutterwave |
|
|
111
|
+
|---|---|---|
|
|
112
|
+
| What is checked | HMAC-SHA512 over the raw body | static `verif-hash` header |
|
|
113
|
+
| Does it cover the body | yes | **no** |
|
|
114
|
+
| Signed timestamp | none | none |
|
|
115
|
+
| Replay defence | dedupe on `event_id` | dedupe, plus compare amount and currency |
|
|
116
|
+
|
|
117
|
+
Neither provider sends a signed timestamp, so a "5 minute replay window" is theatre:
|
|
118
|
+
there is nothing authentic to compare a clock against. Deduplication is the only
|
|
119
|
+
honest defence available, which is why `event_id` is computed for you and why the store
|
|
120
|
+
is part of the API.
|
|
121
|
+
|
|
122
|
+
For Flutterwave, always compare `event.data["amount"]` and `currency` against your own
|
|
123
|
+
record before fulfilling. The header cannot tell you the amount is right.
|
|
124
|
+
|
|
125
|
+
## What this deliberately does not do
|
|
126
|
+
|
|
127
|
+
- **It is not an API client.** Use `paystackapi` or `rave-python` to charge cards. This
|
|
128
|
+
library only consumes webhooks.
|
|
129
|
+
- **It does not validate amounts for you.** That requires your records, not the payload.
|
|
130
|
+
- **`MemoryStore` is single-process.** It is correct for one worker. On several
|
|
131
|
+
instances implement the four-line `Store` protocol against Redis or a table with a
|
|
132
|
+
unique constraint; a check-then-insert will race and double-charge.
|
|
133
|
+
- **It does not decide your response codes for unknown events.** The adapter returns
|
|
134
|
+
200 and calls your handler; ignore event types you do not care about.
|
|
135
|
+
|
|
136
|
+
## Errors map to status codes
|
|
137
|
+
|
|
138
|
+
| Exception | Meaning | Status |
|
|
139
|
+
|---|---|---|
|
|
140
|
+
| `SignatureError` | wrong secret, forged header, tampered body | 401 |
|
|
141
|
+
| `MalformedPayload` | valid signature, unusable body | 400 |
|
|
142
|
+
| `RawBodyRequired` | you passed text or a dict instead of bytes | 500, and it is a bug in your code |
|
|
143
|
+
|
|
144
|
+
## Tests
|
|
145
|
+
|
|
146
|
+
The suite runs offline: signatures are computed in the tests with `hmac`/`hashlib`
|
|
147
|
+
directly, so a bug in this library cannot make its own tests pass. It covers valid and
|
|
148
|
+
forged signatures, tampered bodies, non-ASCII headers, text bodies, validly signed
|
|
149
|
+
garbage, idempotency including a 50-thread race on one event id, TTL and capacity
|
|
150
|
+
eviction, both adapters, and a check that importing the core pulls in no web framework.
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
pip install -e ".[dev]"
|
|
154
|
+
python -m pytest -q
|
|
155
|
+
bash scripts/verify.sh
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
## License
|
|
159
|
+
|
|
160
|
+
MIT.
|
|
@@ -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,24 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
paystack_webhook.egg-info/PKG-INFO
|
|
5
|
+
paystack_webhook.egg-info/SOURCES.txt
|
|
6
|
+
paystack_webhook.egg-info/dependency_links.txt
|
|
7
|
+
paystack_webhook.egg-info/requires.txt
|
|
8
|
+
paystack_webhook.egg-info/top_level.txt
|
|
9
|
+
paystack_webhooks/__init__.py
|
|
10
|
+
paystack_webhooks/errors.py
|
|
11
|
+
paystack_webhooks/event.py
|
|
12
|
+
paystack_webhooks/flutterwave.py
|
|
13
|
+
paystack_webhooks/idempotency.py
|
|
14
|
+
paystack_webhooks/paystack.py
|
|
15
|
+
paystack_webhooks/signature.py
|
|
16
|
+
paystack_webhooks/contrib/__init__.py
|
|
17
|
+
paystack_webhooks/contrib/fastapi.py
|
|
18
|
+
paystack_webhooks/contrib/flask.py
|
|
19
|
+
tests/test_adapters.py
|
|
20
|
+
tests/test_event.py
|
|
21
|
+
tests/test_flutterwave.py
|
|
22
|
+
tests/test_idempotency.py
|
|
23
|
+
tests/test_paystack.py
|
|
24
|
+
tests/test_signature.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -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
|