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.
@@ -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,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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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"))