didit-sdk 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.
didit/__init__.py ADDED
@@ -0,0 +1,64 @@
1
+ """Official-grade Python SDK for Didit Identity Verification & KYC.
2
+
3
+ Disclaimer:
4
+ This is an independent open-source library and is not officially affiliated
5
+ with or endorsed by Didit Protocol Inc.
6
+ """
7
+
8
+ from didit.client import AsyncDidit, Didit
9
+ from didit.config import DiditConfig
10
+ from didit.errors import (
11
+ DiditAPIError,
12
+ DiditAuthenticationError,
13
+ DiditConfigurationError,
14
+ DiditError,
15
+ DiditNotFoundError,
16
+ DiditRateLimitError,
17
+ DiditServerError,
18
+ DiditSignatureError,
19
+ DiditTimeoutError,
20
+ )
21
+ from didit.models.decision import (
22
+ AMLData,
23
+ BiometricsData,
24
+ DecisionResponse,
25
+ DocumentData,
26
+ ReviewData,
27
+ )
28
+ from didit.models.enums import Language, SessionStatus
29
+ from didit.models.session import CreateSessionRequest, SessionResponse
30
+ from didit.models.webhook import WebhookPayload
31
+ from didit.simulation import SimulatedAsyncDidit, SimulatedDidit
32
+ from didit.webhooks import parse_webhook_payload, verify_webhook_signature
33
+
34
+ __version__ = "0.1.0"
35
+
36
+ __all__ = [
37
+ "AMLData",
38
+ "AsyncDidit",
39
+ "BiometricsData",
40
+ "CreateSessionRequest",
41
+ "DecisionResponse",
42
+ "Didit",
43
+ "DiditAPIError",
44
+ "DiditAuthenticationError",
45
+ "DiditConfig",
46
+ "DiditConfigurationError",
47
+ "DiditError",
48
+ "DiditNotFoundError",
49
+ "DiditRateLimitError",
50
+ "DiditServerError",
51
+ "DiditSignatureError",
52
+ "DiditTimeoutError",
53
+ "DocumentData",
54
+ "Language",
55
+ "ReviewData",
56
+ "SessionResponse",
57
+ "SessionStatus",
58
+ "SimulatedAsyncDidit",
59
+ "SimulatedDidit",
60
+ "WebhookPayload",
61
+ "__version__",
62
+ "parse_webhook_payload",
63
+ "verify_webhook_signature",
64
+ ]
didit/client.py ADDED
@@ -0,0 +1,204 @@
1
+ """Synchronous and asynchronous Didit client implementations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from didit.config import DiditConfig
11
+ from didit.errors import DiditConfigurationError
12
+ from didit.models.webhook import WebhookPayload
13
+ from didit.resources.sessions import AsyncSessionsResource, SessionsResource
14
+ from didit.webhooks import parse_webhook_payload, verify_webhook_signature
15
+
16
+
17
+ class Didit:
18
+ """Synchronous client for the Didit Verification API."""
19
+
20
+ def __init__(
21
+ self,
22
+ api_key: str | None = None,
23
+ *,
24
+ base_url: str | None = None,
25
+ timeout: float | None = None,
26
+ max_retries: int | None = None,
27
+ webhook_secret: str | None = None,
28
+ config: DiditConfig | None = None,
29
+ http_client: httpx.Client | None = None,
30
+ ) -> None:
31
+ if config is not None:
32
+ self._config = config
33
+ else:
34
+ self._config = DiditConfig.from_env(
35
+ api_key=api_key,
36
+ base_url=base_url,
37
+ timeout=timeout,
38
+ max_retries=max_retries,
39
+ webhook_secret=webhook_secret,
40
+ )
41
+
42
+ self._manage_http = http_client is None
43
+ self._http = http_client or httpx.Client(
44
+ base_url=self._config.base_url,
45
+ headers={
46
+ "x-api-key": self._config.api_key,
47
+ "Accept": "application/json",
48
+ },
49
+ timeout=self._config.timeout,
50
+ )
51
+
52
+ self.sessions = SessionsResource(self._http)
53
+
54
+ @property
55
+ def config(self) -> DiditConfig:
56
+ """Client configuration instance."""
57
+ return self._config
58
+
59
+ @property
60
+ def http_client(self) -> httpx.Client:
61
+ """Underlying httpx.Client instance."""
62
+ return self._http
63
+
64
+ def verify_webhook(
65
+ self,
66
+ raw_body: bytes,
67
+ headers: Mapping[str, str],
68
+ *,
69
+ secret: str | None = None,
70
+ max_age_seconds: int | None = None,
71
+ ) -> bool:
72
+ """Verify an incoming webhook's signature and timestamp freshness."""
73
+ wh_secret = secret or self._config.webhook_secret
74
+ if not wh_secret:
75
+ raise DiditConfigurationError(
76
+ "No webhook_secret configured on client. "
77
+ "Provide secret parameter or configure DIDIT_WEBHOOK_SECRET."
78
+ )
79
+ return verify_webhook_signature(
80
+ raw_body, headers, wh_secret, max_age_seconds=max_age_seconds
81
+ )
82
+
83
+ def parse_webhook(
84
+ self,
85
+ raw_body: bytes,
86
+ headers: Mapping[str, str],
87
+ *,
88
+ secret: str | None = None,
89
+ max_age_seconds: int | None = None,
90
+ ) -> WebhookPayload:
91
+ """Verify and parse an incoming webhook payload into a WebhookPayload object."""
92
+ wh_secret = secret or self._config.webhook_secret
93
+ if not wh_secret:
94
+ raise DiditConfigurationError(
95
+ "No webhook_secret configured on client. "
96
+ "Provide secret parameter or configure DIDIT_WEBHOOK_SECRET."
97
+ )
98
+ return parse_webhook_payload(raw_body, headers, wh_secret, max_age_seconds=max_age_seconds)
99
+
100
+ def close(self) -> None:
101
+ """Close the underlying HTTP client."""
102
+ if self._manage_http:
103
+ self._http.close()
104
+
105
+ def __enter__(self) -> Didit:
106
+ return self
107
+
108
+ def __exit__(self, *args: Any) -> None:
109
+ self.close()
110
+
111
+
112
+ class AsyncDidit:
113
+ """Asynchronous client for the Didit Verification API."""
114
+
115
+ def __init__(
116
+ self,
117
+ api_key: str | None = None,
118
+ *,
119
+ base_url: str | None = None,
120
+ timeout: float | None = None,
121
+ max_retries: int | None = None,
122
+ webhook_secret: str | None = None,
123
+ config: DiditConfig | None = None,
124
+ http_client: httpx.AsyncClient | None = None,
125
+ ) -> None:
126
+ if config is not None:
127
+ self._config = config
128
+ else:
129
+ self._config = DiditConfig.from_env(
130
+ api_key=api_key,
131
+ base_url=base_url,
132
+ timeout=timeout,
133
+ max_retries=max_retries,
134
+ webhook_secret=webhook_secret,
135
+ )
136
+
137
+ self._manage_http = http_client is None
138
+ self._http = http_client or httpx.AsyncClient(
139
+ base_url=self._config.base_url,
140
+ headers={
141
+ "x-api-key": self._config.api_key,
142
+ "Accept": "application/json",
143
+ },
144
+ timeout=self._config.timeout,
145
+ )
146
+
147
+ self.sessions = AsyncSessionsResource(self._http)
148
+
149
+ @property
150
+ def config(self) -> DiditConfig:
151
+ """Client configuration instance."""
152
+ return self._config
153
+
154
+ @property
155
+ def http_client(self) -> httpx.AsyncClient:
156
+ """Underlying httpx.AsyncClient instance."""
157
+ return self._http
158
+
159
+ def verify_webhook(
160
+ self,
161
+ raw_body: bytes,
162
+ headers: Mapping[str, str],
163
+ *,
164
+ secret: str | None = None,
165
+ max_age_seconds: int | None = None,
166
+ ) -> bool:
167
+ """Verify an incoming webhook's signature and timestamp freshness."""
168
+ wh_secret = secret or self._config.webhook_secret
169
+ if not wh_secret:
170
+ raise DiditConfigurationError(
171
+ "No webhook_secret configured on client. "
172
+ "Provide secret parameter or configure DIDIT_WEBHOOK_SECRET."
173
+ )
174
+ return verify_webhook_signature(
175
+ raw_body, headers, wh_secret, max_age_seconds=max_age_seconds
176
+ )
177
+
178
+ def parse_webhook(
179
+ self,
180
+ raw_body: bytes,
181
+ headers: Mapping[str, str],
182
+ *,
183
+ secret: str | None = None,
184
+ max_age_seconds: int | None = None,
185
+ ) -> WebhookPayload:
186
+ """Verify and parse an incoming webhook payload into a WebhookPayload object."""
187
+ wh_secret = secret or self._config.webhook_secret
188
+ if not wh_secret:
189
+ raise DiditConfigurationError(
190
+ "No webhook_secret configured on client. "
191
+ "Provide secret parameter or configure DIDIT_WEBHOOK_SECRET."
192
+ )
193
+ return parse_webhook_payload(raw_body, headers, wh_secret, max_age_seconds=max_age_seconds)
194
+
195
+ async def aclose(self) -> None:
196
+ """Close the underlying asynchronous HTTP client."""
197
+ if self._manage_http:
198
+ await self._http.aclose()
199
+
200
+ async def __aenter__(self) -> AsyncDidit:
201
+ return self
202
+
203
+ async def __aexit__(self, *args: Any) -> None:
204
+ await self.aclose()
didit/config.py ADDED
@@ -0,0 +1,68 @@
1
+ """Configuration and global defaults for Didit SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from dataclasses import dataclass
7
+
8
+ DEFAULT_BASE_URL: str = "https://verification.didit.me/v3"
9
+ DEFAULT_TIMEOUT: float = 30.0
10
+ DEFAULT_MAX_RETRIES: int = 2
11
+ DEFAULT_WEBHOOK_MAX_AGE_SECONDS: int = 300 # 5 minutes
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class DiditConfig:
16
+ """Immutable client configuration."""
17
+
18
+ api_key: str
19
+ base_url: str = DEFAULT_BASE_URL
20
+ timeout: float = DEFAULT_TIMEOUT
21
+ max_retries: int = DEFAULT_MAX_RETRIES
22
+ webhook_secret: str | None = None
23
+
24
+ @classmethod
25
+ def from_env(
26
+ cls,
27
+ *,
28
+ api_key: str | None = None,
29
+ base_url: str | None = None,
30
+ timeout: float | None = None,
31
+ max_retries: int | None = None,
32
+ webhook_secret: str | None = None,
33
+ ) -> DiditConfig:
34
+ """Resolve configuration falling back to environment variables."""
35
+ resolved_key = api_key or os.environ.get("DIDIT_API_KEY")
36
+ if not resolved_key:
37
+ from didit.errors import DiditConfigurationError
38
+
39
+ raise DiditConfigurationError(
40
+ "Missing Didit API key. "
41
+ "Provide api_key or set the DIDIT_API_KEY environment variable."
42
+ )
43
+
44
+ resolved_base_url = (
45
+ base_url or os.environ.get("DIDIT_BASE_URL") or DEFAULT_BASE_URL
46
+ ).rstrip("/")
47
+
48
+ resolved_timeout = (
49
+ timeout
50
+ if timeout is not None
51
+ else float(os.environ.get("DIDIT_TIMEOUT", DEFAULT_TIMEOUT))
52
+ )
53
+
54
+ resolved_retries = (
55
+ max_retries
56
+ if max_retries is not None
57
+ else int(os.environ.get("DIDIT_MAX_RETRIES", DEFAULT_MAX_RETRIES))
58
+ )
59
+
60
+ resolved_secret = webhook_secret or os.environ.get("DIDIT_WEBHOOK_SECRET")
61
+
62
+ return cls(
63
+ api_key=resolved_key,
64
+ base_url=resolved_base_url,
65
+ timeout=resolved_timeout,
66
+ max_retries=resolved_retries,
67
+ webhook_secret=resolved_secret,
68
+ )
didit/errors.py ADDED
@@ -0,0 +1,83 @@
1
+ """Exception hierarchy for didit-sdk."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+
9
+ class DiditError(Exception):
10
+ """Base exception for all Didit SDK errors."""
11
+
12
+
13
+ class DiditConfigurationError(DiditError):
14
+ """Raised when the client is configured with missing or invalid parameters."""
15
+
16
+
17
+ class DiditSignatureError(DiditError):
18
+ """Raised when a webhook signature fails cryptographic verification or freshness check."""
19
+
20
+
21
+ class DiditTimeoutError(DiditError):
22
+ """Raised when an operation such as polling exceeds the configured timeout limit."""
23
+
24
+
25
+ class DiditAPIError(DiditError):
26
+ """Raised when the Didit API returns an HTTP error status code."""
27
+
28
+ def __init__(
29
+ self,
30
+ message: str,
31
+ *,
32
+ status_code: int,
33
+ response_body: str | None = None,
34
+ headers: Mapping[str, str] | None = None,
35
+ error_code: str | None = None,
36
+ details: Any = None,
37
+ ) -> None:
38
+ super().__init__(message)
39
+ self.status_code = status_code
40
+ self.response_body = response_body
41
+ self.headers = dict(headers) if headers else {}
42
+ self.error_code = error_code
43
+ self.details = details
44
+
45
+ def __repr__(self) -> str:
46
+ return (
47
+ f"{self.__class__.__name__}(status_code={self.status_code}, "
48
+ f"error_code={self.error_code!r}, message={str(self)!r})"
49
+ )
50
+
51
+
52
+ class DiditAuthenticationError(DiditAPIError):
53
+ """Raised on 401 Unauthorized or 403 Forbidden responses (invalid API key)."""
54
+
55
+
56
+ class DiditNotFoundError(DiditAPIError):
57
+ """Raised on 404 Not Found responses (session or resource does not exist)."""
58
+
59
+
60
+ class DiditRateLimitError(DiditAPIError):
61
+ """Raised on 429 Too Many Requests responses."""
62
+
63
+ def __init__(
64
+ self,
65
+ message: str,
66
+ *,
67
+ status_code: int = 429,
68
+ response_body: str | None = None,
69
+ headers: Mapping[str, str] | None = None,
70
+ retry_after: float | None = None,
71
+ ) -> None:
72
+ super().__init__(
73
+ message,
74
+ status_code=status_code,
75
+ response_body=response_body,
76
+ headers=headers,
77
+ error_code="RATE_LIMIT_EXCEEDED",
78
+ )
79
+ self.retry_after = retry_after
80
+
81
+
82
+ class DiditServerError(DiditAPIError):
83
+ """Raised on 5xx Internal Server Error responses."""
@@ -0,0 +1,5 @@
1
+ """Web framework integrations for Didit SDK."""
2
+
3
+ from didit.integrations.fastapi import DiditWebhookGuard
4
+
5
+ __all__ = ["DiditWebhookGuard"]
@@ -0,0 +1,72 @@
1
+ """FastAPI integration utilities and webhook security guard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+
8
+ from starlette.requests import Request
9
+
10
+ from didit.config import DEFAULT_WEBHOOK_MAX_AGE_SECONDS
11
+ from didit.errors import DiditConfigurationError
12
+ from didit.models.webhook import WebhookPayload
13
+ from didit.webhooks import verify_webhook_signature
14
+
15
+
16
+ class DiditWebhookGuard:
17
+ """FastAPI dependency for verifying and parsing Didit webhook requests.
18
+
19
+ Example:
20
+ ```python
21
+ guard = DiditWebhookGuard(secret="whsec_...")
22
+
23
+ @app.post("/webhooks/didit")
24
+ async def handle_webhook(payload: WebhookPayload = Depends(guard)):
25
+ if payload.status == SessionStatus.APPROVED:
26
+ # Process approved KYC verification
27
+ ...
28
+ ```
29
+ """
30
+
31
+ def __init__(
32
+ self,
33
+ secret: str | None = None,
34
+ *,
35
+ max_age_seconds: int = DEFAULT_WEBHOOK_MAX_AGE_SECONDS,
36
+ ) -> None:
37
+ resolved_secret = secret or os.environ.get("DIDIT_WEBHOOK_SECRET")
38
+ if not resolved_secret:
39
+ raise DiditConfigurationError(
40
+ "Missing webhook secret. "
41
+ "Provide secret parameter or set DIDIT_WEBHOOK_SECRET environment variable."
42
+ )
43
+ self.secret: str = resolved_secret
44
+ self.max_age_seconds = max_age_seconds
45
+
46
+ async def __call__(self, request: Request) -> WebhookPayload:
47
+ from fastapi import HTTPException
48
+
49
+ raw_body = await request.body()
50
+ try:
51
+ body_dict = json.loads(raw_body.decode("utf-8"))
52
+ except (ValueError, UnicodeDecodeError):
53
+ raise HTTPException(status_code=400, detail="Malformed JSON in webhook body") from None
54
+
55
+ if not isinstance(body_dict, dict):
56
+ raise HTTPException(status_code=400, detail="Malformed JSON in webhook body")
57
+
58
+ is_valid = verify_webhook_signature(
59
+ raw_body,
60
+ request.headers,
61
+ self.secret,
62
+ max_age_seconds=self.max_age_seconds,
63
+ )
64
+ if not is_valid:
65
+ raise HTTPException(
66
+ status_code=401,
67
+ detail="Invalid webhook signature or expired timestamp",
68
+ )
69
+
70
+ payload = WebhookPayload.model_validate(body_dict)
71
+ payload.raw_data = body_dict
72
+ return payload
@@ -0,0 +1,25 @@
1
+ """Domain and data models for Didit SDK."""
2
+
3
+ from didit.models.decision import (
4
+ AMLData,
5
+ BiometricsData,
6
+ DecisionResponse,
7
+ DocumentData,
8
+ ReviewData,
9
+ )
10
+ from didit.models.enums import Language, SessionStatus
11
+ from didit.models.session import CreateSessionRequest, SessionResponse
12
+ from didit.models.webhook import WebhookPayload
13
+
14
+ __all__ = [
15
+ "AMLData",
16
+ "BiometricsData",
17
+ "CreateSessionRequest",
18
+ "DecisionResponse",
19
+ "DocumentData",
20
+ "Language",
21
+ "ReviewData",
22
+ "SessionResponse",
23
+ "SessionStatus",
24
+ "WebhookPayload",
25
+ ]
@@ -0,0 +1,85 @@
1
+ """Decision and verification result data models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from pydantic import BaseModel, ConfigDict, Field
8
+
9
+ from didit.models.enums import SessionStatus
10
+
11
+
12
+ class DocumentData(BaseModel):
13
+ """Document extraction and validation data."""
14
+
15
+ model_config = ConfigDict(extra="ignore")
16
+
17
+ document_type: str | None = Field(
18
+ default=None, description="e.g. passport, id_card, drivers_license"
19
+ )
20
+ country: str | None = Field(default=None, description="ISO-3 country code")
21
+ document_number: str | None = Field(default=None, description="Masked or raw document number")
22
+ first_name: str | None = Field(default=None, description="Given names")
23
+ last_name: str | None = Field(default=None, description="Family names")
24
+ date_of_birth: str | None = Field(default=None, description="YYYY-MM-DD format")
25
+ expiration_date: str | None = Field(default=None, description="YYYY-MM-DD format")
26
+ is_valid: bool | None = Field(
27
+ default=None, description="Whether document authenticity checks passed"
28
+ )
29
+
30
+
31
+ class BiometricsData(BaseModel):
32
+ """Facial matching and liveness assessment."""
33
+
34
+ model_config = ConfigDict(extra="ignore")
35
+
36
+ face_match: bool | None = Field(default=None, description="Face match against document photo")
37
+ liveness_check: bool | None = Field(
38
+ default=None, description="Active or passive liveness check"
39
+ )
40
+ score: float | None = Field(
41
+ default=None, description="Biometric confidence score between 0.0 and 1.0"
42
+ )
43
+
44
+
45
+ class AMLData(BaseModel):
46
+ """Anti-Money Laundering and Watchlist screening results."""
47
+
48
+ model_config = ConfigDict(extra="ignore")
49
+
50
+ pep_detected: bool | None = Field(
51
+ default=None, description="Politically Exposed Person detection"
52
+ )
53
+ sanctions_detected: bool | None = Field(
54
+ default=None, description="International sanctions list match"
55
+ )
56
+ adverse_media_detected: bool | None = Field(default=None, description="Adverse media match")
57
+
58
+
59
+ class ReviewData(BaseModel):
60
+ """Manual or compliance agent review information."""
61
+
62
+ model_config = ConfigDict(extra="ignore")
63
+
64
+ reviewed_by: str | None = Field(default=None, description="Identifier of the reviewer")
65
+ decision_reason: str | None = Field(default=None, description="Reviewer explanation or notes")
66
+
67
+
68
+ class DecisionResponse(BaseModel):
69
+ """Complete verification decision returned by Didit."""
70
+
71
+ model_config = ConfigDict(extra="ignore")
72
+
73
+ session_id: str = Field(..., description="Unique session identifier")
74
+ status: SessionStatus = Field(..., description="Final or current verification status")
75
+ workflow_id: str | None = Field(default=None, description="Associated workflow identifier")
76
+ vendor_data: str | None = Field(default=None, description="Echoed vendor reference")
77
+ document: DocumentData | None = Field(default=None, description="Document verification details")
78
+ biometrics: BiometricsData | None = Field(
79
+ default=None, description="Biometric matching details"
80
+ )
81
+ aml: AMLData | None = Field(default=None, description="AML / Watchlist screening details")
82
+ review: ReviewData | None = Field(default=None, description="Human review details")
83
+ raw_data: dict[str, Any] | None = Field(
84
+ default=None, description="Raw JSON payload received from Didit"
85
+ )
didit/models/enums.py ADDED
@@ -0,0 +1,39 @@
1
+ """Enumerations for Didit verification statuses and configurations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import Enum
6
+
7
+
8
+ class SessionStatus(str, Enum):
9
+ """Case-sensitive session statuses defined by Didit's Sessions API."""
10
+
11
+ NOT_STARTED = "Not Started"
12
+ IN_PROGRESS = "In Progress"
13
+ IN_REVIEW = "In Review"
14
+ APPROVED = "Approved"
15
+ DECLINED = "Declined"
16
+
17
+ @property
18
+ def is_terminal(self) -> bool:
19
+ """Return True if the verification workflow has finished (Approved or Declined)."""
20
+ return self in (SessionStatus.APPROVED, SessionStatus.DECLINED)
21
+
22
+ @property
23
+ def is_in_review(self) -> bool:
24
+ """Return True if the verification requires manual human review."""
25
+ return self == SessionStatus.IN_REVIEW
26
+
27
+
28
+ class Language(str, Enum):
29
+ """Supported UI languages for Didit hosted verification pages."""
30
+
31
+ EN = "en"
32
+ ES = "es"
33
+ FR = "fr"
34
+ DE = "de"
35
+ IT = "it"
36
+ PT = "pt"
37
+ CA = "ca"
38
+ EU = "eu"
39
+ GL = "gl"