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.
Files changed (26) hide show
  1. paystack_webhook-0.1.1/LICENSE +21 -0
  2. paystack_webhook-0.1.1/PKG-INFO +189 -0
  3. paystack_webhook-0.1.1/README.md +160 -0
  4. paystack_webhook-0.1.1/paystack_webhook.egg-info/PKG-INFO +189 -0
  5. paystack_webhook-0.1.1/paystack_webhook.egg-info/SOURCES.txt +24 -0
  6. paystack_webhook-0.1.1/paystack_webhook.egg-info/dependency_links.txt +1 -0
  7. paystack_webhook-0.1.1/paystack_webhook.egg-info/requires.txt +8 -0
  8. paystack_webhook-0.1.1/paystack_webhook.egg-info/top_level.txt +1 -0
  9. paystack_webhook-0.1.1/paystack_webhooks/__init__.py +28 -0
  10. paystack_webhook-0.1.1/paystack_webhooks/contrib/__init__.py +1 -0
  11. paystack_webhook-0.1.1/paystack_webhooks/contrib/fastapi.py +46 -0
  12. paystack_webhook-0.1.1/paystack_webhooks/contrib/flask.py +38 -0
  13. paystack_webhook-0.1.1/paystack_webhooks/errors.py +39 -0
  14. paystack_webhook-0.1.1/paystack_webhooks/event.py +96 -0
  15. paystack_webhook-0.1.1/paystack_webhooks/flutterwave.py +71 -0
  16. paystack_webhook-0.1.1/paystack_webhooks/idempotency.py +66 -0
  17. paystack_webhook-0.1.1/paystack_webhooks/paystack.py +72 -0
  18. paystack_webhook-0.1.1/paystack_webhooks/signature.py +28 -0
  19. paystack_webhook-0.1.1/pyproject.toml +45 -0
  20. paystack_webhook-0.1.1/setup.cfg +4 -0
  21. paystack_webhook-0.1.1/tests/test_adapters.py +115 -0
  22. paystack_webhook-0.1.1/tests/test_event.py +54 -0
  23. paystack_webhook-0.1.1/tests/test_flutterwave.py +74 -0
  24. paystack_webhook-0.1.1/tests/test_idempotency.py +83 -0
  25. paystack_webhook-0.1.1/tests/test_paystack.py +141 -0
  26. 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
+ [![ci](https://github.com/mgkvibez/paystack-webhooks/actions/workflows/ci.yml/badge.svg)](https://github.com/mgkvibez/paystack-webhooks/actions/workflows/ci.yml)
35
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
36
+ [![python](https://img.shields.io/badge/python-3.9%2B-blue.svg)](pyproject.toml)
37
+ [![dependencies](https://img.shields.io/badge/dependencies-0-brightgreen.svg)](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
+ [![ci](https://github.com/mgkvibez/paystack-webhooks/actions/workflows/ci.yml/badge.svg)](https://github.com/mgkvibez/paystack-webhooks/actions/workflows/ci.yml)
6
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
7
+ [![python](https://img.shields.io/badge/python-3.9%2B-blue.svg)](pyproject.toml)
8
+ [![dependencies](https://img.shields.io/badge/dependencies-0-brightgreen.svg)](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
+ [![ci](https://github.com/mgkvibez/paystack-webhooks/actions/workflows/ci.yml/badge.svg)](https://github.com/mgkvibez/paystack-webhooks/actions/workflows/ci.yml)
35
+ [![license: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
36
+ [![python](https://img.shields.io/badge/python-3.9%2B-blue.svg)](pyproject.toml)
37
+ [![dependencies](https://img.shields.io/badge/dependencies-0-brightgreen.svg)](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,8 @@
1
+
2
+ [dev]
3
+ pytest>=8
4
+ fastapi>=0.110
5
+ httpx>=0.27
6
+ flask>=3.0
7
+ ruff>=0.4
8
+ build>=1.0
@@ -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