proof-holdings-sdk 1.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.
Files changed (65) hide show
  1. proof_holdings_sdk-1.1.0/.gitignore +7 -0
  2. proof_holdings_sdk-1.1.0/CHANGELOG.md +21 -0
  3. proof_holdings_sdk-1.1.0/CLAUDE.md +40 -0
  4. proof_holdings_sdk-1.1.0/LICENSE +21 -0
  5. proof_holdings_sdk-1.1.0/PKG-INFO +253 -0
  6. proof_holdings_sdk-1.1.0/README.md +225 -0
  7. proof_holdings_sdk-1.1.0/pyproject.toml +44 -0
  8. proof_holdings_sdk-1.1.0/pytest.ini +2 -0
  9. proof_holdings_sdk-1.1.0/release-fingerprint.json +5 -0
  10. proof_holdings_sdk-1.1.0/src/proof_sdk/__init__.py +38 -0
  11. proof_holdings_sdk-1.1.0/src/proof_sdk/client.py +111 -0
  12. proof_holdings_sdk-1.1.0/src/proof_sdk/credentials.py +178 -0
  13. proof_holdings_sdk-1.1.0/src/proof_sdk/crypto/__init__.py +29 -0
  14. proof_holdings_sdk-1.1.0/src/proof_sdk/crypto/hitl_crypto.py +428 -0
  15. proof_holdings_sdk-1.1.0/src/proof_sdk/errors.py +119 -0
  16. proof_holdings_sdk-1.1.0/src/proof_sdk/http.py +191 -0
  17. proof_holdings_sdk-1.1.0/src/proof_sdk/jwks/__init__.py +1 -0
  18. proof_holdings_sdk-1.1.0/src/proof_sdk/jwks/key_set_cache.py +340 -0
  19. proof_holdings_sdk-1.1.0/src/proof_sdk/jwks/keyset.py +526 -0
  20. proof_holdings_sdk-1.1.0/src/proof_sdk/polling.py +87 -0
  21. proof_holdings_sdk-1.1.0/src/proof_sdk/py.typed +0 -0
  22. proof_holdings_sdk-1.1.0/src/proof_sdk/resources/__init__.py +37 -0
  23. proof_holdings_sdk-1.1.0/src/proof_sdk/resources/authorizations.py +99 -0
  24. proof_holdings_sdk-1.1.0/src/proof_sdk/resources/circles.py +137 -0
  25. proof_holdings_sdk-1.1.0/src/proof_sdk/resources/confirmations.py +66 -0
  26. proof_holdings_sdk-1.1.0/src/proof_sdk/resources/delegations.py +90 -0
  27. proof_holdings_sdk-1.1.0/src/proof_sdk/resources/hitl.py +113 -0
  28. proof_holdings_sdk-1.1.0/src/proof_sdk/resources/hitl_keys.py +28 -0
  29. proof_holdings_sdk-1.1.0/src/proof_sdk/resources/me.py +67 -0
  30. proof_holdings_sdk-1.1.0/src/proof_sdk/resources/profiles.py +101 -0
  31. proof_holdings_sdk-1.1.0/src/proof_sdk/resources/proof_me.py +40 -0
  32. proof_holdings_sdk-1.1.0/src/proof_sdk/resources/proofs.py +524 -0
  33. proof_holdings_sdk-1.1.0/src/proof_sdk/resources/public_profiles.py +52 -0
  34. proof_holdings_sdk-1.1.0/src/proof_sdk/resources/sessions.py +64 -0
  35. proof_holdings_sdk-1.1.0/src/proof_sdk/resources/streams.py +184 -0
  36. proof_holdings_sdk-1.1.0/src/proof_sdk/resources/templates.py +83 -0
  37. proof_holdings_sdk-1.1.0/src/proof_sdk/resources/verification_requests.py +118 -0
  38. proof_holdings_sdk-1.1.0/src/proof_sdk/resources/verifications.py +214 -0
  39. proof_holdings_sdk-1.1.0/src/proof_sdk/resources/webhook_deliveries.py +48 -0
  40. proof_holdings_sdk-1.1.0/src/proof_sdk/streaming.py +256 -0
  41. proof_holdings_sdk-1.1.0/src/proof_sdk/types.py +2191 -0
  42. proof_holdings_sdk-1.1.0/src/proof_sdk/version.py +1 -0
  43. proof_holdings_sdk-1.1.0/tests/conftest.py +27 -0
  44. proof_holdings_sdk-1.1.0/tests/integration/__init__.py +0 -0
  45. proof_holdings_sdk-1.1.0/tests/integration/conftest.py +174 -0
  46. proof_holdings_sdk-1.1.0/tests/integration/test_client.py +83 -0
  47. proof_holdings_sdk-1.1.0/tests/integration/test_errors.py +75 -0
  48. proof_holdings_sdk-1.1.0/tests/integration/test_live_lifecycle.py +235 -0
  49. proof_holdings_sdk-1.1.0/tests/integration/test_polling.py +86 -0
  50. proof_holdings_sdk-1.1.0/tests/integration/test_proof_me.py +49 -0
  51. proof_holdings_sdk-1.1.0/tests/integration/test_resources.py +57 -0
  52. proof_holdings_sdk-1.1.0/tests/integration/test_retries.py +29 -0
  53. proof_holdings_sdk-1.1.0/tests/integration/test_streaming.py +29 -0
  54. proof_holdings_sdk-1.1.0/tests/test_client.py +36 -0
  55. proof_holdings_sdk-1.1.0/tests/test_credentials_fixtures.py +220 -0
  56. proof_holdings_sdk-1.1.0/tests/test_delegations.py +104 -0
  57. proof_holdings_sdk-1.1.0/tests/test_errors.py +106 -0
  58. proof_holdings_sdk-1.1.0/tests/test_hitl_crypto.py +293 -0
  59. proof_holdings_sdk-1.1.0/tests/test_http.py +322 -0
  60. proof_holdings_sdk-1.1.0/tests/test_jwks_rotation_fixtures.py +405 -0
  61. proof_holdings_sdk-1.1.0/tests/test_offline_revocation.py +574 -0
  62. proof_holdings_sdk-1.1.0/tests/test_packaging.py +56 -0
  63. proof_holdings_sdk-1.1.0/tests/test_polling.py +214 -0
  64. proof_holdings_sdk-1.1.0/tests/test_resources.py +814 -0
  65. proof_holdings_sdk-1.1.0/tests/test_streaming.py +586 -0
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
7
+ ._*
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ Notable changes to [`proof-holdings-sdk`](https://pypi.org/project/proof-holdings-sdk/) (import name
4
+ `proof_sdk`). Source: [ProofHoldings/sdk-python](https://github.com/ProofHoldings/sdk-python).
5
+
6
+ ## 1.1.0 — 2026-10-14
7
+
8
+ The first release on PyPI.
9
+
10
+ ### Added
11
+
12
+ - `Proof()` without a key resolves one: a non-empty `PROOF_API_KEY`, else the entry that
13
+ `npx @proof-holdings/mcp-server login` saved in `~/.proof-holdings/credentials` for the client's base
14
+ URL. The base URL is the `base_url` argument, else `PROOF_BASE_URL`, else
15
+ `https://api.proof.holdings`. An explicit key behaves as before; an explicit empty key is an error.
16
+ - `verifications.delete_domain_verification(id)` deletes a domain verification attempt that is not
17
+ verified (pending, verifying or failed). A verified domain is refused with 409 `domain_verified`.
18
+ - Types for account deletion and export, identifier, domain and project removal, the artifact claim
19
+ lookup, and the Delegations section of a public profile.
20
+ - API-key listings carry `replaced_key_ids`: a key's usage covers every key it replaced.
21
+ - The `free` billing plan in `BillingPlan`.
@@ -0,0 +1,40 @@
1
+ # Python SDK CLAUDE.md
2
+
3
+ ## Purpose
4
+ Official Python client SDK for the proof.holdings REST API. PyPI distribution `proof-holdings-sdk`; the import package stays `proof_sdk` under `src/`, and because the two names differ, `pyproject.toml`'s wheel target names `src/proof_sdk` explicitly.
5
+
6
+ ## Narrative Summary
7
+ The client (`client.py`) wires 17 resources: verifications, verification_requests, proofs, sessions, streams, webhook_deliveries, authorizations, confirmations, hitl, hitl_keys, templates, profiles, public_profiles, me, proof_me, circles, delegations. **api-map rule**: a module counts toward `available` only once it is BOTH re-exported from `resources/__init__.py` AND instantiated as `Class(self._http)` in `client.py` — keyword arguments after `self._http` are allowed (`Proofs(self._http, max_jwks_age=...)`), and both parsers of `client.py` accept them: `getWiredPyResources` in `scripts/api-map-shared.ts` and `readPyClient` in `src/__tests__/drift/sdk-code-samples.test.ts`. **Construction**: `Proof(api_key: str | None = None, *, base_url: str | None = None, …)` — an omitted key and the base URL resolve through `proof_sdk/credentials.py` (stdlib only, so `test_packaging.py` sees nothing new; the cross-SDK contract is in `../CLAUDE.md`): `resolve_api_key`, `resolve_base_url`, `credential_key`, `default_credentials_path`, `read_credentials_file`, `missing_key_message`, the frozen dataclass `ResolvedKey`, `CredentialsFileError`, `LOGIN_COMMAND` and `DEFAULT_BASE_URL` (which `client.py` re-exports, having been its home before). `__init__.py` exports `resolve_api_key`, `resolve_base_url`, `ResolvedKey`, `LOGIN_COMMAND`. The HTTP layer (`http.py`) exposes `request`/`get`/`post`/`put`/`delete` (`delete` accepts a `json=` body). `AsyncClient` is built with `follow_redirects=True` so the avatar 301 is followed; non-JSON 2xx returns a `{"data": bytes, "content_type": str}` envelope (see `../CLAUDE.md`).
8
+
9
+ - `hitl` — config CRUD (`create`/`list`/`retrieve`/`update`/`archive`/`finalize`/`request_authorization`/`create_chat_id_discovery`/`get_approver_keys`).
10
+ - `templates` (7 → `/templates*`), `profiles` (10 incl. profile-template CRUD → `/me/profiles*`), `public_profiles` (`get_by_id`/`get_avatar`/`get_by_username`/`get_badge`/`check_username`, unauthenticated; `get_by_id` takes the PUBLIC `profile_id` only; `get_avatar` returns `{"data": bytes, "content_type": str}`; `get_badge(username)` returns the embed descriptor — branch on its `state`, since a username with no public record answers 200, never 404).
11
+ - `me` (class `Me`) — the dual-auth data surface `summary`/`search`/`api_key_self`/`api_key_usage`.
12
+ - `verifications` — also multi-channel (`create_multi_channel`/`get_multi_channel_status`; returns the generated `MultiChannel*` TypedDicts). Test-mode groups have no completion path (`unsupported_for_group`).
13
+ - `delegations` (class `Delegations`) — Proof of Delegation (`create`/`list`/`retrieve`/`revoke` → `/api/v1/delegations*`). Return annotations use the generated aliases from `types.py`, and since `m-fix-delegation-list-cascade` flattened the four delegation OpenAPI schemas off a shared shape, ALL of them are real TypedDicts — `DelegationWithToken`/`DelegationDetail` had been literal `Any` for as long as they existed (allOf compositions degrade to `Any` in the Python type generator), so the detail shipped untyped until then. `DelegationListResponse.data` is `list[DelegationListItem]` — `Delegation` plus the derived `effective_status`/`is_valid`; the `status` kwarg filters the delegation's own stored record, not the derived one. Since `m-fix-proof-api-consistency` the writes carry the derived pair too, so `create` is annotated `DelegationDetail` and `revoke` `DelegationListItem` (previously `DelegationWithToken` / `Delegation`).
14
+ - `proof_me` (class `ProofMe`) — the Proof-Me surface (2 async methods: `create_identity_challenge`/`get_identity_challenge`; returns the generated `IdentityChallengeResponse` from `types.py`). Challenger enrollment lives on the `circles` resource.
15
+ - `circles` (class `Circles`) — Circle people-primitive: CRUD + member management + member-channel management (13 methods). Response types (`Circle`, `CircleListResponse`, `CircleMember`, `CircleMemberChannel`, `CircleMemberChannelListResponse`, `CircleMemberDrillResponse`, `CircleMemberInviteResponse`, `CircleMemberListResponse`) are imported from the generated `types.py`; the resource module no longer declares them as inline `TypedDict`s (unified 2026-09, `m-sdk-proofme-circle-reshape` — the two had drifted into a field-for-field duplicate).
16
+
17
+ **⚠️ Parser constraints (load-bearing) — `parsePySdkMethods` in `scripts/api-map-shared.ts`:**
18
+ - The PATCH verb MUST stay on the SAME LINE as `self._http.request(` — the same-line regex only recognizes `self._http.request("VERB", ...)` when `"VERB"` is on the opening line; breaking it across lines silently DROPS the endpoint from the `available` claim (affects `hitl.update`, `profiles.update`).
19
+ - Multi-segment path f-strings (e.g. `profiles.update_template`/`delete_template`) MUST stay on ONE line — the parser doesn't join adjacent string literals across lines.
20
+
21
+ The multi-channel status f-string uses `{quote(group_id, safe='')}` with SINGLE quotes — nesting `safe=""` in a double-quoted f-string is PEP 701 (3.12+) while `requires-python` is `>=3.10` (several pre-existing methods already violate this — a known follow-up).
22
+
23
+ - **Streaming** (`streaming.py`, `resources/streams.py`) — every stream is an async generator yielding `{"event", "id", "data"}` dicts like the other SDKs (`_parse_sse_block` keeps the `event:`/`id:` fields; `data` is JSON-decoded, or the raw text when not JSON); the polling fallback yields `poll` events and a terminal snapshot as `terminal`. `cancel_event` is RACED against each socket read (`_until_cancelled`), so a set event stops the generator promptly even while the server is silent between its 30s keepalives, raising `asyncio.CancelledError`. The asset stream's fallback polls `…/assets/{idx}/status`, ends on `verified`/`failed`/`expired`/`revoked`, and — through `request_terminal_states` — on a request `cancelled`/`expired` (cross-SDK contract in `../CLAUDE.md`). Tests: `tests/test_streaming.py`.
24
+ - `proofs` (class `Proofs`) — `verify_offline(token, *, check_revocation=True)` reads the token's status-list slot by default and enforces the key-rotation rules (contract in `../CLAUDE.md`); `refresh_jwks()` forces a refetch, `clear_tombstones()` resets tombstones, `Proof(max_jwks_age=...)` (seconds, validated by `validate_max_jwks_age` when `Proofs` is constructed) bounds a stale key set. Python-specific:
25
+ - `proof_sdk/jwks/keyset.py` ports the verifier's `packages/delegation-verifier/src/keyset.ts` function for function (state persistence omitted — the key set is in memory only); a rule change lands in the verifier first. `proof_sdk/jwks/key_set_cache.py` is the I/O shell (`KeySetCache`: TTL, cooldowns, confirmed-only fallback, staleness; single-flight is one asyncio Task awaited through `asyncio.shield`, so a cancelled waiter does not cancel the shared fetch). Test seams `set_jwks_clock_for_tests` / `set_jwks_event_listener_for_tests`; a tombstone naming a live key is logged as a warning on the `proof_sdk` logger.
26
+ - PyJWT only imports keys (`jwt.PyJWK`) and checks signatures (`jwt.PyJWS().decode` per candidate). The claims are checked by `_claims_failure` in `resources/proofs.py` on the SDK's clock, which PyJWT cannot take: `exp`/`iat`/`nbf` must be finite numbers when present (a NumericDate must be a JSON number — PyJWT coerced numeric strings), `sub`/`jti` strings when present, and on the token path (not the status list) `iss` must match and a non-empty `aud` is refused (no audience is asked for).
27
+ - The key set and the status list both go through `http.fetch_public`, a second credential-free `AsyncClient` (`follow_redirects=False`, branded UA) that STREAMS via `client.stream` under one `asyncio.wait_for` over the whole transfer — httpx's own timeout bounds each socket operation and the read loop would restart it on every chunk — and abandons the body one byte past `max_bytes` (512 KiB). `verify_offline` answers a result dict on an unexpected error during checking rather than raising (it still raises `ImportError` without PyJWT), and `_origin` is wrapped where it is called: `urlsplit(...).port` RAISES for a port outside 0-65535 and `urlsplit` for a malformed IPv6 literal, which made this the only SDK that raised instead of failing closed.
28
+
29
+ ## Key Files
30
+ - `src/proof_sdk/` (package root), `pyproject.toml`, `pytest.ini`, `tests/conftest.py`.
31
+
32
+ ## Test Layout
33
+ - **Unit** (`tests/test_*.py`) via `pytest --ignore=tests/integration` (what `python-sdk-tests` runs on PRs after `pip install -e '.[jwt]'`; `pytest.ini` sets no `testpaths`, so a bare `pytest` also collects the integration suite). `test_resources.py` covers templates/profiles/public_profiles; `test_http.py` covers the `{data, content_type}` envelope + the `follow_redirects` assertion; `test_packaging.py` is a packaging drift guard — every top-level third-party import in `src/proof_sdk` must be covered by a declared `[project]` dependency in `pyproject.toml` (runtime deps: `httpx`, `typing-extensions`, `cryptography` — the last because `proof_sdk.crypto.hitl_crypto` imports it at module top and a confirmation cannot be created without `encrypt_message`, so a bare `pip install proof-holdings-sdk` must carry it; only `jwt`, the offline-verification extra imported lazily in `proofs.py` and `jwks/key_set_cache.py`, is exempt). `test_offline_revocation.py` runs against a real loopback HTTP server; `test_jwks_rotation_fixtures.py` runs the shared rotation cases through an `httpx.MockTransport` patched into the SDK's `AsyncClient`. `test_credentials_fixtures.py` runs the shared credentials cases through `Proof()` with `HOME` monkeypatched to `tmp_path`; the `AsyncClient` it swaps in must be built with the SDK's `base_url` and headers (the key read back from `proof._http._api_key`), as `tests/integration/conftest.py` does, because `http.py` puts both on the client and `request()` passes none per request.
34
+ - **Integration** (`tests/integration/`) — dual-mode via `httpx.MockTransport` in `conftest.py` (live when `PROOF_BASE_URL`+`PROOF_API_KEY_TEST` are set). Live staging is behind Cloudflare Access; header injection happens ONLY in `conftest.py`'s `_patch_client_with_cf_access` (production client stays unaware). Any client rebuild must go through `_sdk_default_headers()` to preserve the branded `proof-sdk-python/<VERSION>` UA. See `../CLAUDE.md` for the CF-Access / User-Agent / phone-identifier invariants.
35
+
36
+ Invoke (mock): `pytest tests/integration/`.
37
+
38
+ ## Related Documentation
39
+ - `../CLAUDE.md` - SDK suite overview (cross-SDK invariants).
40
+ - `../../docs/api-map.yaml` - API parity map.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Proof Holdings
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.
@@ -0,0 +1,253 @@
1
+ Metadata-Version: 2.5
2
+ Name: proof-holdings-sdk
3
+ Version: 1.1.0
4
+ Summary: Official Python SDK for the proof.holdings verification API
5
+ Project-URL: Homepage, https://proof.holdings
6
+ Project-URL: Documentation, https://api.proof.holdings/api/docs/
7
+ Project-URL: Repository, https://github.com/ProofHoldings/sdk-python
8
+ Author: proof.holdings
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: api,authentication,jwt,proof-holdings,sdk,verification
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
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: Typing :: Typed
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: cryptography>=41.0.0
23
+ Requires-Dist: httpx>=0.25.0
24
+ Requires-Dist: typing-extensions>=4.0
25
+ Provides-Extra: jwt
26
+ Requires-Dist: pyjwt>=2.8.0; extra == 'jwt'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # proof-holdings-sdk
30
+
31
+ Official Python SDK for the [proof.holdings](https://proof.holdings) verification API.
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ pip install proof-holdings-sdk
37
+ ```
38
+
39
+ For offline JWT verification:
40
+
41
+ ```bash
42
+ pip install 'proof-holdings-sdk[jwt]'
43
+ ```
44
+
45
+ ## Quick Start
46
+
47
+ ```python
48
+ import asyncio
49
+ from proof_sdk import Proof
50
+
51
+ async def main():
52
+ async with Proof("pk_live_...") as proof:
53
+ # Create a phone verification
54
+ v = await proof.verifications.create(
55
+ type="phone",
56
+ channel="whatsapp",
57
+ identifier="+1234567890",
58
+ )
59
+ print(f"Verification {v['id']} created: {v['status']}")
60
+
61
+ # Wait for user to complete verification
62
+ result = await proof.verifications.wait_for_completion(v["id"])
63
+ print(f"Result: {result['status']}")
64
+
65
+ asyncio.run(main())
66
+ ```
67
+
68
+ ## Resources
69
+
70
+ ### Verifications
71
+
72
+ ```python
73
+ # Create
74
+ v = await proof.verifications.create(type="domain", channel="dns", identifier="example.com")
75
+
76
+ # Retrieve
77
+ v = await proof.verifications.retrieve("ver_abc123")
78
+
79
+ # List with filters
80
+ page = await proof.verifications.list(status="verified", type="phone", limit=10)
81
+
82
+ # Trigger DNS/HTTP check
83
+ v = await proof.verifications.verify("ver_abc123")
84
+
85
+ # Submit OTP code
86
+ v = await proof.verifications.submit("ver_abc123", code="ABC123")
87
+
88
+ # Poll until complete
89
+ v = await proof.verifications.wait_for_completion("ver_abc123", interval=2.0, timeout=300.0)
90
+
91
+ # WABA (WhatsApp Business) verification
92
+ v = await proof.verifications.create(type="waba", channel="waba_otp", identifier="+37069199199")
93
+ # OTP is sent via WhatsApp — submit with proof.verifications.submit(v["id"], code="123456")
94
+
95
+ # Telegram Bot verification
96
+ v = await proof.verifications.create(
97
+ type="telegram_bot", channel="telegram_bot_token", identifier="@mybusinessbot"
98
+ )
99
+ # Submit bot token with proof.verifications.submit(v["id"], code="bot_token_here")
100
+ ```
101
+
102
+ ### Verification Requests (Multi-Asset)
103
+
104
+ ```python
105
+ # Create multi-asset request
106
+ req = await proof.verification_requests.create(
107
+ assets=[
108
+ {"type": "phone", "required": True},
109
+ {"type": "email", "identifier": "user@example.com"},
110
+ ],
111
+ reference_id="user_123",
112
+ callback_url="https://yourapp.com/webhook",
113
+ expires_in=86400, # 24 hours in seconds
114
+ )
115
+ print(f"Send user to: {req['verification_url']}")
116
+
117
+ # Poll until complete
118
+ result = await proof.verification_requests.wait_for_completion(req["id"])
119
+ ```
120
+
121
+ ### Proofs
122
+
123
+ ```python
124
+ # Validate online (checks revocation)
125
+ result = await proof.proofs.validate("eyJhbGciOi...")
126
+
127
+ # Revoke
128
+ response = await proof.proofs.revoke("ver_abc123", reason="User requested")
129
+
130
+ # Get revocation list
131
+ revoked = await proof.proofs.list_revoked()
132
+
133
+ # Offline verification (requires proof-holdings-sdk[jwt]) — no API call with your key. Reads two public
134
+ # documents: the JWKS for the signature, and the issuer's status list for revocation, which is
135
+ # checked by default.
136
+ result = await proof.proofs.verify_offline("eyJhbGciOi...")
137
+ if not result["valid"]:
138
+ # "revoked" | "suspended" | "expired" | "invalid" | "status_unavailable"
139
+ # | "key_revoked" | "jwks_stale" | "wrong_key_purpose" | "outside_key_window"
140
+ # | "token_lifetime_exceeded" | "unknown_key" — see "Signing-key rotation" below
141
+ print(result["reason"])
142
+
143
+ # "status_unavailable" means the revocation status could NOT be read — an unreachable list, one
144
+ # that failed its own checks, or a slot the list does not cover. It is a refusal to guess, not a
145
+ # claim that the proof is withdrawn. A token minted without a status-list slot stays valid and
146
+ # reports revocation_checked=False, exactly as the issuer's own validate answers for it.
147
+
148
+ # Signature only, for an air-gapped check against a warm key cache:
149
+ signature_only = await proof.proofs.verify_offline("eyJhbGciOi...", check_revocation=False)
150
+ assert signature_only["revocation_checked"] is False # never assume it was checked
151
+ ```
152
+
153
+ ### Signing-key rotation
154
+
155
+ `verify_offline` fetches and caches the issuer's key set (JWKS) itself and follows its rotation
156
+ rules. The cached set is reused for 10 minutes; a `kid` it does not know triggers one refetch, at
157
+ most once every 30 seconds.
158
+
159
+ - **`key_revoked`** — the issuer revoked the key that signed the token. The result carries
160
+ `pull_hint: {"issuer_origin", "handle"}`: where to fetch the re-signed token (`handle` is the
161
+ proof's `proof_id`, or a delegation's `sub`). The handle is read from the token itself, whose
162
+ signature a revoked key no longer vouches for: URL-encode it before building a request from it. A
163
+ revocation takes effect only after two key-set fetches at least 5 minutes apart both carry it, and
164
+ is then kept for a year.
165
+ - **`jwks_stale`** — the key set could not be refreshed and the cached copy is older than
166
+ `max_jwks_age` (seconds; default 24 hours, at most 25 days; `Proof(key, max_jwks_age=...)`). The
167
+ result carries `cache_age_seconds`. Call `refresh_jwks()` once the issuer is reachable.
168
+ - **`wrong_key_purpose`**, **`outside_key_window`**, **`token_lifetime_exceeded`** — the key belongs
169
+ to another token family, the token was signed outside the key's validity window, or it lives
170
+ longer than the key allows. **`unknown_key`** — no published key matches the token's `kid`.
171
+
172
+ Revoked-key tombstones are kept in memory only — they are not persisted, so a restarted process
173
+ learns them again from the issuer. `clear_tombstones()` forgets them on purpose. `refresh_jwks()`
174
+ makes the next verification refetch the key set (and drops the cached status list); it does not
175
+ forget tombstones. A tombstone naming a key last seen live is logged as a warning on the
176
+ `proof_sdk` logger.
177
+
178
+ ### Sessions (Phone-First Flow)
179
+
180
+ ```python
181
+ session = await proof.sessions.create(channel="telegram")
182
+ print(f"Deep link: {session['deep_link']}")
183
+ print(f"QR code: {session['qr_code']}")
184
+
185
+ # Poll until verified
186
+ result = await proof.sessions.wait_for_completion(session["id"])
187
+ ```
188
+
189
+ ### Webhook Deliveries
190
+
191
+ ```python
192
+ # List deliveries
193
+ deliveries = await proof.webhook_deliveries.list(status="failed")
194
+
195
+ # Retry a failed delivery
196
+ result = await proof.webhook_deliveries.retry("del_abc123")
197
+ ```
198
+
199
+ ## Error Handling
200
+
201
+ ```python
202
+ from proof_sdk import Proof, NotFoundError, RateLimitError, RequestTimeoutError, ProofError
203
+
204
+ async with Proof("pk_live_...") as proof:
205
+ try:
206
+ v = await proof.verifications.retrieve("nonexistent")
207
+ except NotFoundError as e:
208
+ print(f"Not found: {e.code}")
209
+ except RateLimitError:
210
+ print("Rate limited, try again later")
211
+ except ProofError as e:
212
+ print(f"API error {e.status_code}: {e.code} - {e}")
213
+ ```
214
+
215
+ ## Configuration
216
+
217
+ ```python
218
+ proof = Proof(
219
+ "pk_live_...",
220
+ base_url="https://api.proof.holdings", # Default
221
+ timeout=30.0, # Seconds, default 30
222
+ max_retries=2, # Default 2
223
+ max_jwks_age=86_400, # Offline verification stale bound, seconds (default 24h)
224
+ )
225
+ ```
226
+
227
+ ## Credentials
228
+
229
+ Sign in once with `npx @proof-holdings/mcp-server login` and construct the client with no key:
230
+
231
+ ```python
232
+ proof = Proof() # or Proof(base_url="...")
233
+ ```
234
+
235
+ When the key argument is omitted it is resolved in this order:
236
+
237
+ 1. `PROOF_API_KEY`, when set and non-empty.
238
+ 2. The entry for this client's base URL in `~/.proof-holdings/credentials`, the file the login
239
+ command writes. The file is keyed by base URL, so a key saved against staging is never sent
240
+ to production.
241
+ 3. Otherwise construction raises `ValueError`, naming the three roads: pass the key, set
242
+ `PROOF_API_KEY`, or run `npx @proof-holdings/mcp-server login`.
243
+
244
+ The base URL is the `base_url` argument, else a non-empty `PROOF_BASE_URL`, else
245
+ `https://api.proof.holdings`. An explicit key is used as given, and an explicit empty key
246
+ (`Proof("")`) is an error — neither reads the environment or the file.
247
+
248
+ ## Requirements
249
+
250
+ - Python >= 3.10
251
+ - httpx >= 0.25.0
252
+ - cryptography >= 41.0.0 (HITL message encryption)
253
+ - PyJWT (optional, the `jwt` extra, for offline verification)
@@ -0,0 +1,225 @@
1
+ # proof-holdings-sdk
2
+
3
+ Official Python SDK for the [proof.holdings](https://proof.holdings) verification API.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install proof-holdings-sdk
9
+ ```
10
+
11
+ For offline JWT verification:
12
+
13
+ ```bash
14
+ pip install 'proof-holdings-sdk[jwt]'
15
+ ```
16
+
17
+ ## Quick Start
18
+
19
+ ```python
20
+ import asyncio
21
+ from proof_sdk import Proof
22
+
23
+ async def main():
24
+ async with Proof("pk_live_...") as proof:
25
+ # Create a phone verification
26
+ v = await proof.verifications.create(
27
+ type="phone",
28
+ channel="whatsapp",
29
+ identifier="+1234567890",
30
+ )
31
+ print(f"Verification {v['id']} created: {v['status']}")
32
+
33
+ # Wait for user to complete verification
34
+ result = await proof.verifications.wait_for_completion(v["id"])
35
+ print(f"Result: {result['status']}")
36
+
37
+ asyncio.run(main())
38
+ ```
39
+
40
+ ## Resources
41
+
42
+ ### Verifications
43
+
44
+ ```python
45
+ # Create
46
+ v = await proof.verifications.create(type="domain", channel="dns", identifier="example.com")
47
+
48
+ # Retrieve
49
+ v = await proof.verifications.retrieve("ver_abc123")
50
+
51
+ # List with filters
52
+ page = await proof.verifications.list(status="verified", type="phone", limit=10)
53
+
54
+ # Trigger DNS/HTTP check
55
+ v = await proof.verifications.verify("ver_abc123")
56
+
57
+ # Submit OTP code
58
+ v = await proof.verifications.submit("ver_abc123", code="ABC123")
59
+
60
+ # Poll until complete
61
+ v = await proof.verifications.wait_for_completion("ver_abc123", interval=2.0, timeout=300.0)
62
+
63
+ # WABA (WhatsApp Business) verification
64
+ v = await proof.verifications.create(type="waba", channel="waba_otp", identifier="+37069199199")
65
+ # OTP is sent via WhatsApp — submit with proof.verifications.submit(v["id"], code="123456")
66
+
67
+ # Telegram Bot verification
68
+ v = await proof.verifications.create(
69
+ type="telegram_bot", channel="telegram_bot_token", identifier="@mybusinessbot"
70
+ )
71
+ # Submit bot token with proof.verifications.submit(v["id"], code="bot_token_here")
72
+ ```
73
+
74
+ ### Verification Requests (Multi-Asset)
75
+
76
+ ```python
77
+ # Create multi-asset request
78
+ req = await proof.verification_requests.create(
79
+ assets=[
80
+ {"type": "phone", "required": True},
81
+ {"type": "email", "identifier": "user@example.com"},
82
+ ],
83
+ reference_id="user_123",
84
+ callback_url="https://yourapp.com/webhook",
85
+ expires_in=86400, # 24 hours in seconds
86
+ )
87
+ print(f"Send user to: {req['verification_url']}")
88
+
89
+ # Poll until complete
90
+ result = await proof.verification_requests.wait_for_completion(req["id"])
91
+ ```
92
+
93
+ ### Proofs
94
+
95
+ ```python
96
+ # Validate online (checks revocation)
97
+ result = await proof.proofs.validate("eyJhbGciOi...")
98
+
99
+ # Revoke
100
+ response = await proof.proofs.revoke("ver_abc123", reason="User requested")
101
+
102
+ # Get revocation list
103
+ revoked = await proof.proofs.list_revoked()
104
+
105
+ # Offline verification (requires proof-holdings-sdk[jwt]) — no API call with your key. Reads two public
106
+ # documents: the JWKS for the signature, and the issuer's status list for revocation, which is
107
+ # checked by default.
108
+ result = await proof.proofs.verify_offline("eyJhbGciOi...")
109
+ if not result["valid"]:
110
+ # "revoked" | "suspended" | "expired" | "invalid" | "status_unavailable"
111
+ # | "key_revoked" | "jwks_stale" | "wrong_key_purpose" | "outside_key_window"
112
+ # | "token_lifetime_exceeded" | "unknown_key" — see "Signing-key rotation" below
113
+ print(result["reason"])
114
+
115
+ # "status_unavailable" means the revocation status could NOT be read — an unreachable list, one
116
+ # that failed its own checks, or a slot the list does not cover. It is a refusal to guess, not a
117
+ # claim that the proof is withdrawn. A token minted without a status-list slot stays valid and
118
+ # reports revocation_checked=False, exactly as the issuer's own validate answers for it.
119
+
120
+ # Signature only, for an air-gapped check against a warm key cache:
121
+ signature_only = await proof.proofs.verify_offline("eyJhbGciOi...", check_revocation=False)
122
+ assert signature_only["revocation_checked"] is False # never assume it was checked
123
+ ```
124
+
125
+ ### Signing-key rotation
126
+
127
+ `verify_offline` fetches and caches the issuer's key set (JWKS) itself and follows its rotation
128
+ rules. The cached set is reused for 10 minutes; a `kid` it does not know triggers one refetch, at
129
+ most once every 30 seconds.
130
+
131
+ - **`key_revoked`** — the issuer revoked the key that signed the token. The result carries
132
+ `pull_hint: {"issuer_origin", "handle"}`: where to fetch the re-signed token (`handle` is the
133
+ proof's `proof_id`, or a delegation's `sub`). The handle is read from the token itself, whose
134
+ signature a revoked key no longer vouches for: URL-encode it before building a request from it. A
135
+ revocation takes effect only after two key-set fetches at least 5 minutes apart both carry it, and
136
+ is then kept for a year.
137
+ - **`jwks_stale`** — the key set could not be refreshed and the cached copy is older than
138
+ `max_jwks_age` (seconds; default 24 hours, at most 25 days; `Proof(key, max_jwks_age=...)`). The
139
+ result carries `cache_age_seconds`. Call `refresh_jwks()` once the issuer is reachable.
140
+ - **`wrong_key_purpose`**, **`outside_key_window`**, **`token_lifetime_exceeded`** — the key belongs
141
+ to another token family, the token was signed outside the key's validity window, or it lives
142
+ longer than the key allows. **`unknown_key`** — no published key matches the token's `kid`.
143
+
144
+ Revoked-key tombstones are kept in memory only — they are not persisted, so a restarted process
145
+ learns them again from the issuer. `clear_tombstones()` forgets them on purpose. `refresh_jwks()`
146
+ makes the next verification refetch the key set (and drops the cached status list); it does not
147
+ forget tombstones. A tombstone naming a key last seen live is logged as a warning on the
148
+ `proof_sdk` logger.
149
+
150
+ ### Sessions (Phone-First Flow)
151
+
152
+ ```python
153
+ session = await proof.sessions.create(channel="telegram")
154
+ print(f"Deep link: {session['deep_link']}")
155
+ print(f"QR code: {session['qr_code']}")
156
+
157
+ # Poll until verified
158
+ result = await proof.sessions.wait_for_completion(session["id"])
159
+ ```
160
+
161
+ ### Webhook Deliveries
162
+
163
+ ```python
164
+ # List deliveries
165
+ deliveries = await proof.webhook_deliveries.list(status="failed")
166
+
167
+ # Retry a failed delivery
168
+ result = await proof.webhook_deliveries.retry("del_abc123")
169
+ ```
170
+
171
+ ## Error Handling
172
+
173
+ ```python
174
+ from proof_sdk import Proof, NotFoundError, RateLimitError, RequestTimeoutError, ProofError
175
+
176
+ async with Proof("pk_live_...") as proof:
177
+ try:
178
+ v = await proof.verifications.retrieve("nonexistent")
179
+ except NotFoundError as e:
180
+ print(f"Not found: {e.code}")
181
+ except RateLimitError:
182
+ print("Rate limited, try again later")
183
+ except ProofError as e:
184
+ print(f"API error {e.status_code}: {e.code} - {e}")
185
+ ```
186
+
187
+ ## Configuration
188
+
189
+ ```python
190
+ proof = Proof(
191
+ "pk_live_...",
192
+ base_url="https://api.proof.holdings", # Default
193
+ timeout=30.0, # Seconds, default 30
194
+ max_retries=2, # Default 2
195
+ max_jwks_age=86_400, # Offline verification stale bound, seconds (default 24h)
196
+ )
197
+ ```
198
+
199
+ ## Credentials
200
+
201
+ Sign in once with `npx @proof-holdings/mcp-server login` and construct the client with no key:
202
+
203
+ ```python
204
+ proof = Proof() # or Proof(base_url="...")
205
+ ```
206
+
207
+ When the key argument is omitted it is resolved in this order:
208
+
209
+ 1. `PROOF_API_KEY`, when set and non-empty.
210
+ 2. The entry for this client's base URL in `~/.proof-holdings/credentials`, the file the login
211
+ command writes. The file is keyed by base URL, so a key saved against staging is never sent
212
+ to production.
213
+ 3. Otherwise construction raises `ValueError`, naming the three roads: pass the key, set
214
+ `PROOF_API_KEY`, or run `npx @proof-holdings/mcp-server login`.
215
+
216
+ The base URL is the `base_url` argument, else a non-empty `PROOF_BASE_URL`, else
217
+ `https://api.proof.holdings`. An explicit key is used as given, and an explicit empty key
218
+ (`Proof("")`) is an error — neither reads the environment or the file.
219
+
220
+ ## Requirements
221
+
222
+ - Python >= 3.10
223
+ - httpx >= 0.25.0
224
+ - cryptography >= 41.0.0 (HITL message encryption)
225
+ - PyJWT (optional, the `jwt` extra, for offline verification)
@@ -0,0 +1,44 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "proof-holdings-sdk"
7
+ version = "1.1.0"
8
+ description = "Official Python SDK for the proof.holdings verification API"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "proof.holdings" },
14
+ ]
15
+ keywords = ["proof-holdings", "verification", "sdk", "authentication", "api", "jwt"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.10",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Typing :: Typed",
26
+ ]
27
+ dependencies = [
28
+ "httpx>=0.25.0",
29
+ "typing-extensions>=4.0",
30
+ "cryptography>=41.0.0",
31
+ ]
32
+
33
+ [project.optional-dependencies]
34
+ jwt = [
35
+ "PyJWT>=2.8.0",
36
+ ]
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["src/proof_sdk"]
40
+
41
+ [project.urls]
42
+ Homepage = "https://proof.holdings"
43
+ Documentation = "https://api.proof.holdings/api/docs/"
44
+ Repository = "https://github.com/ProofHoldings/sdk-python"
@@ -0,0 +1,2 @@
1
+ [pytest]
2
+ asyncio_mode = auto
@@ -0,0 +1,5 @@
1
+ {
2
+ "package": "proof-holdings-sdk",
3
+ "version": "1.1.0",
4
+ "sha256": "c0cdff1e9c195a192dfac801d9489614bf3a27ad95fa870aece8a27e27d22698"
5
+ }
@@ -0,0 +1,38 @@
1
+ """Official Python SDK for the proof.holdings verification API."""
2
+
3
+ from .client import Proof
4
+ from .credentials import LOGIN_COMMAND, ResolvedKey, resolve_api_key, resolve_base_url
5
+ from .errors import (
6
+ ProofError,
7
+ ValidationError,
8
+ AuthenticationError,
9
+ ForbiddenError,
10
+ NotFoundError,
11
+ ConflictError,
12
+ RateLimitError,
13
+ ServerError,
14
+ NetworkError,
15
+ RequestTimeoutError,
16
+ PollingTimeoutError,
17
+ )
18
+ from .version import VERSION
19
+
20
+ __all__ = [
21
+ "Proof",
22
+ "ProofError",
23
+ "ValidationError",
24
+ "AuthenticationError",
25
+ "ForbiddenError",
26
+ "NotFoundError",
27
+ "ConflictError",
28
+ "RateLimitError",
29
+ "ServerError",
30
+ "NetworkError",
31
+ "RequestTimeoutError",
32
+ "PollingTimeoutError",
33
+ "LOGIN_COMMAND",
34
+ "ResolvedKey",
35
+ "resolve_api_key",
36
+ "resolve_base_url",
37
+ "VERSION",
38
+ ]