Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
__pycache__/
*.pyc
.pytest_cache/
31 changes: 29 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"])
Expand Down
4 changes: 2 additions & 2 deletions infinihash_kyc/__init__.py
Original file line number Diff line number Diff line change
@@ -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"
Binary file removed infinihash_kyc/__pycache__/__init__.cpython-311.pyc
Binary file not shown.
Binary file removed infinihash_kyc/__pycache__/client.cpython-311.pyc
Binary file not shown.
31 changes: 28 additions & 3 deletions infinihash_kyc/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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]:
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Binary file not shown.
21 changes: 20 additions & 1 deletion tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}

Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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
Loading