uverify 0.1.0__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.
uverify/__init__.py ADDED
@@ -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__"]
uverify/client.py ADDED
@@ -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")
uverify/errors.py ADDED
@@ -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."""
uverify/py.typed ADDED
File without changes
uverify/webhooks.py ADDED
@@ -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,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,8 @@
1
+ uverify/__init__.py,sha256=a8VujlWBb637pTTbooUvEAvAfgHA6P2_Ks_9e9WdJIA,413
2
+ uverify/client.py,sha256=SmfZ0EJAkkmOgdbrKmhNrJazBKmZB6MV5YQInypEGz0,14012
3
+ uverify/errors.py,sha256=tVzAN2pwtHhzdWe1oW8_f2icx1CrwfqGr8Y5ZvPCo_w,1374
4
+ uverify/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ uverify/webhooks.py,sha256=BLzOLz0xmwMVv3f5ajJNRU9SE8fkzi6q2BfoAhqe2Lo,2152
6
+ uverify-0.1.0.dist-info/METADATA,sha256=a_BSgjoN6trAzV5YzscDMU9iZ_Cpx9E-JDo7EI6wfL8,4050
7
+ uverify-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
8
+ uverify-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any