sebpay 0.2.0__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.
sebpay-0.2.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Sebpay
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.
sebpay-0.2.0/PKG-INFO ADDED
@@ -0,0 +1,143 @@
1
+ Metadata-Version: 2.4
2
+ Name: sebpay
3
+ Version: 0.2.0
4
+ Summary: Official Python SDK for Sebpay hosted checkout — create a checkout session, redirect your customer, confirm the payment and verify webhooks.
5
+ Author: Sebpay
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://sebpay.africa
8
+ Project-URL: Repository, https://github.com/mjims/sebpay-sdk-py
9
+ Project-URL: Issues, https://github.com/mjims/sebpay-sdk-py/issues
10
+ Keywords: sebpay,payment,payments,checkout,mobile-money,africa,webhook,sdk
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Software Development :: Libraries
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.9
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: requests>=2.28
27
+ Dynamic: license-file
28
+
29
+ # sebpay-python
30
+
31
+ Official Python SDK for [Sebpay](https://sebpay.africa) **hosted checkout**. It does four things:
32
+ - create a checkout session;
33
+ - redirect your customer to the Sebpay payment page;
34
+ - confirm the payment when they come back;
35
+ - verify Sebpay's webhooks.
36
+
37
+ Its only dependency is `requests`. Direct collections, payouts and reference data are not part of the SDK. They remain available over the plain HTTP API.
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pip install sebpay
43
+ ```
44
+
45
+ Requires Python 3.9 or later. The package ships its type hints (`py.typed`).
46
+
47
+ ## Usage
48
+
49
+ ### 1. Create a session and redirect your customer
50
+
51
+ ```python
52
+ import os
53
+ from sebpay import SebpayClient
54
+
55
+ # Keys from your dashboard (API développeur): pk_test_ keys go to the sandbox server, pk_live_ keys to live.
56
+ sebpay = SebpayClient(os.environ["SEBPAY_PUBLIC_KEY"], os.environ["SEBPAY_SECRET_KEY"])
57
+
58
+ session = sebpay.checkout_sessions.create(
59
+ amount=5000,
60
+ currency="XOF",
61
+ redirect_url="https://yoursite.com/orders/return",
62
+ external_reference=order.id, # your order id: sending it again returns the same session
63
+ callback_url="https://yoursite.com/webhook/sebpay",
64
+ metadata={"order_id": order.id},
65
+ )
66
+
67
+ return redirect(session["checkout_url"])
68
+ ```
69
+
70
+ ### 2. Confirm the payment when your customer comes back
71
+
72
+ Your `redirect_url` receives `?checkout_session_id=<id>`. Coming back proves nothing, so ask Sebpay before you deliver:
73
+
74
+ ```python
75
+ session = sebpay.checkout_sessions.retrieve(request.args["checkout_session_id"])
76
+
77
+ if session["transaction_status"] == "approved":
78
+ ... # Paid: deliver the order (session["external_reference"], session["metadata"]).
79
+ ```
80
+
81
+ `retrieve()` also accepts your `external_reference`.
82
+
83
+ ### 3. Verify the webhook
84
+
85
+ Sebpay POSTs the final status (`approved` or `rejected`) to your `callback_url`. For a checkout, the request also carries `checkout_session_id` and your `metadata`.
86
+
87
+ ```python
88
+ from sebpay import SIGNATURE_HEADER, verify_signature
89
+
90
+ # With Flask:
91
+ if not verify_signature(request.get_data(), request.headers.get(SIGNATURE_HEADER), os.environ["SEBPAY_SECRET_KEY"]):
92
+ return {"error": "Invalid signature"}, 401
93
+
94
+ payload = request.get_json() # payload["status"]: "approved" | "rejected"
95
+ ```
96
+
97
+ - Always pass the **raw** request body. Re-serializing a parsed payload can reorder keys and break the signature.
98
+ - A missing header returns `False`.
99
+
100
+ The shapes of the session and of the webhook payload are described as `TypedDict`s in `sebpay.types`, for editors and type checkers.
101
+
102
+ ## Options
103
+
104
+ ```python
105
+ SebpayClient(public_key, secret_key,
106
+ base_url="http://localhost:8000/api/v1", # only to test against a local API; otherwise chosen from the keys
107
+ timeout=10) # seconds, default 30
108
+ ```
109
+
110
+ `sebpay.base_url` tells which server the client talks to.
111
+
112
+ ## Errors
113
+
114
+ Every failed call raises a `SebpayApiError`, with three attributes:
115
+ - `status`: the HTTP status, or 0 when no response came back;
116
+ - `code`: the API's `errors.code`, or one of the SDK's own codes. Examples: `EXTERNAL_REFERENCE_CONFLICT`, `RATE_LIMITED`, `WRONG_ENVIRONMENT`, `NETWORK_ERROR`, `TIMEOUT`, `INVALID_RESPONSE`. It is `None` otherwise;
117
+ - `body`: the decoded response.
118
+
119
+ ```python
120
+ from sebpay import SebpayApiError
121
+
122
+ try:
123
+ sebpay.checkout_sessions.create(...)
124
+ except SebpayApiError as e:
125
+ if e.code == "EXTERNAL_REFERENCE_CONFLICT":
126
+ ... # This order id was already used with other parameters.
127
+ ```
128
+
129
+ ## Examples
130
+
131
+ See [`examples/`](https://github.com/mjims/sebpay-sdk-py/tree/main/examples). Set `SEBPAY_PUBLIC_KEY` and `SEBPAY_SECRET_KEY`, then:
132
+ - `python examples/create_checkout.py` creates a session and prints its `checkout_url`;
133
+ - `python examples/webhook_server.py` starts a Flask server (`pip install flask`) that handles the webhook and the return page, on `PORT` (default 3002).
134
+
135
+ For local testing, also set `SEBPAY_BASE_URL`.
136
+
137
+ ## Tests
138
+
139
+ `python -m unittest discover -s tests` runs the SDK's offline tests. They cover the transport, against a local HTTP server, and the shared signature vectors.
140
+
141
+ ## Requirements
142
+
143
+ Python 3.9+ and `requests`.
sebpay-0.2.0/README.md ADDED
@@ -0,0 +1,115 @@
1
+ # sebpay-python
2
+
3
+ Official Python SDK for [Sebpay](https://sebpay.africa) **hosted checkout**. It does four things:
4
+ - create a checkout session;
5
+ - redirect your customer to the Sebpay payment page;
6
+ - confirm the payment when they come back;
7
+ - verify Sebpay's webhooks.
8
+
9
+ Its only dependency is `requests`. Direct collections, payouts and reference data are not part of the SDK. They remain available over the plain HTTP API.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install sebpay
15
+ ```
16
+
17
+ Requires Python 3.9 or later. The package ships its type hints (`py.typed`).
18
+
19
+ ## Usage
20
+
21
+ ### 1. Create a session and redirect your customer
22
+
23
+ ```python
24
+ import os
25
+ from sebpay import SebpayClient
26
+
27
+ # Keys from your dashboard (API développeur): pk_test_ keys go to the sandbox server, pk_live_ keys to live.
28
+ sebpay = SebpayClient(os.environ["SEBPAY_PUBLIC_KEY"], os.environ["SEBPAY_SECRET_KEY"])
29
+
30
+ session = sebpay.checkout_sessions.create(
31
+ amount=5000,
32
+ currency="XOF",
33
+ redirect_url="https://yoursite.com/orders/return",
34
+ external_reference=order.id, # your order id: sending it again returns the same session
35
+ callback_url="https://yoursite.com/webhook/sebpay",
36
+ metadata={"order_id": order.id},
37
+ )
38
+
39
+ return redirect(session["checkout_url"])
40
+ ```
41
+
42
+ ### 2. Confirm the payment when your customer comes back
43
+
44
+ Your `redirect_url` receives `?checkout_session_id=<id>`. Coming back proves nothing, so ask Sebpay before you deliver:
45
+
46
+ ```python
47
+ session = sebpay.checkout_sessions.retrieve(request.args["checkout_session_id"])
48
+
49
+ if session["transaction_status"] == "approved":
50
+ ... # Paid: deliver the order (session["external_reference"], session["metadata"]).
51
+ ```
52
+
53
+ `retrieve()` also accepts your `external_reference`.
54
+
55
+ ### 3. Verify the webhook
56
+
57
+ Sebpay POSTs the final status (`approved` or `rejected`) to your `callback_url`. For a checkout, the request also carries `checkout_session_id` and your `metadata`.
58
+
59
+ ```python
60
+ from sebpay import SIGNATURE_HEADER, verify_signature
61
+
62
+ # With Flask:
63
+ if not verify_signature(request.get_data(), request.headers.get(SIGNATURE_HEADER), os.environ["SEBPAY_SECRET_KEY"]):
64
+ return {"error": "Invalid signature"}, 401
65
+
66
+ payload = request.get_json() # payload["status"]: "approved" | "rejected"
67
+ ```
68
+
69
+ - Always pass the **raw** request body. Re-serializing a parsed payload can reorder keys and break the signature.
70
+ - A missing header returns `False`.
71
+
72
+ The shapes of the session and of the webhook payload are described as `TypedDict`s in `sebpay.types`, for editors and type checkers.
73
+
74
+ ## Options
75
+
76
+ ```python
77
+ SebpayClient(public_key, secret_key,
78
+ base_url="http://localhost:8000/api/v1", # only to test against a local API; otherwise chosen from the keys
79
+ timeout=10) # seconds, default 30
80
+ ```
81
+
82
+ `sebpay.base_url` tells which server the client talks to.
83
+
84
+ ## Errors
85
+
86
+ Every failed call raises a `SebpayApiError`, with three attributes:
87
+ - `status`: the HTTP status, or 0 when no response came back;
88
+ - `code`: the API's `errors.code`, or one of the SDK's own codes. Examples: `EXTERNAL_REFERENCE_CONFLICT`, `RATE_LIMITED`, `WRONG_ENVIRONMENT`, `NETWORK_ERROR`, `TIMEOUT`, `INVALID_RESPONSE`. It is `None` otherwise;
89
+ - `body`: the decoded response.
90
+
91
+ ```python
92
+ from sebpay import SebpayApiError
93
+
94
+ try:
95
+ sebpay.checkout_sessions.create(...)
96
+ except SebpayApiError as e:
97
+ if e.code == "EXTERNAL_REFERENCE_CONFLICT":
98
+ ... # This order id was already used with other parameters.
99
+ ```
100
+
101
+ ## Examples
102
+
103
+ See [`examples/`](https://github.com/mjims/sebpay-sdk-py/tree/main/examples). Set `SEBPAY_PUBLIC_KEY` and `SEBPAY_SECRET_KEY`, then:
104
+ - `python examples/create_checkout.py` creates a session and prints its `checkout_url`;
105
+ - `python examples/webhook_server.py` starts a Flask server (`pip install flask`) that handles the webhook and the return page, on `PORT` (default 3002).
106
+
107
+ For local testing, also set `SEBPAY_BASE_URL`.
108
+
109
+ ## Tests
110
+
111
+ `python -m unittest discover -s tests` runs the SDK's offline tests. They cover the transport, against a local HTTP server, and the shared signature vectors.
112
+
113
+ ## Requirements
114
+
115
+ Python 3.9+ and `requests`.
@@ -0,0 +1,42 @@
1
+ [project]
2
+ name = "sebpay"
3
+ version = "0.2.0"
4
+ description = "Official Python SDK for Sebpay hosted checkout — create a checkout session, redirect your customer, confirm the payment and verify webhooks."
5
+ readme = "README.md"
6
+ requires-python = ">=3.9"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [{ name = "Sebpay" }]
10
+ keywords = ["sebpay", "payment", "payments", "checkout", "mobile-money", "africa", "webhook", "sdk"]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Intended Audience :: Developers",
14
+ "Operating System :: OS Independent",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.9",
17
+ "Programming Language :: Python :: 3.10",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "Programming Language :: Python :: 3.14",
22
+ "Topic :: Software Development :: Libraries",
23
+ "Typing :: Typed",
24
+ ]
25
+ dependencies = [
26
+ "requests>=2.28",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://sebpay.africa"
31
+ Repository = "https://github.com/mjims/sebpay-sdk-py"
32
+ Issues = "https://github.com/mjims/sebpay-sdk-py/issues"
33
+
34
+ [build-system]
35
+ requires = ["setuptools>=77"]
36
+ build-backend = "setuptools.build_meta"
37
+
38
+ [tool.setuptools.packages.find]
39
+ include = ["sebpay*"]
40
+
41
+ [tool.setuptools.package-data]
42
+ sebpay = ["py.typed"]
@@ -0,0 +1,6 @@
1
+ from ._version import __version__
2
+ from .client import SebpayClient
3
+ from .errors import SebpayApiError
4
+ from .webhooks import SIGNATURE_HEADER, verify_signature
5
+
6
+ __all__ = ["SebpayClient", "SebpayApiError", "verify_signature", "SIGNATURE_HEADER"]
@@ -0,0 +1,2 @@
1
+ # Sent in the User-Agent header. Keep in sync with pyproject.toml (checked by the tests).
2
+ __version__ = "0.2.0"
@@ -0,0 +1,69 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional
4
+
5
+ import requests
6
+
7
+ from ._version import __version__
8
+ from .errors import SebpayApiError
9
+ from .resources.checkout_sessions import CheckoutSessions
10
+
11
+ LIVE_BASE_URL = "https://newapi.sebpay.africa/api/v1"
12
+ SANDBOX_BASE_URL = "https://api.sandbox.sebpay.bj/api/v1"
13
+ DEFAULT_TIMEOUT = 30
14
+
15
+
16
+ class SebpayClient:
17
+ """Entry point for Sebpay hosted checkout. Pass the key pair from your
18
+ dashboard (API développeur): the sandbox and live are separate servers,
19
+ and pk_test_ keys are sent to the sandbox, pk_live_ keys to live.
20
+ `base_url` overrides that choice; `timeout` is in seconds.
21
+ """
22
+
23
+ def __init__(self, public_key: str, secret_key: str, base_url: Optional[str] = None, timeout: float = DEFAULT_TIMEOUT):
24
+ self._public_key = public_key
25
+ self._secret_key = secret_key
26
+ self._timeout = timeout
27
+ self.base_url = (base_url or (SANDBOX_BASE_URL if public_key.startswith("pk_test_") else LIVE_BASE_URL)).rstrip("/")
28
+ self._session = requests.Session()
29
+
30
+ # The transport stays private: the SDK only calls the endpoints it wraps
31
+ self.checkout_sessions = CheckoutSessions(self._request)
32
+
33
+ def _request(self, method: str, path: str, body: Optional[dict] = None) -> Any:
34
+ """Returns the `data` field of a successful response; raises SebpayApiError otherwise."""
35
+ headers = {
36
+ "X-Public-Key": self._public_key,
37
+ "X-Secret-Key": self._secret_key,
38
+ "Accept": "application/json",
39
+ "User-Agent": f"sebpay-python/{__version__}",
40
+ }
41
+
42
+ try:
43
+ response = self._session.request(method, f"{self.base_url}{path}", headers=headers, json=body, timeout=self._timeout)
44
+ except requests.Timeout as e:
45
+ raise SebpayApiError(f"Sebpay request timed out after {self._timeout} s", 0, None, code="TIMEOUT") from e
46
+ except requests.RequestException as e:
47
+ raise SebpayApiError(f"Sebpay request failed: {e}", 0, None, code="NETWORK_ERROR") from e
48
+
49
+ try:
50
+ decoded = response.json()
51
+ except ValueError:
52
+ decoded = None
53
+ error_body = decoded if decoded is not None else response.text
54
+
55
+ if not 200 <= response.status_code < 300 or (isinstance(decoded, dict) and decoded.get("success") is False):
56
+ errors = decoded.get("errors") if isinstance(decoded, dict) else None
57
+ code = errors.get("code") if isinstance(errors, dict) else None
58
+ message = decoded.get("message") if isinstance(decoded, dict) else None
59
+ raise SebpayApiError(
60
+ message if isinstance(message, str) else f"Sebpay API error (HTTP {response.status_code})",
61
+ response.status_code,
62
+ error_body,
63
+ code=code if isinstance(code, str) else None,
64
+ )
65
+
66
+ if not isinstance(decoded, dict) or not isinstance(decoded.get("data"), dict):
67
+ raise SebpayApiError("Sebpay API returned an unexpected response", response.status_code, error_body, code="INVALID_RESPONSE")
68
+
69
+ return decoded["data"]
@@ -0,0 +1,17 @@
1
+ from typing import Any, Optional
2
+
3
+
4
+ class SebpayApiError(Exception):
5
+ """Raised for every failed call.
6
+
7
+ status: the HTTP status, or 0 when no response came back (code "NETWORK_ERROR" or "TIMEOUT").
8
+ code: the API's errors.code when it sends one ("EXTERNAL_REFERENCE_CONFLICT", "RATE_LIMITED",
9
+ "WRONG_ENVIRONMENT"…), else None.
10
+ body: the decoded response (its text when it was not JSON).
11
+ """
12
+
13
+ def __init__(self, message: str, status: int, body: Any = None, code: Optional[str] = None):
14
+ super().__init__(message)
15
+ self.status = status
16
+ self.code = code
17
+ self.body = body
File without changes
File without changes
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import TYPE_CHECKING, Any, Callable
4
+ from urllib.parse import quote
5
+
6
+ if TYPE_CHECKING:
7
+ from ..types import CheckoutSession, CheckoutSessionDetails
8
+
9
+
10
+ class CheckoutSessions:
11
+ def __init__(self, request: Callable[..., Any]):
12
+ self._request = request
13
+
14
+ def create(self, **params: Any) -> CheckoutSession:
15
+ """Create a hosted checkout session. Returns `checkout_url` — redirect
16
+ your customer's browser there.
17
+
18
+ Params: amount, currency, redirect_url (required), external_reference,
19
+ callback_url, customer_name, customer_email, customer_phone, metadata (optional).
20
+ Sending an external_reference again with the same parameters returns the
21
+ same session with `idempotent_replay` = True.
22
+ """
23
+ return self._request("POST", "/checkout/sessions", params)
24
+
25
+ def retrieve(self, id_or_reference: str) -> CheckoutSessionDetails:
26
+ """Retrieve a session by its id (the `checkout_session_id` of your
27
+ redirect_url), its token or your external_reference. This is the
28
+ server-to-server confirmation: when your customer comes back, deliver
29
+ only if `transaction_status` is "approved" — the redirect alone proves nothing."""
30
+ return self._request("GET", f"/checkout/sessions/{quote(id_or_reference, safe='')}")
@@ -0,0 +1,46 @@
1
+ """Shapes of what the API returns, for type checkers and editors — plain dicts at runtime."""
2
+
3
+ from typing import Any, Dict, Literal, Optional, TypedDict
4
+
5
+ CheckoutSessionStatus = Literal["open", "completed", "expired", "canceled"]
6
+
7
+
8
+ class _CheckoutSessionFields(TypedDict):
9
+ id: str
10
+ status: CheckoutSessionStatus
11
+ checkout_url: str
12
+ amount: float
13
+ currency: str
14
+ external_reference: Optional[str]
15
+ metadata: Optional[Dict[str, Any]]
16
+ expires_at: str
17
+
18
+
19
+ class CheckoutSession(_CheckoutSessionFields, total=False):
20
+ # present (True) when this external_reference had already created the session
21
+ idempotent_replay: bool
22
+
23
+
24
+ class CheckoutSessionDetails(_CheckoutSessionFields):
25
+ transaction_id: Optional[str]
26
+ # "approved" is the only status that means paid
27
+ transaction_status: Optional[Literal["pending", "approved", "rejected"]]
28
+ created_at: str
29
+
30
+
31
+ class _WebhookPayloadFields(TypedDict):
32
+ transaction_id: str
33
+ external_reference: Optional[str]
34
+ # only final statuses are sent
35
+ status: Literal["approved", "rejected"]
36
+ amount: float
37
+ currency: str
38
+ customer_phone: Optional[str]
39
+ created_at: str
40
+ updated_at: str
41
+
42
+
43
+ class WebhookPayload(_WebhookPayloadFields, total=False):
44
+ # a hosted checkout's webhook names its session and echoes its metadata
45
+ checkout_session_id: str
46
+ metadata: Optional[Dict[str, Any]]
@@ -0,0 +1,22 @@
1
+ import hashlib
2
+ import hmac
3
+ from typing import Optional, Union
4
+
5
+ SIGNATURE_HEADER = "X-SebPay-Signature"
6
+
7
+
8
+ def verify_signature(raw_body: Union[bytes, str], signature: Optional[str], secret_key: str) -> bool:
9
+ """Verify the `X-SebPay-Signature` header Sebpay sends with every webhook
10
+ callback. Always pass the raw request body — re-serializing a parsed
11
+ payload (e.g. via json.dumps after parsing) can reorder keys and break
12
+ the signature. A missing signature returns False.
13
+ """
14
+ if not isinstance(signature, str) or not signature:
15
+ return False
16
+ if isinstance(raw_body, str):
17
+ raw_body = raw_body.encode("utf-8")
18
+ if not isinstance(raw_body, (bytes, bytearray)):
19
+ return False
20
+
21
+ expected = hmac.new(secret_key.encode("utf-8"), raw_body, hashlib.sha256).hexdigest()
22
+ return hmac.compare_digest(expected.encode("ascii"), signature.encode("utf-8"))
@@ -0,0 +1,143 @@
1
+ Metadata-Version: 2.4
2
+ Name: sebpay
3
+ Version: 0.2.0
4
+ Summary: Official Python SDK for Sebpay hosted checkout — create a checkout session, redirect your customer, confirm the payment and verify webhooks.
5
+ Author: Sebpay
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://sebpay.africa
8
+ Project-URL: Repository, https://github.com/mjims/sebpay-sdk-py
9
+ Project-URL: Issues, https://github.com/mjims/sebpay-sdk-py/issues
10
+ Keywords: sebpay,payment,payments,checkout,mobile-money,africa,webhook,sdk
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Software Development :: Libraries
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.9
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Requires-Dist: requests>=2.28
27
+ Dynamic: license-file
28
+
29
+ # sebpay-python
30
+
31
+ Official Python SDK for [Sebpay](https://sebpay.africa) **hosted checkout**. It does four things:
32
+ - create a checkout session;
33
+ - redirect your customer to the Sebpay payment page;
34
+ - confirm the payment when they come back;
35
+ - verify Sebpay's webhooks.
36
+
37
+ Its only dependency is `requests`. Direct collections, payouts and reference data are not part of the SDK. They remain available over the plain HTTP API.
38
+
39
+ ## Install
40
+
41
+ ```bash
42
+ pip install sebpay
43
+ ```
44
+
45
+ Requires Python 3.9 or later. The package ships its type hints (`py.typed`).
46
+
47
+ ## Usage
48
+
49
+ ### 1. Create a session and redirect your customer
50
+
51
+ ```python
52
+ import os
53
+ from sebpay import SebpayClient
54
+
55
+ # Keys from your dashboard (API développeur): pk_test_ keys go to the sandbox server, pk_live_ keys to live.
56
+ sebpay = SebpayClient(os.environ["SEBPAY_PUBLIC_KEY"], os.environ["SEBPAY_SECRET_KEY"])
57
+
58
+ session = sebpay.checkout_sessions.create(
59
+ amount=5000,
60
+ currency="XOF",
61
+ redirect_url="https://yoursite.com/orders/return",
62
+ external_reference=order.id, # your order id: sending it again returns the same session
63
+ callback_url="https://yoursite.com/webhook/sebpay",
64
+ metadata={"order_id": order.id},
65
+ )
66
+
67
+ return redirect(session["checkout_url"])
68
+ ```
69
+
70
+ ### 2. Confirm the payment when your customer comes back
71
+
72
+ Your `redirect_url` receives `?checkout_session_id=<id>`. Coming back proves nothing, so ask Sebpay before you deliver:
73
+
74
+ ```python
75
+ session = sebpay.checkout_sessions.retrieve(request.args["checkout_session_id"])
76
+
77
+ if session["transaction_status"] == "approved":
78
+ ... # Paid: deliver the order (session["external_reference"], session["metadata"]).
79
+ ```
80
+
81
+ `retrieve()` also accepts your `external_reference`.
82
+
83
+ ### 3. Verify the webhook
84
+
85
+ Sebpay POSTs the final status (`approved` or `rejected`) to your `callback_url`. For a checkout, the request also carries `checkout_session_id` and your `metadata`.
86
+
87
+ ```python
88
+ from sebpay import SIGNATURE_HEADER, verify_signature
89
+
90
+ # With Flask:
91
+ if not verify_signature(request.get_data(), request.headers.get(SIGNATURE_HEADER), os.environ["SEBPAY_SECRET_KEY"]):
92
+ return {"error": "Invalid signature"}, 401
93
+
94
+ payload = request.get_json() # payload["status"]: "approved" | "rejected"
95
+ ```
96
+
97
+ - Always pass the **raw** request body. Re-serializing a parsed payload can reorder keys and break the signature.
98
+ - A missing header returns `False`.
99
+
100
+ The shapes of the session and of the webhook payload are described as `TypedDict`s in `sebpay.types`, for editors and type checkers.
101
+
102
+ ## Options
103
+
104
+ ```python
105
+ SebpayClient(public_key, secret_key,
106
+ base_url="http://localhost:8000/api/v1", # only to test against a local API; otherwise chosen from the keys
107
+ timeout=10) # seconds, default 30
108
+ ```
109
+
110
+ `sebpay.base_url` tells which server the client talks to.
111
+
112
+ ## Errors
113
+
114
+ Every failed call raises a `SebpayApiError`, with three attributes:
115
+ - `status`: the HTTP status, or 0 when no response came back;
116
+ - `code`: the API's `errors.code`, or one of the SDK's own codes. Examples: `EXTERNAL_REFERENCE_CONFLICT`, `RATE_LIMITED`, `WRONG_ENVIRONMENT`, `NETWORK_ERROR`, `TIMEOUT`, `INVALID_RESPONSE`. It is `None` otherwise;
117
+ - `body`: the decoded response.
118
+
119
+ ```python
120
+ from sebpay import SebpayApiError
121
+
122
+ try:
123
+ sebpay.checkout_sessions.create(...)
124
+ except SebpayApiError as e:
125
+ if e.code == "EXTERNAL_REFERENCE_CONFLICT":
126
+ ... # This order id was already used with other parameters.
127
+ ```
128
+
129
+ ## Examples
130
+
131
+ See [`examples/`](https://github.com/mjims/sebpay-sdk-py/tree/main/examples). Set `SEBPAY_PUBLIC_KEY` and `SEBPAY_SECRET_KEY`, then:
132
+ - `python examples/create_checkout.py` creates a session and prints its `checkout_url`;
133
+ - `python examples/webhook_server.py` starts a Flask server (`pip install flask`) that handles the webhook and the return page, on `PORT` (default 3002).
134
+
135
+ For local testing, also set `SEBPAY_BASE_URL`.
136
+
137
+ ## Tests
138
+
139
+ `python -m unittest discover -s tests` runs the SDK's offline tests. They cover the transport, against a local HTTP server, and the shared signature vectors.
140
+
141
+ ## Requirements
142
+
143
+ Python 3.9+ and `requests`.
@@ -0,0 +1,19 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ sebpay/__init__.py
5
+ sebpay/_version.py
6
+ sebpay/client.py
7
+ sebpay/errors.py
8
+ sebpay/py.typed
9
+ sebpay/types.py
10
+ sebpay/webhooks.py
11
+ sebpay.egg-info/PKG-INFO
12
+ sebpay.egg-info/SOURCES.txt
13
+ sebpay.egg-info/dependency_links.txt
14
+ sebpay.egg-info/requires.txt
15
+ sebpay.egg-info/top_level.txt
16
+ sebpay/resources/__init__.py
17
+ sebpay/resources/checkout_sessions.py
18
+ tests/test_client.py
19
+ tests/test_webhooks.py
@@ -0,0 +1 @@
1
+ requests>=2.28
@@ -0,0 +1 @@
1
+ sebpay
sebpay-0.2.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,166 @@
1
+ import json
2
+ import re
3
+ import socket
4
+ import threading
5
+ import time
6
+ import unittest
7
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
8
+ from pathlib import Path
9
+
10
+ import sebpay
11
+ from sebpay import SebpayApiError, SebpayClient
12
+
13
+
14
+ def _json(handler, status, payload):
15
+ raw = json.dumps(payload).encode()
16
+ handler.send_response(status)
17
+ handler.send_header("Content-Type", "application/json")
18
+ handler.send_header("Content-Length", str(len(raw)))
19
+ handler.end_headers()
20
+ handler.wfile.write(raw)
21
+
22
+
23
+ def _text(handler, status, text):
24
+ raw = text.encode()
25
+ handler.send_response(status)
26
+ handler.send_header("Content-Type", "text/html")
27
+ handler.send_header("Content-Length", str(len(raw)))
28
+ handler.end_headers()
29
+ handler.wfile.write(raw)
30
+
31
+
32
+ class FakeApi(BaseHTTPRequestHandler):
33
+ """A local stand-in for the API: the first path segment picks the answer."""
34
+
35
+ last = None
36
+
37
+ def log_message(self, *args):
38
+ pass
39
+
40
+ def _handle(self):
41
+ length = int(self.headers.get("Content-Length") or 0)
42
+ raw = self.rfile.read(length).decode() if length else ""
43
+ FakeApi.last = {"method": self.command, "path": self.path, "headers": dict(self.headers), "raw": raw}
44
+ mode = self.path.split("/")[1]
45
+ if mode == "ok":
46
+ _json(self, 200, {"success": True, "data": {"echo": {"method": self.command, "url": self.path, "body": json.loads(raw) if raw else None}}})
47
+ elif mode == "conflict":
48
+ _json(self, 409, {"success": False, "message": "Already used", "errors": {"code": "EXTERNAL_REFERENCE_CONFLICT"}})
49
+ elif mode == "invalid":
50
+ _json(self, 422, {"success": False, "message": "Validation Error.", "errors": {"redirect_url": ["required"]}})
51
+ elif mode == "refused":
52
+ _json(self, 200, {"success": False, "message": "Refused"})
53
+ elif mode == "nodata":
54
+ _json(self, 200, {"success": True})
55
+ elif mode == "html":
56
+ _text(self, 200, "<html>maintenance</html>")
57
+ elif mode == "gateway":
58
+ _text(self, 502, "<html>Bad gateway</html>")
59
+ elif mode == "slow":
60
+ time.sleep(1.5)
61
+ _json(self, 200, {"success": True, "data": {}})
62
+
63
+ do_GET = _handle
64
+ do_POST = _handle
65
+
66
+
67
+ def _free_port():
68
+ with socket.socket() as s:
69
+ s.bind(("127.0.0.1", 0))
70
+ return s.getsockname()[1]
71
+
72
+
73
+ class ClientTest(unittest.TestCase):
74
+ @classmethod
75
+ def setUpClass(cls):
76
+ cls.server = ThreadingHTTPServer(("127.0.0.1", 0), FakeApi)
77
+ cls.server.daemon_threads = True
78
+ threading.Thread(target=cls.server.serve_forever, daemon=True).start()
79
+ cls.base = f"http://127.0.0.1:{cls.server.server_address[1]}"
80
+
81
+ @classmethod
82
+ def tearDownClass(cls):
83
+ cls.server.shutdown()
84
+ cls.server.server_close()
85
+
86
+ def client(self, mode, **options):
87
+ return SebpayClient("pk_test_pub", "sk_test_sec", base_url=f"{self.base}/{mode}", **options)
88
+
89
+ def test_base_url_follows_the_key_prefix(self):
90
+ self.assertEqual(SebpayClient("pk_test_x", "sk_test_x").base_url, "https://api.sandbox.sebpay.bj/api/v1")
91
+ self.assertEqual(SebpayClient("pk_live_x", "sk_live_x").base_url, "https://newapi.sebpay.africa/api/v1")
92
+ self.assertEqual(SebpayClient("pk_test_x", "sk_test_x", base_url="http://localhost:8000/api/v1/").base_url, "http://localhost:8000/api/v1")
93
+
94
+ def test_only_hosted_checkout_is_exposed(self):
95
+ client = self.client("ok")
96
+ self.assertTrue(callable(client.checkout_sessions.create) and callable(client.checkout_sessions.retrieve))
97
+ for gone in ("collections", "payouts", "references", "request"):
98
+ self.assertFalse(hasattr(client, gone), gone)
99
+ self.assertEqual(sorted(sebpay.__all__), ["SIGNATURE_HEADER", "SebpayApiError", "SebpayClient", "verify_signature"])
100
+
101
+ def test_version_matches_pyproject(self):
102
+ pyproject = (Path(__file__).resolve().parents[1] / "pyproject.toml").read_text(encoding="utf-8")
103
+ self.assertEqual(re.search(r'^version = "([^"]+)"', pyproject, re.M).group(1), sebpay.__version__)
104
+
105
+ def test_create_posts_json_with_the_keys_and_user_agent(self):
106
+ echo = self.client("ok").checkout_sessions.create(amount=5000, currency="XOF", redirect_url="https://shop.test/r", metadata={"a": 1})["echo"]
107
+ self.assertEqual(echo, {"method": "POST", "url": "/ok/checkout/sessions", "body": {"amount": 5000, "currency": "XOF", "redirect_url": "https://shop.test/r", "metadata": {"a": 1}}})
108
+ headers = FakeApi.last["headers"]
109
+ self.assertEqual(headers["X-Public-Key"], "pk_test_pub")
110
+ self.assertEqual(headers["X-Secret-Key"], "sk_test_sec")
111
+ self.assertEqual(headers["User-Agent"], f"sebpay-python/{sebpay.__version__}")
112
+ self.assertEqual(headers["Content-Type"], "application/json")
113
+ self.assertEqual(headers["Accept"], "application/json")
114
+
115
+ def test_retrieve_gets_the_encoded_id_without_body(self):
116
+ echo = self.client("ok").checkout_sessions.retrieve("A/B C?d")["echo"]
117
+ self.assertEqual(echo["url"], "/ok/checkout/sessions/A%2FB%20C%3Fd")
118
+ self.assertEqual(echo["method"], "GET")
119
+ self.assertEqual(FakeApi.last["raw"], "")
120
+
121
+ def test_api_error_carries_status_code_and_body(self):
122
+ with self.assertRaises(SebpayApiError) as ctx:
123
+ self.client("conflict").checkout_sessions.retrieve("x")
124
+ err = ctx.exception
125
+ self.assertEqual((err.status, err.code, str(err)), (409, "EXTERNAL_REFERENCE_CONFLICT", "Already used"))
126
+ self.assertEqual(err.body["errors"], {"code": "EXTERNAL_REFERENCE_CONFLICT"})
127
+
128
+ def test_error_without_code_has_code_none(self):
129
+ with self.assertRaises(SebpayApiError) as ctx:
130
+ self.client("invalid").checkout_sessions.retrieve("x")
131
+ self.assertEqual((ctx.exception.status, ctx.exception.code), (422, None))
132
+ self.assertEqual(ctx.exception.body["errors"]["redirect_url"], ["required"])
133
+
134
+ def test_success_false_is_an_error_even_with_200(self):
135
+ with self.assertRaises(SebpayApiError) as ctx:
136
+ self.client("refused").checkout_sessions.retrieve("x")
137
+ self.assertEqual((ctx.exception.status, str(ctx.exception)), (200, "Refused"))
138
+
139
+ def test_2xx_without_data_or_not_json_is_invalid_response(self):
140
+ for mode in ("nodata", "html"):
141
+ with self.subTest(mode), self.assertRaises(SebpayApiError) as ctx:
142
+ self.client(mode).checkout_sessions.retrieve("x")
143
+ self.assertEqual((ctx.exception.status, ctx.exception.code), (200, "INVALID_RESPONSE"))
144
+
145
+ def test_non_json_error_page_keeps_status_and_text(self):
146
+ with self.assertRaises(SebpayApiError) as ctx:
147
+ self.client("gateway").checkout_sessions.retrieve("x")
148
+ self.assertEqual((ctx.exception.status, ctx.exception.code), (502, None))
149
+ self.assertIn("Bad gateway", ctx.exception.body)
150
+
151
+ def test_no_server_is_network_error(self):
152
+ client = SebpayClient("pk_test_x", "sk_test_x", base_url=f"http://127.0.0.1:{_free_port()}")
153
+ with self.assertRaises(SebpayApiError) as ctx:
154
+ client.checkout_sessions.retrieve("x")
155
+ self.assertEqual((ctx.exception.status, ctx.exception.code), (0, "NETWORK_ERROR"))
156
+
157
+ def test_timeout_aborts_a_slow_request(self):
158
+ started = time.monotonic()
159
+ with self.assertRaises(SebpayApiError) as ctx:
160
+ self.client("slow", timeout=0.2).checkout_sessions.retrieve("x")
161
+ self.assertEqual((ctx.exception.status, ctx.exception.code), (0, "TIMEOUT"))
162
+ self.assertLess(time.monotonic() - started, 1.2)
163
+
164
+
165
+ if __name__ == "__main__":
166
+ unittest.main()
@@ -0,0 +1,28 @@
1
+ import json
2
+ import unittest
3
+ from pathlib import Path
4
+
5
+ from sebpay import SIGNATURE_HEADER, verify_signature
6
+
7
+ VECTORS = json.loads((Path(__file__).resolve().parents[2] / "test-vectors" / "webhooks.json").read_text(encoding="utf-8"))
8
+
9
+
10
+ class SignatureVectors(unittest.TestCase):
11
+ def test_vectors(self):
12
+ for case in VECTORS["cases"]:
13
+ for body in (case["body"].encode("utf-8"), case["body"]):
14
+ with self.subTest(case["name"], body_type=type(body).__name__):
15
+ self.assertIs(verify_signature(body, case["signature"], VECTORS["secret_key"]), case["valid"])
16
+
17
+ def test_missing_or_odd_inputs_return_false_without_raising(self):
18
+ ok = VECTORS["cases"][0]
19
+ self.assertIs(verify_signature(ok["body"].encode(), None, VECTORS["secret_key"]), False)
20
+ self.assertIs(verify_signature(ok["body"].encode(), "é" * 64, VECTORS["secret_key"]), False)
21
+ self.assertIs(verify_signature(None, ok["signature"], VECTORS["secret_key"]), False)
22
+
23
+ def test_signature_header(self):
24
+ self.assertEqual(SIGNATURE_HEADER, "X-SebPay-Signature")
25
+
26
+
27
+ if __name__ == "__main__":
28
+ unittest.main()