uverify 0.1.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.
@@ -0,0 +1,14 @@
1
+ node_modules/
2
+ dist/
3
+ *.tgz
4
+ __pycache__/
5
+ *.egg-info/
6
+ build/
7
+ .venv/
8
+ vendor/
9
+ composer.lock
10
+ .phpunit.result.cache
11
+ .dart_tool/
12
+ .packages
13
+ pubspec.lock
14
+ .DS_Store
uverify-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,118 @@
1
+ Metadata-Version: 2.5
2
+ Name: uverify
3
+ Version: 0.1.0
4
+ Summary: Official Python library for the UVerify API: BVN, NIN and ID checks, liveness, face match, ID documents and AML screening for Nigeria.
5
+ Project-URL: Homepage, https://uverify.com.ng
6
+ Project-URL: Documentation, https://uverify.com.ng/docs
7
+ Project-URL: Source, https://github.com/elastodev/uverify-sdks
8
+ Author: Elasto Web Services Limited
9
+ License: MIT
10
+ Keywords: aml,bvn,face-match,kyc,liveness,nigeria,nin,uverify
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Typing :: Typed
15
+ Requires-Python: >=3.8
16
+ Description-Content-Type: text/markdown
17
+
18
+ # UVerify for Python
19
+
20
+ The official Python library for [UVerify](https://uverify.com.ng): BVN, NIN, driver’s licence and voter’s card checks, liveness and face match, ID documents, AML screening and CAC, for Nigerian businesses.
21
+
22
+ ```bash
23
+ pip install uverify
24
+ ```
25
+
26
+ Python 3.8 or later. No dependencies.
27
+
28
+ ## Quick start
29
+
30
+ ```python
31
+ from uverify import UVerify
32
+
33
+ uverify = UVerify("uvk_test_...") # or set UVERIFY_API_KEY; uvk_test_ is the free sandbox
34
+
35
+ v = uverify.identity.nin(id_number="12345678901", first_name="Adaeze", last_name="Okafor")
36
+ if v["status"] == "verified":
37
+ print(v["data"], v["field_matches"])
38
+ ```
39
+
40
+ In the sandbox the last two digits of the ID number choose the result: `00` not found, `99` registry error, `98` face mismatch, anything else verified.
41
+
42
+ ## A live customer, end to end
43
+
44
+ ```python
45
+ session = uverify.liveness.create_session(redirect_url="https://yourapp.com/kyc/done")
46
+ # send the person to session["url"]; when they come back:
47
+ v = uverify.identity.bvn_face_match(
48
+ id_number="22212345678", first_name="Adaeze", last_name="Okafor",
49
+ liveness_session_id=session["id"],
50
+ aml_screening=True,
51
+ )
52
+ if v["status"] == "verified" and v["face_match"]["status"] == "matched" and v["aml_screening"]["status"] == "clear":
53
+ ... # approve
54
+ ```
55
+
56
+ ## Everything else
57
+
58
+ ```python
59
+ uverify.identity.bvn(id_number=..., first_name=..., last_name=...) # also drivers_license, voters_card, tin
60
+ uverify.identity.nin_face_match(id_number=..., selfie_image=open("me.jpg", "rb").read())
61
+ uverify.business.cac(id_number="RC123456", aml_screening=True)
62
+
63
+ uverify.documents.verify(front_image=open("nin.jpg", "rb").read(), document_type="nin_card")
64
+ uverify.aml.screen(name="Adaeze Okafor", date_of_birth="1990-01-15")
65
+ uverify.aml.monitors.create(name="Adaeze Okafor")
66
+ link = uverify.kyc.create_link(customer_name="Ada", require_document=True) # send link["url"]
67
+
68
+ uverify.verifications.list(status="verified")
69
+ uverify.account.balance()
70
+ ```
71
+
72
+ Responses are plain dicts, exactly as in the [API reference](https://uverify.com.ng/docs).
73
+
74
+ ## Errors
75
+
76
+ ```python
77
+ from uverify import UVerifyError
78
+
79
+ try:
80
+ uverify.identity.nin(id_number=id_number)
81
+ except UVerifyError as e:
82
+ if e.code == "insufficient_balance":
83
+ ...
84
+ print(e.code, e.message, e.request_id, e.details)
85
+ ```
86
+
87
+ A check that finds nothing isn’t an error: it returns `status: "not_found"` (and you aren’t charged).
88
+
89
+ ## Retries
90
+
91
+ Timeouts, connection errors, 5xx and rate limits are retried twice with backoff (`max_retries`, `timeout`). Every check carries a `reference` (generated if you don’t pass one), so a retry never charges twice, and if the first attempt went through you get that result.
92
+
93
+ ## Webhooks
94
+
95
+ ```python
96
+ from uverify import construct_event, UVerifySignatureError
97
+
98
+ @app.post("/webhooks/uverify") # Flask/FastAPI/Django: use the raw body
99
+ def hook():
100
+ try:
101
+ event = construct_event(request.get_data(), request.headers.get("UVerify-Signature"), WEBHOOK_SECRET)
102
+ except UVerifySignatureError:
103
+ return "", 400
104
+ if event["type"] == "verification.completed":
105
+ ...
106
+ return "", 200
107
+ ```
108
+
109
+ Deliveries can repeat: dedupe on `event["id"]`.
110
+
111
+ ## Tests
112
+
113
+ ```bash
114
+ python -m unittest discover -s tests # offline
115
+ UVERIFY_TEST_KEY=uvk_test_... python -m unittest tests.test_sandbox
116
+ ```
117
+
118
+ MIT licence.
@@ -0,0 +1,101 @@
1
+ # UVerify for Python
2
+
3
+ The official Python library for [UVerify](https://uverify.com.ng): BVN, NIN, driver’s licence and voter’s card checks, liveness and face match, ID documents, AML screening and CAC, for Nigerian businesses.
4
+
5
+ ```bash
6
+ pip install uverify
7
+ ```
8
+
9
+ Python 3.8 or later. No dependencies.
10
+
11
+ ## Quick start
12
+
13
+ ```python
14
+ from uverify import UVerify
15
+
16
+ uverify = UVerify("uvk_test_...") # or set UVERIFY_API_KEY; uvk_test_ is the free sandbox
17
+
18
+ v = uverify.identity.nin(id_number="12345678901", first_name="Adaeze", last_name="Okafor")
19
+ if v["status"] == "verified":
20
+ print(v["data"], v["field_matches"])
21
+ ```
22
+
23
+ In the sandbox the last two digits of the ID number choose the result: `00` not found, `99` registry error, `98` face mismatch, anything else verified.
24
+
25
+ ## A live customer, end to end
26
+
27
+ ```python
28
+ session = uverify.liveness.create_session(redirect_url="https://yourapp.com/kyc/done")
29
+ # send the person to session["url"]; when they come back:
30
+ v = uverify.identity.bvn_face_match(
31
+ id_number="22212345678", first_name="Adaeze", last_name="Okafor",
32
+ liveness_session_id=session["id"],
33
+ aml_screening=True,
34
+ )
35
+ if v["status"] == "verified" and v["face_match"]["status"] == "matched" and v["aml_screening"]["status"] == "clear":
36
+ ... # approve
37
+ ```
38
+
39
+ ## Everything else
40
+
41
+ ```python
42
+ uverify.identity.bvn(id_number=..., first_name=..., last_name=...) # also drivers_license, voters_card, tin
43
+ uverify.identity.nin_face_match(id_number=..., selfie_image=open("me.jpg", "rb").read())
44
+ uverify.business.cac(id_number="RC123456", aml_screening=True)
45
+
46
+ uverify.documents.verify(front_image=open("nin.jpg", "rb").read(), document_type="nin_card")
47
+ uverify.aml.screen(name="Adaeze Okafor", date_of_birth="1990-01-15")
48
+ uverify.aml.monitors.create(name="Adaeze Okafor")
49
+ link = uverify.kyc.create_link(customer_name="Ada", require_document=True) # send link["url"]
50
+
51
+ uverify.verifications.list(status="verified")
52
+ uverify.account.balance()
53
+ ```
54
+
55
+ Responses are plain dicts, exactly as in the [API reference](https://uverify.com.ng/docs).
56
+
57
+ ## Errors
58
+
59
+ ```python
60
+ from uverify import UVerifyError
61
+
62
+ try:
63
+ uverify.identity.nin(id_number=id_number)
64
+ except UVerifyError as e:
65
+ if e.code == "insufficient_balance":
66
+ ...
67
+ print(e.code, e.message, e.request_id, e.details)
68
+ ```
69
+
70
+ A check that finds nothing isn’t an error: it returns `status: "not_found"` (and you aren’t charged).
71
+
72
+ ## Retries
73
+
74
+ Timeouts, connection errors, 5xx and rate limits are retried twice with backoff (`max_retries`, `timeout`). Every check carries a `reference` (generated if you don’t pass one), so a retry never charges twice, and if the first attempt went through you get that result.
75
+
76
+ ## Webhooks
77
+
78
+ ```python
79
+ from uverify import construct_event, UVerifySignatureError
80
+
81
+ @app.post("/webhooks/uverify") # Flask/FastAPI/Django: use the raw body
82
+ def hook():
83
+ try:
84
+ event = construct_event(request.get_data(), request.headers.get("UVerify-Signature"), WEBHOOK_SECRET)
85
+ except UVerifySignatureError:
86
+ return "", 400
87
+ if event["type"] == "verification.completed":
88
+ ...
89
+ return "", 200
90
+ ```
91
+
92
+ Deliveries can repeat: dedupe on `event["id"]`.
93
+
94
+ ## Tests
95
+
96
+ ```bash
97
+ python -m unittest discover -s tests # offline
98
+ UVERIFY_TEST_KEY=uvk_test_... python -m unittest tests.test_sandbox
99
+ ```
100
+
101
+ MIT licence.
@@ -0,0 +1,28 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.18"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "uverify"
7
+ version = "0.1.0"
8
+ description = "Official Python library for the UVerify API: BVN, NIN and ID checks, liveness, face match, ID documents and AML screening for Nigeria."
9
+ readme = "README.md"
10
+ requires-python = ">=3.8"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Elasto Web Services Limited" }]
13
+ keywords = ["uverify", "kyc", "bvn", "nin", "liveness", "face-match", "aml", "nigeria"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "License :: OSI Approved :: MIT License",
17
+ "Operating System :: OS Independent",
18
+ "Typing :: Typed",
19
+ ]
20
+ dependencies = []
21
+
22
+ [project.urls]
23
+ Homepage = "https://uverify.com.ng"
24
+ Documentation = "https://uverify.com.ng/docs"
25
+ Source = "https://github.com/elastodev/uverify-sdks"
26
+
27
+ [tool.hatch.build.targets.wheel]
28
+ packages = ["src/uverify"]
@@ -0,0 +1,8 @@
1
+ """Official Python library for the UVerify API (https://uverify.com.ng/docs)."""
2
+
3
+ from .client import VERSION, UVerify
4
+ from .errors import UVerifyConnectionError, UVerifyError, UVerifySignatureError
5
+ from .webhooks import construct_event, sign_payload
6
+
7
+ __version__ = VERSION
8
+ __all__ = ["UVerify", "UVerifyError", "UVerifyConnectionError", "UVerifySignatureError", "construct_event", "sign_payload", "__version__"]
@@ -0,0 +1,307 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import os
6
+ import platform
7
+ import random
8
+ import secrets
9
+ import time
10
+ import urllib.error
11
+ import urllib.parse
12
+ import urllib.request
13
+ from typing import Any, Callable, Dict, Optional, Tuple, Union
14
+
15
+ from . import webhooks as _webhooks
16
+ from .errors import UVerifyConnectionError, UVerifyError
17
+
18
+ VERSION = "0.1.0"
19
+ DEFAULT_BASE_URL = "https://api.uverify.com.ng/v1"
20
+ IMAGE_FIELDS = ("selfie_image", "front_image", "back_image")
21
+
22
+ #: (method, url, headers, body, timeout seconds) -> (status, headers, body bytes)
23
+ Transport = Callable[[str, str, Dict[str, str], Optional[bytes], float], Tuple[int, Dict[str, str], bytes]]
24
+
25
+ ImageInput = Union[bytes, bytearray, str]
26
+ Json = Dict[str, Any]
27
+
28
+
29
+ def _urllib_transport(method: str, url: str, headers: Dict[str, str], body: Optional[bytes], timeout: float) -> Tuple[int, Dict[str, str], bytes]:
30
+ req = urllib.request.Request(url, data=body, method=method, headers=headers)
31
+ try:
32
+ with urllib.request.urlopen(req, timeout=timeout) as r:
33
+ return r.status, {k.lower(): v for k, v in r.headers.items()}, r.read()
34
+ except urllib.error.HTTPError as e: # 4xx/5xx still carry a JSON body
35
+ with e:
36
+ return e.code, {k.lower(): v for k, v in (e.headers or {}).items()}, e.read()
37
+
38
+
39
+ def _new_reference(prefix: str) -> str:
40
+ return f"{prefix}_{secrets.token_urlsafe(9)}"
41
+
42
+
43
+ def _b64(v: ImageInput) -> str:
44
+ return v if isinstance(v, str) else base64.b64encode(bytes(v)).decode()
45
+
46
+
47
+ def _backoff(attempt: int) -> float:
48
+ return min(8.0, 0.5 * 2**attempt) * (0.75 + random.random() * 0.5)
49
+
50
+
51
+ class _DuplicateOnRetry(Exception):
52
+ def __init__(self, err: UVerifyError) -> None:
53
+ self.err = err
54
+
55
+
56
+ class UVerify:
57
+ """The UVerify API client.
58
+
59
+ >>> from uverify import UVerify
60
+ >>> uverify = UVerify(api_key="uvk_test_...")
61
+ >>> v = uverify.identity.nin(id_number="12345678901")
62
+ >>> v["status"]
63
+ 'verified'
64
+ """
65
+
66
+ webhooks = _webhooks
67
+
68
+ def __init__(
69
+ self,
70
+ api_key: Optional[str] = None,
71
+ *,
72
+ base_url: Optional[str] = None,
73
+ timeout: float = 60.0,
74
+ max_retries: int = 2,
75
+ transport: Optional[Transport] = None,
76
+ ) -> None:
77
+ key = api_key or os.environ.get("UVERIFY_API_KEY", "")
78
+ if not key:
79
+ raise ValueError("UVerify: pass api_key (uvk_test_... or uvk_live_...) or set UVERIFY_API_KEY.")
80
+ self._key = key
81
+ self._base = (base_url or os.environ.get("UVERIFY_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
82
+ self._timeout = timeout
83
+ self._max_retries = max_retries
84
+ self._transport = transport or _urllib_transport
85
+ self.identity = _Identity(self)
86
+ self.business = _Business(self)
87
+ self.verifications = _Verifications(self)
88
+ self.liveness = _Liveness(self)
89
+ self.documents = _Documents(self)
90
+ self.aml = _Aml(self)
91
+ self.kyc = _Kyc(self)
92
+ self.account = _Account(self)
93
+
94
+ @property
95
+ def environment(self) -> str:
96
+ """``"test"`` for a sandbox key, ``"live"`` for a live one."""
97
+ return "live" if "_live_" in self._key else "test"
98
+
99
+ # ── transport ──────────────────────────────────────────────────────────
100
+
101
+ def _request(self, method: str, path: str, *, query: Optional[Json] = None, body: Optional[Json] = None, retryable: Optional[bool] = None) -> Any:
102
+ url = self._base + path
103
+ q = {k: ("true" if v is True else "false" if v is False else v) for k, v in (query or {}).items() if v is not None}
104
+ if q:
105
+ url += "?" + urllib.parse.urlencode(q)
106
+ data = json.dumps({k: v for k, v in body.items() if v is not None}).encode() if body is not None else None
107
+ headers = {
108
+ "authorization": f"Bearer {self._key}",
109
+ "accept": "application/json",
110
+ "user-agent": f"uverify-python/{VERSION} python/{platform.python_version()}",
111
+ }
112
+ if data is not None:
113
+ headers["content-type"] = "application/json"
114
+ can_retry = (method == "GET") if retryable is None else retryable
115
+
116
+ attempt = 0
117
+ while True:
118
+ try:
119
+ status, rh, raw = self._transport(method, url, headers, data, self._timeout)
120
+ except Exception as e: # timeout, DNS, reset
121
+ if can_retry and attempt < self._max_retries:
122
+ time.sleep(_backoff(attempt))
123
+ attempt += 1
124
+ continue
125
+ raise UVerifyConnectionError(f"Couldn't reach UVerify: {e}") from e
126
+
127
+ try:
128
+ payload = json.loads(raw.decode() or "null")
129
+ except ValueError:
130
+ payload = None
131
+ if 200 <= status < 300 and not (isinstance(payload, dict) and payload.get("success") is False):
132
+ return payload.get("data", payload) if isinstance(payload, dict) else payload
133
+
134
+ e_obj = (payload or {}).get("error") or {} if isinstance(payload, dict) else {}
135
+ err = UVerifyError(
136
+ e_obj.get("message") or f"UVerify returned HTTP {status}.",
137
+ code=e_obj.get("code") or ("internal_error" if status >= 500 else "http_error"),
138
+ status=status,
139
+ request_id=(payload or {}).get("request_id") if isinstance(payload, dict) else rh.get("x-request-id"),
140
+ details=e_obj.get("details"),
141
+ )
142
+ rate_limited = status == 429 and err.code == "rate_limited"
143
+ if can_retry and attempt < self._max_retries and (rate_limited or (status >= 500 and status != 501)):
144
+ after = (err.details or {}).get("retry_after_seconds") if isinstance(err.details, dict) else None
145
+ time.sleep(min(float(after), 60.0) if rate_limited and after else _backoff(attempt))
146
+ attempt += 1
147
+ continue
148
+ if attempt > 0 and err.code == "duplicate_reference":
149
+ raise _DuplicateOnRetry(err)
150
+ raise err
151
+
152
+ def _prepare(self, params: Json, prefix: str) -> Json:
153
+ body = dict(params)
154
+ body.setdefault("reference", None)
155
+ if not body["reference"]:
156
+ body["reference"] = _new_reference(prefix)
157
+ for f in IMAGE_FIELDS:
158
+ if body.get(f) is not None:
159
+ body[f] = _b64(body[f])
160
+ return body
161
+
162
+ def _check(self, path: str, params: Json) -> Json:
163
+ """An identity/business check: always carries a reference, so a retry never charges twice."""
164
+ body = self._prepare(params, "sdk")
165
+ try:
166
+ return self._request("POST", path, body=body, retryable=True)
167
+ except _DuplicateOnRetry as d:
168
+ # The first attempt went through but its answer was lost: return that result.
169
+ found = self.verifications.list(reference=body["reference"], per_page=1)
170
+ if found["items"]:
171
+ return self.verifications.get(found["items"][0]["id"])
172
+ raise d.err from None
173
+
174
+ def _create(self, path: str, params: Json, prefix: str) -> Json:
175
+ try:
176
+ return self._request("POST", path, body=self._prepare(params, prefix), retryable=True)
177
+ except _DuplicateOnRetry as d:
178
+ raise d.err from None
179
+
180
+
181
+ def _q(v: str) -> str:
182
+ return urllib.parse.quote(v, safe="")
183
+
184
+
185
+ class _Resource:
186
+ def __init__(self, client: UVerify) -> None:
187
+ self._c = client
188
+
189
+
190
+ class _Identity(_Resource):
191
+ def bvn(self, *, id_number: str, first_name: str, last_name: str, **kw: Any) -> Json:
192
+ """BVN lookup. Names are required by the registry. Also: dob, include_photo, reference, aml_screening, aml_monitoring."""
193
+ return self._c._check("/identity/bvn", {"id_number": id_number, "first_name": first_name, "last_name": last_name, **kw})
194
+
195
+ def bvn_face_match(self, *, id_number: str, first_name: str, last_name: str, liveness_session_id: Optional[str] = None, selfie_image: Optional[ImageInput] = None, **kw: Any) -> Json:
196
+ """BVN lookup + face match. Send liveness_session_id (recommended) or selfie_image (bytes or base64)."""
197
+ return self._c._check("/identity/bvn/face-match", {"id_number": id_number, "first_name": first_name, "last_name": last_name, "liveness_session_id": liveness_session_id, "selfie_image": selfie_image, **kw})
198
+
199
+ def nin(self, *, id_number: str, **kw: Any) -> Json:
200
+ return self._c._check("/identity/nin", {"id_number": id_number, **kw})
201
+
202
+ def nin_face_match(self, *, id_number: str, liveness_session_id: Optional[str] = None, selfie_image: Optional[ImageInput] = None, **kw: Any) -> Json:
203
+ return self._c._check("/identity/nin/face-match", {"id_number": id_number, "liveness_session_id": liveness_session_id, "selfie_image": selfie_image, **kw})
204
+
205
+ def drivers_license(self, *, id_number: str, first_name: str, last_name: str, **kw: Any) -> Json:
206
+ return self._c._check("/identity/drivers-license", {"id_number": id_number, "first_name": first_name, "last_name": last_name, **kw})
207
+
208
+ def drivers_license_face_match(self, *, id_number: str, first_name: str, last_name: str, liveness_session_id: Optional[str] = None, selfie_image: Optional[ImageInput] = None, **kw: Any) -> Json:
209
+ return self._c._check("/identity/drivers-license/face-match", {"id_number": id_number, "first_name": first_name, "last_name": last_name, "liveness_session_id": liveness_session_id, "selfie_image": selfie_image, **kw})
210
+
211
+ def voters_card(self, *, id_number: str, first_name: str, last_name: str, **kw: Any) -> Json:
212
+ return self._c._check("/identity/voters-card", {"id_number": id_number, "first_name": first_name, "last_name": last_name, **kw})
213
+
214
+ def voters_card_face_match(self, *, id_number: str, first_name: str, last_name: str, liveness_session_id: Optional[str] = None, selfie_image: Optional[ImageInput] = None, **kw: Any) -> Json:
215
+ return self._c._check("/identity/voters-card/face-match", {"id_number": id_number, "first_name": first_name, "last_name": last_name, "liveness_session_id": liveness_session_id, "selfie_image": selfie_image, **kw})
216
+
217
+ def tin(self, *, id_number: str, reference: Optional[str] = None) -> Json:
218
+ return self._c._check("/identity/tin", {"id_number": id_number, "reference": reference})
219
+
220
+
221
+ class _Business(_Resource):
222
+ def cac(self, *, id_number: str, **kw: Any) -> Json:
223
+ """CAC lookup (RC/BN/IT number). aml_screening=True screens the company and each director."""
224
+ return self._c._check("/business/cac", {"id_number": id_number, **kw})
225
+
226
+
227
+ class _Verifications(_Resource):
228
+ def list(self, **params: Any) -> Json:
229
+ """Filters: type, service, status, reference, page, per_page."""
230
+ return self._c._request("GET", "/verifications", query=params)
231
+
232
+ def get(self, id: str) -> Json:
233
+ """One verification, with its record."""
234
+ return self._c._request("GET", f"/verifications/{_q(id)}")
235
+
236
+
237
+ class _Liveness(_Resource):
238
+ def create_session(self, **params: Any) -> Json:
239
+ """A hosted camera check: send the person to session["url"], then pass session["id"] to a face match."""
240
+ return self._c._create("/liveness/sessions", params, "lv")
241
+
242
+ def get_session(self, id: str) -> Json:
243
+ return self._c._request("GET", f"/liveness/sessions/{_q(id)}")
244
+
245
+ def simulate(self, id: str, *, outcome: str, face: Optional[str] = None) -> Json:
246
+ """Sandbox only: finish a session without a camera (passed, failed or expired)."""
247
+ return self._c._request("POST", f"/liveness/sessions/{_q(id)}/simulate", body={"outcome": outcome, "face": face})
248
+
249
+
250
+ class _Documents(_Resource):
251
+ def verify(self, *, front_image: ImageInput, **params: Any) -> Json:
252
+ """Read and check a photo of an ID. Images: bytes or base64. Also: back_image, document_type, liveness_session_id, selfie_image, verify_with_registry, reference."""
253
+ return self._c._create("/documents/verify", {"front_image": front_image, **params}, "doc")
254
+
255
+ def get(self, id: str) -> Json:
256
+ return self._c._request("GET", f"/documents/{_q(id)}")
257
+
258
+
259
+ class _Monitors(_Resource):
260
+ def create(self, *, name: str, **params: Any) -> Json:
261
+ """Screen a name now and keep watching it (billed monthly)."""
262
+ return self._c._create("/aml/monitors", {"name": name, **params}, "amon")
263
+
264
+ def list(self, **params: Any) -> Json:
265
+ return self._c._request("GET", "/aml/monitors", query=params)
266
+
267
+ def get(self, id: str) -> Json:
268
+ return self._c._request("GET", f"/aml/monitors/{_q(id)}")
269
+
270
+ def stop(self, id: str) -> Json:
271
+ return self._c._request("DELETE", f"/aml/monitors/{_q(id)}", retryable=True)
272
+
273
+
274
+ class _Aml(_Resource):
275
+ def __init__(self, client: UVerify) -> None:
276
+ super().__init__(client)
277
+ self.monitors = _Monitors(client)
278
+
279
+ def screen(self, *, name: str, **params: Any) -> Json:
280
+ """Screen a person or organisation (entity_type="entity") against the UN, OFAC, UK, EU and Nigeria lists."""
281
+ return self._c._create("/aml/screen", {"name": name, **params}, "aml")
282
+
283
+ def get_screening(self, id: str) -> Json:
284
+ return self._c._request("GET", f"/aml/screenings/{_q(id)}")
285
+
286
+ def lists(self) -> Any:
287
+ return self._c._request("GET", "/aml/lists")
288
+
289
+
290
+ class _Kyc(_Resource):
291
+ def create_link(self, **params: Any) -> Json:
292
+ """A hosted verification link: send link["url"] to your customer."""
293
+ return self._c._create("/kyc/requests", params, "kyc")
294
+
295
+ def get_link(self, id: str) -> Json:
296
+ return self._c._request("GET", f"/kyc/requests/{_q(id)}")
297
+
298
+ def list_links(self, **params: Any) -> Json:
299
+ return self._c._request("GET", "/kyc/requests", query=params)
300
+
301
+
302
+ class _Account(_Resource):
303
+ def balance(self) -> Json:
304
+ return self._c._request("GET", "/balance")
305
+
306
+ def pricing(self) -> Any:
307
+ return self._c._request("GET", "/pricing")
@@ -0,0 +1,33 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Optional
4
+
5
+
6
+ class UVerifyError(Exception):
7
+ """Every API error. Branch on ``code``: it's stable (https://uverify.com.ng/docs#responses)."""
8
+
9
+ def __init__(self, message: str, *, code: str, status: int, request_id: Optional[str] = None, details: Any = None) -> None:
10
+ super().__init__(message)
11
+ self.message = message
12
+ #: e.g. validation_error, insufficient_balance, duplicate_reference, rate_limited
13
+ self.code = code
14
+ #: HTTP status (0 when no response arrived)
15
+ self.status = status
16
+ #: Quote this to UVerify support to trace the request
17
+ self.request_id = request_id
18
+ #: Field errors for validation_error; retry_after_seconds for rate_limited; ...
19
+ self.details = details
20
+
21
+ def __repr__(self) -> str:
22
+ return f"UVerifyError(code={self.code!r}, status={self.status}, message={self.message!r}, request_id={self.request_id!r})"
23
+
24
+
25
+ class UVerifyConnectionError(UVerifyError):
26
+ """The request didn't complete (timeout, DNS, connection reset). Safe to retry with the same reference."""
27
+
28
+ def __init__(self, message: str) -> None:
29
+ super().__init__(message, code="connection_error", status=0)
30
+
31
+
32
+ class UVerifySignatureError(Exception):
33
+ """A webhook failed signature or timestamp verification. Don't trust its body."""
File without changes
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import hmac
5
+ import json
6
+ import time
7
+ from typing import Any, Dict, Optional, Union
8
+
9
+ from .errors import UVerifySignatureError
10
+
11
+
12
+ def construct_event(raw_body: Union[bytes, str], signature_header: Optional[str], secret: str, tolerance_seconds: int = 300) -> Dict[str, Any]:
13
+ """Verify a webhook and return its event (a dict: id, type, created_at, environment, data).
14
+
15
+ Pass the raw request body exactly as received, the ``UVerify-Signature``
16
+ header and your endpoint's signing secret (``whsec_...``). Raises
17
+ :class:`UVerifySignatureError` if the signature doesn't match or the event
18
+ is older than ``tolerance_seconds`` (a replay). Deliveries can repeat:
19
+ dedupe on ``event["id"]``.
20
+ """
21
+ if not signature_header:
22
+ raise UVerifySignatureError("Missing UVerify-Signature header.")
23
+ if not secret:
24
+ raise UVerifySignatureError("Missing webhook secret.")
25
+ parts = dict(p.strip().split("=", 1) for p in signature_header.split(",") if "=" in p)
26
+ t, v1 = parts.get("t"), parts.get("v1")
27
+ if not t or not v1 or not t.isdigit():
28
+ raise UVerifySignatureError("Malformed UVerify-Signature header.")
29
+ body = raw_body.decode("utf-8") if isinstance(raw_body, (bytes, bytearray)) else raw_body
30
+ expected = hmac.new(secret.encode(), f"{t}.{body}".encode(), hashlib.sha256).hexdigest()
31
+ if not hmac.compare_digest(expected, v1):
32
+ raise UVerifySignatureError("Signature does not match. Check the secret and that you passed the raw body.")
33
+ if tolerance_seconds > 0 and abs(time.time() - int(t)) > tolerance_seconds:
34
+ raise UVerifySignatureError("Signature is too old (possible replay).")
35
+ try:
36
+ return json.loads(body)
37
+ except ValueError as e:
38
+ raise UVerifySignatureError("Body is not JSON.") from e
39
+
40
+
41
+ def sign_payload(body: str, secret: str, timestamp: Optional[int] = None) -> str:
42
+ """Sign a payload the way UVerify does, for your own tests."""
43
+ t = int(time.time()) if timestamp is None else timestamp
44
+ return f"t={t},v1={hmac.new(secret.encode(), f'{t}.{body}'.encode(), hashlib.sha256).hexdigest()}"
@@ -0,0 +1,187 @@
1
+ import base64
2
+ import json
3
+ import os
4
+ import sys
5
+ import time
6
+ import unittest
7
+ from pathlib import Path
8
+
9
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
10
+
11
+ from uverify import UVerify, UVerifyConnectionError, UVerifyError, UVerifySignatureError, construct_event, sign_payload # noqa: E402
12
+ from uverify import client as client_mod # noqa: E402
13
+
14
+ client_mod._backoff = lambda attempt: 0.0 # no real waiting in tests
15
+
16
+
17
+ def ok(data, status=200):
18
+ return status, {"content-type": "application/json"}, json.dumps({"success": True, "data": data, "request_id": "req_1"}).encode()
19
+
20
+
21
+ def fail(status, code, message="nope", details=None):
22
+ return status, {}, json.dumps({"success": False, "error": {"code": code, "message": message, "details": details}, "request_id": "req_err"}).encode()
23
+
24
+
25
+ class Fake:
26
+ """A transport that answers from handlers in order, recording each call."""
27
+
28
+ def __init__(self, *handlers):
29
+ self.handlers = handlers
30
+ self.calls = []
31
+
32
+ def __call__(self, method, url, headers, body, timeout):
33
+ call = {"method": method, "url": url, "headers": headers, "body": json.loads(body) if body else None}
34
+ self.calls.append(call)
35
+ h = self.handlers[min(len(self.calls) - 1, len(self.handlers) - 1)]
36
+ r = h(call)
37
+ if r == "network-error":
38
+ raise OSError("connection reset")
39
+ return r
40
+
41
+
42
+ def client(t, **kw):
43
+ return UVerify("uvk_test_abc", base_url="https://api.test/v1", transport=t, **kw)
44
+
45
+
46
+ class Requests(unittest.TestCase):
47
+ def test_auth_reference_and_unwrap(self):
48
+ t = Fake(lambda c: ok({"id": "v1", "status": "verified"}))
49
+ v = client(t).identity.nin(id_number="12345678901")
50
+ self.assertEqual(v, {"id": "v1", "status": "verified"})
51
+ self.assertEqual(t.calls[0]["url"], "https://api.test/v1/identity/nin")
52
+ self.assertEqual(t.calls[0]["headers"]["authorization"], "Bearer uvk_test_abc")
53
+ self.assertTrue(t.calls[0]["headers"]["user-agent"].startswith("uverify-python/"))
54
+ self.assertTrue(t.calls[0]["body"]["reference"].startswith("sdk_"))
55
+
56
+ def test_own_reference_and_no_nones(self):
57
+ t = Fake(lambda c: ok({}))
58
+ client(t).identity.bvn(id_number="22212345678", first_name="Ada", last_name="Okafor", reference="mine-1", dob=None)
59
+ self.assertEqual(t.calls[0]["body"], {"id_number": "22212345678", "first_name": "Ada", "last_name": "Okafor", "reference": "mine-1"})
60
+
61
+ def test_bytes_images_become_base64(self):
62
+ t = Fake(lambda c: ok({}))
63
+ client(t).identity.nin_face_match(id_number="1", selfie_image=b"jpeg-bytes")
64
+ client(t).documents.verify(front_image=bytes([1, 2, 3]), back_image="already-base64")
65
+ self.assertEqual(t.calls[0]["body"]["selfie_image"], base64.b64encode(b"jpeg-bytes").decode())
66
+ self.assertEqual(t.calls[1]["body"]["front_image"], "AQID")
67
+ self.assertEqual(t.calls[1]["body"]["back_image"], "already-base64")
68
+
69
+ def test_list_query(self):
70
+ t = Fake(lambda c: ok({"items": [], "pagination": {}}))
71
+ client(t).verifications.list(status="verified", page=2)
72
+ self.assertEqual(t.calls[0]["url"], "https://api.test/v1/verifications?status=verified&page=2")
73
+
74
+ def test_environment_and_key(self):
75
+ self.assertEqual(UVerify("uvk_live_x").environment, "live")
76
+ self.assertEqual(UVerify("uvk_test_x").environment, "test")
77
+ was = os.environ.pop("UVERIFY_API_KEY", None)
78
+ with self.assertRaises(ValueError):
79
+ UVerify()
80
+ if was:
81
+ os.environ["UVERIFY_API_KEY"] = was
82
+
83
+
84
+ class Errors(unittest.TestCase):
85
+ def test_error_fields(self):
86
+ t = Fake(lambda c: fail(400, "validation_error", "One or more fields are invalid.", ["id_number must be the 11-digit NIN"]))
87
+ with self.assertRaises(UVerifyError) as cm:
88
+ client(t).identity.nin(id_number="x")
89
+ e = cm.exception
90
+ self.assertEqual((e.code, e.status, e.request_id, e.details), ("validation_error", 400, "req_err", ["id_number must be the 11-digit NIN"]))
91
+
92
+ def test_4xx_not_retried(self):
93
+ t = Fake(lambda c: fail(402, "insufficient_balance"))
94
+ with self.assertRaises(UVerifyError):
95
+ client(t).identity.nin(id_number="1")
96
+ self.assertEqual(len(t.calls), 1)
97
+
98
+
99
+ class Retries(unittest.TestCase):
100
+ def test_5xx_then_ok_same_reference(self):
101
+ t = Fake(lambda c: fail(503, "internal_error"), lambda c: ok({"id": "v1"}))
102
+ self.assertEqual(client(t).identity.nin(id_number="1"), {"id": "v1"})
103
+ self.assertEqual(t.calls[0]["body"]["reference"], t.calls[1]["body"]["reference"])
104
+
105
+ def test_rate_limit(self):
106
+ t = Fake(lambda c: fail(429, "rate_limited", "slow", {"retry_after_seconds": 0}), lambda c: ok({"balance": 5}))
107
+ self.assertEqual(client(t).account.balance(), {"balance": 5})
108
+ self.assertEqual(len(t.calls), 2)
109
+
110
+ def test_recovers_lost_answer(self):
111
+ t = Fake(
112
+ lambda c: "network-error",
113
+ lambda c: fail(409, "duplicate_reference"),
114
+ lambda c: ok({"items": [{"id": "v9"}], "pagination": {}}) if "/verifications?" in c["url"] else fail(500, "x"),
115
+ lambda c: ok({"id": "v9", "data": {"first_name": "ADA"}}) if c["url"].endswith("/verifications/v9") else fail(500, "x"),
116
+ )
117
+ v = client(t).identity.nin(id_number="1")
118
+ self.assertEqual(v["id"], "v9")
119
+ self.assertIn(f"reference={t.calls[0]['body']['reference']}", t.calls[2]["url"])
120
+
121
+ def test_gives_up(self):
122
+ t = Fake(lambda c: "network-error")
123
+ with self.assertRaises(UVerifyConnectionError):
124
+ client(t, max_retries=1).identity.nin(id_number="1")
125
+ self.assertEqual(len(t.calls), 2)
126
+
127
+ def test_simulate_not_retried(self):
128
+ t = Fake(lambda c: fail(500, "internal_error"))
129
+ with self.assertRaises(UVerifyError):
130
+ client(t).liveness.simulate("s1", outcome="passed")
131
+ self.assertEqual(len(t.calls), 1)
132
+
133
+
134
+ class Webhooks(unittest.TestCase):
135
+ secret = "whsec_test123"
136
+ body = json.dumps({"id": "evt_1", "type": "verification.completed", "created_at": "2026-09-30T10:00:00Z", "environment": "live", "data": {"id": "v1"}})
137
+
138
+ def test_valid(self):
139
+ e = construct_event(self.body, sign_payload(self.body, self.secret), self.secret)
140
+ self.assertEqual(e["id"], "evt_1")
141
+ self.assertEqual(UVerify.webhooks.construct_event(self.body.encode(), sign_payload(self.body, self.secret), self.secret)["type"], "verification.completed")
142
+
143
+ def test_rejects(self):
144
+ sig = sign_payload(self.body, self.secret)
145
+ for args in [(self.body.replace("v1", "v2"), sig, self.secret), (self.body, sig, "whsec_other"), (self.body, None, self.secret), (self.body, "v1=abc", self.secret)]:
146
+ with self.assertRaises(UVerifySignatureError):
147
+ construct_event(*args)
148
+ old = sign_payload(self.body, self.secret, int(time.time()) - 3600)
149
+ with self.assertRaises(UVerifySignatureError):
150
+ construct_event(self.body, old, self.secret)
151
+ self.assertEqual(construct_event(self.body, old, self.secret, tolerance_seconds=0)["id"], "evt_1")
152
+
153
+ def test_matches_node_and_server(self):
154
+ # Same inputs as the Node SDK's test: identical signatures across languages.
155
+ import hashlib
156
+ import hmac
157
+ t = 1790590000
158
+ expected = hmac.new(self.secret.encode(), f"{t}.{self.body}".encode(), hashlib.sha256).hexdigest()
159
+ self.assertEqual(sign_payload(self.body, self.secret, t), f"t={t},v1={expected}")
160
+
161
+
162
+ class Contract(unittest.TestCase):
163
+ def test_covers_every_public_route(self):
164
+ routes = json.loads((Path(__file__).resolve().parents[2] / "contract" / "public-api.json").read_text())["routes"]
165
+ seen = set()
166
+
167
+ def transport(method, url, headers, body, timeout):
168
+ path = url.split("/v1", 1)[1].split("?")[0].replace("/id-x", "/{id}")
169
+ seen.add(f"{method} {path}")
170
+ return ok({"items": [], "pagination": {}})
171
+
172
+ u = client(transport)
173
+ p = dict(id_number="1", first_name="A", last_name="B")
174
+ u.identity.bvn(**p); u.identity.bvn_face_match(**p, liveness_session_id="s"); u.identity.nin(id_number="1"); u.identity.nin_face_match(id_number="1", liveness_session_id="s")
175
+ u.identity.drivers_license(**p); u.identity.drivers_license_face_match(**p, liveness_session_id="s"); u.identity.voters_card(**p); u.identity.voters_card_face_match(**p, liveness_session_id="s")
176
+ u.identity.tin(id_number="1"); u.business.cac(id_number="RC1")
177
+ u.verifications.list(); u.verifications.get("id-x"); u.account.balance(); u.account.pricing()
178
+ u.liveness.create_session(); u.liveness.get_session("id-x"); u.liveness.simulate("id-x", outcome="passed")
179
+ u.documents.verify(front_image="x"); u.documents.get("id-x")
180
+ u.aml.screen(name="A B"); u.aml.get_screening("id-x"); u.aml.lists()
181
+ u.aml.monitors.create(name="A B"); u.aml.monitors.list(); u.aml.monitors.get("id-x"); u.aml.monitors.stop("id-x")
182
+ u.kyc.create_link(); u.kyc.list_links(); u.kyc.get_link("id-x")
183
+ self.assertEqual([r for r in routes if r not in seen], [])
184
+
185
+
186
+ if __name__ == "__main__":
187
+ unittest.main()
@@ -0,0 +1,68 @@
1
+ """The real urllib transport, against a local HTTP server (no network)."""
2
+ import json
3
+ import sys
4
+ import threading
5
+ import unittest
6
+ from http.server import BaseHTTPRequestHandler, HTTPServer
7
+ from pathlib import Path
8
+
9
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
10
+
11
+ from uverify import UVerify, UVerifyError # noqa: E402
12
+
13
+
14
+ class Handler(BaseHTTPRequestHandler):
15
+ def log_message(self, *a): # quiet
16
+ pass
17
+
18
+ def _send(self, status, obj):
19
+ body = json.dumps(obj).encode()
20
+ self.send_response(status)
21
+ self.send_header("content-type", "application/json")
22
+ self.send_header("content-length", str(len(body)))
23
+ self.end_headers()
24
+ self.wfile.write(body)
25
+
26
+ def do_GET(self):
27
+ if self.headers.get("authorization") != "Bearer uvk_test_local":
28
+ return self._send(401, {"success": False, "error": {"code": "invalid_api_key", "message": "Bad key"}, "request_id": "req_401"})
29
+ self._send(200, {"success": True, "data": {"balance": 1234.5, "currency": "NGN"}, "request_id": "req_ok"})
30
+
31
+ def do_POST(self):
32
+ n = int(self.headers.get("content-length") or 0)
33
+ body = json.loads(self.rfile.read(n) or b"{}")
34
+ if body.get("id_number") == "bad":
35
+ return self._send(400, {"success": False, "error": {"code": "validation_error", "message": "Invalid", "details": ["id_number must be the 11-digit NIN"]}, "request_id": "req_400"})
36
+ self._send(200, {"success": True, "data": {"id": "v1", "status": "verified", "echo": body}, "request_id": "req_ok"})
37
+
38
+
39
+ class RealHttp(unittest.TestCase):
40
+ @classmethod
41
+ def setUpClass(cls):
42
+ cls.server = HTTPServer(("127.0.0.1", 0), Handler)
43
+ threading.Thread(target=cls.server.serve_forever, daemon=True).start()
44
+ cls.base = f"http://127.0.0.1:{cls.server.server_port}/v1"
45
+
46
+ @classmethod
47
+ def tearDownClass(cls):
48
+ cls.server.shutdown()
49
+ cls.server.server_close()
50
+
51
+ def test_get_and_post(self):
52
+ u = UVerify("uvk_test_local", base_url=self.base)
53
+ self.assertEqual(u.account.balance()["balance"], 1234.5)
54
+ v = u.identity.nin(id_number="12345678901")
55
+ self.assertEqual(v["status"], "verified")
56
+ self.assertTrue(v["echo"]["reference"].startswith("sdk_"))
57
+
58
+ def test_error_bodies_are_read(self):
59
+ with self.assertRaises(UVerifyError) as cm:
60
+ UVerify("uvk_test_local", base_url=self.base).identity.nin(id_number="bad")
61
+ self.assertEqual((cm.exception.code, cm.exception.status, cm.exception.request_id), ("validation_error", 400, "req_400"))
62
+ with self.assertRaises(UVerifyError) as cm:
63
+ UVerify("uvk_test_wrong", base_url=self.base).account.balance()
64
+ self.assertEqual(cm.exception.code, "invalid_api_key")
65
+
66
+
67
+ if __name__ == "__main__":
68
+ unittest.main()
@@ -0,0 +1,44 @@
1
+ """The real API, in sandbox (free). Runs only with UVERIFY_TEST_KEY=uvk_test_... set."""
2
+ import os
3
+ import sys
4
+ import unittest
5
+ from pathlib import Path
6
+
7
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
8
+
9
+ from uverify import UVerify, UVerifyError # noqa: E402
10
+
11
+ KEY = os.environ.get("UVERIFY_TEST_KEY", "")
12
+ JPEG = bytes.fromhex("ffd8ffe000104a46494600010100000100010000ffdb004300080606070605080707070909080a0c140d0c0b0b0c1912130f141d1a1f1e1d1a1c1c20242e2720222c231c1c2837292c30313434341f27393d38323c2e333432ffc0000b080001000101011100ffc4001400010000000000000000000000000000000affc40014100100000000000000000000000000000000ffda0008010100003f002a9fffd9")
13
+
14
+
15
+ @unittest.skipUnless(KEY.startswith("uvk_test_"), "set UVERIFY_TEST_KEY to run against the sandbox")
16
+ class Sandbox(unittest.TestCase):
17
+ def setUp(self):
18
+ self.u = UVerify(KEY, base_url=os.environ.get("UVERIFY_BASE_URL"))
19
+
20
+ def test_nin(self):
21
+ self.assertEqual(self.u.identity.nin(id_number="12345678942")["status"], "verified")
22
+ self.assertEqual(self.u.identity.nin(id_number="12345678900")["status"], "not_found")
23
+
24
+ def test_liveness_face_match(self):
25
+ s = self.u.liveness.create_session()
26
+ self.u.liveness.simulate(s["id"], outcome="passed")
27
+ v = self.u.identity.nin_face_match(id_number="12345678942", liveness_session_id=s["id"])
28
+ self.assertEqual((v["face_match"]["status"], v["face_match"]["liveness"]), ("matched", "passed"))
29
+
30
+ def test_the_rest(self):
31
+ self.assertEqual(self.u.documents.verify(front_image=JPEG, document_type="nin_card")["status"], "verified")
32
+ self.assertEqual(self.u.aml.screen(name="Adaeze Okafor")["status"], "clear")
33
+ link = self.u.kyc.create_link(customer_name="SDK Test")
34
+ self.assertEqual(self.u.kyc.get_link(link["id"])["status"], "pending")
35
+ self.assertIsInstance(self.u.account.balance()["balance"], (int, float))
36
+
37
+ def test_validation_error(self):
38
+ with self.assertRaises(UVerifyError) as cm:
39
+ self.u.identity.nin(id_number="abc")
40
+ self.assertEqual(cm.exception.code, "validation_error")
41
+
42
+
43
+ if __name__ == "__main__":
44
+ unittest.main()