diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..75c6182 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +.pytest_cache/ diff --git a/README.md b/README.md index b78de56..6d13821 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,37 @@ Python SDK for the [Infinihash KYC API](https://kyc.infinihash.com): KYC and KYB sanctions screening, and case reports. ```bash -pip install "git+https://github.com/Infinihash/kyc-python@v0.1.0" +pip install "git+https://github.com/Infinihash/kyc-python@v0.2.0" export INFINIHASH_KYC_KEY=... # your API key ``` +## Try it free: the sandbox + +No account and no billing. A sandbox key runs the real case lifecycle with +deterministic results, and no provider is ever called: + +```python +from infinihash_kyc import KYC, create_sandbox_key + +kyc = KYC(api_key=create_sandbox_key("you@example.com")) # kyc_sbx_..., shown once +case = kyc.submissions.create(entity_type="individual", entity_name="Jane SANCTIONED Doe") +kyc.submissions.run(case["id"]) +print(kyc.submissions.wait(case["id"])["summary"]["action"]) # block_report +``` + +| Entity name contains | Result | +|---|---| +| `SANCTIONED` | sanctions HIT, so the case is `failed` / `block_report` | +| `UNSCREENED` | the sanctions list is not searched, so the case ends in `review` and `is_unscreened()` is True | +| `PEP` | `pep_screen` goes to review, so the case gets `enhanced_review` | +| `ADVERSE` | `negative_news` goes to review (adverse media) | +| `LIVENESS_FAIL` | `liveness_check` fails | +| anything else | every check passes, and the case is `complete` / `approve` | + +Sandbox keys can create, run, read, list and export cases and screen names. +Routes that call paid providers (OCR, liveness, biometrics) return +`403 sandbox_route_unavailable`. Use a production key for those. + ## Screen a name ```python @@ -37,7 +64,7 @@ kyc.submissions.run(case["id"]) result = kyc.submissions.wait(case["id"]) # complete | review | failed if is_unscreened(result): - print("manual review:", result["summary"]["error"]) + print("manual review:", result["summary"].get("error") or result["summary"]["screeningErrors"]) else: print(result["summary"]["action"]) # approve | enhanced_review | manual_review | block_report pdf = kyc.submissions.export_pdf(case["id"]) diff --git a/infinihash_kyc/__init__.py b/infinihash_kyc/__init__.py index 7433b74..018e780 100644 --- a/infinihash_kyc/__init__.py +++ b/infinihash_kyc/__init__.py @@ -1,5 +1,5 @@ """Infinihash KYC Python SDK. See client.py for the full guide.""" -from .client import KYC, KYCError, ScreeningUnavailable, is_unscreened, ENTITY_TYPES, RISK_TIERS +from .client import KYC, KYCError, ScreeningUnavailable, is_unscreened, create_sandbox_key, ENTITY_TYPES, RISK_TIERS __all__ = ["KYC", "KYCError", "ScreeningUnavailable", "is_unscreened", "ENTITY_TYPES", "RISK_TIERS"] -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/infinihash_kyc/__pycache__/__init__.cpython-311.pyc b/infinihash_kyc/__pycache__/__init__.cpython-311.pyc deleted file mode 100644 index b0f302b..0000000 Binary files a/infinihash_kyc/__pycache__/__init__.cpython-311.pyc and /dev/null differ diff --git a/infinihash_kyc/__pycache__/client.cpython-311.pyc b/infinihash_kyc/__pycache__/client.cpython-311.pyc deleted file mode 100644 index 4769726..0000000 Binary files a/infinihash_kyc/__pycache__/client.cpython-311.pyc and /dev/null differ diff --git a/infinihash_kyc/client.py b/infinihash_kyc/client.py index a3e3366..db12c87 100644 --- a/infinihash_kyc/client.py +++ b/infinihash_kyc/client.py @@ -127,8 +127,33 @@ def export_pdf(self, submission_id: str) -> bytes: def is_unscreened(submission: Dict[str, Any]) -> bool: - """True when the case finished without being screened (fail-closed review).""" - return bool(((submission or {}).get("summary") or {}).get("error")) + """True when the case finished without being fully screened (fail-closed review). + + Either no checklist ran (``summary.error``), or a screen could not be + performed (``summary.screeningErrors``, e.g. a sanctions list that was not + searched). Such a case is never ``complete``; treat it as unscreened, not clear. + """ + summary = (submission or {}).get("summary") or {} + return bool(summary.get("error") or summary.get("screeningErrors")) + + +def create_sandbox_key(email: str, base_url: Optional[str] = None, timeout: float = DEFAULT_TIMEOUT) -> str: + """Self-serve sandbox key (``kyc_sbx_...``), returned once. No account needed. + + Sandbox keys run the real case lifecycle with deterministic results and are + never billed. Magic entity names: SANCTIONED, UNSCREENED, PEP, ADVERSE, + LIVENESS_FAIL; any other name passes. See ``GET /api/sandbox``. + """ + url = (base_url or os.environ.get("INFINIHASH_KYC_URL") or DEFAULT_BASE_URL).rstrip("/") + r = requests.post(url + "/api/sandbox/keys", json={"email": email}, timeout=timeout, + headers={"User-Agent": "infinihash-kyc-python/0.2.0"}) + if r.status_code != 201: + try: + msg = r.json().get("error") + except ValueError: + msg = r.text[:200] + raise KYCError(r.status_code, msg or r.reason) + return r.json()["apiKey"] class KYC: @@ -140,7 +165,7 @@ def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None self.base_url = (base_url or os.environ.get("INFINIHASH_KYC_URL") or DEFAULT_BASE_URL).rstrip("/") self.timeout = timeout self._s = session or requests.Session() - self._s.headers.update({"X-API-Key": self.api_key, "User-Agent": "infinihash-kyc-python/0.1.0"}) + self._s.headers.update({"X-API-Key": self.api_key, "User-Agent": "infinihash-kyc-python/0.2.0"}) self.submissions = _Submissions(self) def screen(self, name: str, country: Optional[str] = None, dob: Optional[str] = None) -> Dict[str, Any]: diff --git a/pyproject.toml b/pyproject.toml index 8456ce5..24536c0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "infinihash-kyc" -version = "0.1.0" +version = "0.2.0" description = "Python SDK for the Infinihash KYC API (KYC/KYB cases, sanctions screening)." readme = "README.md" requires-python = ">=3.8" diff --git a/tests/__pycache__/test_client.cpython-311-pytest-9.1.1.pyc b/tests/__pycache__/test_client.cpython-311-pytest-9.1.1.pyc deleted file mode 100644 index 0a14978..0000000 Binary files a/tests/__pycache__/test_client.cpython-311-pytest-9.1.1.pyc and /dev/null differ diff --git a/tests/test_client.py b/tests/test_client.py index 95528ef..e0481fa 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -7,7 +7,7 @@ import pytest -from infinihash_kyc import KYC, KYCError, ScreeningUnavailable, is_unscreened +from infinihash_kyc import KYC, KYCError, ScreeningUnavailable, create_sandbox_key, is_unscreened STATE = {} @@ -55,6 +55,10 @@ def do_POST(self): return self._send(400, {"error": "entityName must be 2–300 characters"}) return self._send(201, {"id": "sub_1", "status": "pending", "entityType": b["entityType"], "requiredChecks": ["sanctions_ofac", "pep"], "_links": {}}) + if self.path == "/api/sandbox/keys": + if "@" not in b.get("email", ""): + return self._send(400, {"error": "a valid email is required"}) + return self._send(201, {"apiKey": "kyc_sbx_" + "0" * 48, "tier": "sandbox"}) if self.path == "/api/kyc/sub_1/run": return self._send(200, {"message": "Checks started", "submissionId": "sub_1"}) if self.path == "/api/kyc/screen": @@ -146,3 +150,18 @@ def test_api_key_is_required(monkeypatch): monkeypatch.delenv("INFINIHASH_KYC_KEY", raising=False) with pytest.raises(ValueError): KYC() + + +def test_a_sanctions_screening_error_counts_as_unscreened(kyc): + # KYC-API 2026-10-06: a sanctions list that was NOT SEARCHED routes the case + # to review with screeningErrors=['sanctions'] but no summary.error. + STATE["final"] = {"id": "sub_1", "status": "review", + "summary": {"action": "manual_review", "screeningErrors": ["sanctions"]}, "checks": []} + assert is_unscreened(kyc.submissions.wait("sub_1", poll=0.01)) + + +def test_create_sandbox_key(kyc): + assert create_sandbox_key("dev@example.com", base_url=kyc.base_url).startswith("kyc_sbx_") + with pytest.raises(KYCError) as e: + create_sandbox_key("nope", base_url=kyc.base_url) + assert e.value.status == 400