easycontract 0.1.0__tar.gz → 0.1.1__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.
@@ -0,0 +1,114 @@
1
+ # Changelog
2
+
3
+ All notable changes to the `easycontract` Python SDK.
4
+
5
+ ## 0.1.1 (2026-10-07)
6
+
7
+ **Security (audit 2026-10-07)**
8
+
9
+ - `requests>=2.33.0,<3` (was `>=2.31`): older `requests` releases are affected
10
+ by CVE-2024-35195, CVE-2024-47081 and CVE-2026-25645.
11
+ - Resource identifiers are validated as UUIDs before a path is built, and
12
+ free-text segments (catalog keys, language codes) are percent-encoded: an
13
+ id such as `"abc/../../webhooks/123"` used to make `requests` resolve the
14
+ dot segments and call ANOTHER endpoint with the caller's API key. Invalid
15
+ ids now raise `ValueError` before any request is sent.
16
+
17
+ **Features**
18
+
19
+ - Asynchronous malware scan of uploaded documents: `document["scan"]`
20
+ (`status` pending/clean/infected/error/not_scanned, `engine`, `signature`,
21
+ `scanned_at`), event `document.scanned`, and
22
+ `client.envelopes.rescan_document(envelope_id, document_id)` to retry a
23
+ failed scan. Signers only see and sign documents once they are clean.
24
+ - Invitation e-mails are held back until the malware verdict:
25
+ `signer["link_delivery"] == "pending_scan"` while waiting (the `sign_url`
26
+ values stay valid); `envelope["void_reason_code"] == "malware_detected"`
27
+ when the platform stopped an envelope.
28
+ - Identity images retention mode: `envelopes.create(..., kyc_images_retention=
29
+ "after_in_person_check")` (also on `templates.create`/`update` and
30
+ `templates.create_envelope`) keeps a verified signer's document/selfie
31
+ images until the requester records the face-to-face check with
32
+ `client.identity.confirm_in_person(envelope_id, signer_id, note=...)`
33
+ (event `signer.kyc_confirmed_in_person`); the default "after_review" purges
34
+ N days after the reviewer's decision. `identity_verification` carries
35
+ `images_retention` and `confirmed_in_person`.
36
+ - Embedded signing (§10.3): `client.organization.update(embed_allowed_origins=[...])`
37
+ registers the web origins allowed to frame the signer flow, and
38
+ `client.envelopes.create_embedded_session(envelope_id, signer_id, origin=...,
39
+ return_url=..., language=..., ttl_minutes=...)` mints the ephemeral `url`
40
+ (token `em_…`) to open in an `<iframe allow="camera">` (postMessage events
41
+ `ready`/`signed`/`declined`/`expired`) or by redirect (`return_url`). The
42
+ organization exposes `embed_allowed_origins`.
43
+ - Biometrics (Enterprise): `envelopes.create(..., biometrics=True)` and
44
+ `templates.create_envelope(..., biometrics=True)` offer signers the optional
45
+ stroke capture; `signer["biometrics"]` carries its status (hash, points,
46
+ consent version, `erased_at`), never the vector;
47
+ `envelopes.erase_signer_biometrics(envelope_id, signer_id, reason=...)`
48
+ erases it (GDPR art. 17); `download_evidence(..., include_biometrics=True)`
49
+ adds the vectors to the ZIP on explicit request.
50
+ - `envelopes.create(..., language=...)` sets the document language.
51
+ - Identity proofing (Pro+): `auth_methods` accepts `"kyc"` on signers and
52
+ template roles; `signer["identity_verification"]` carries the status
53
+ (provider, method, document type/country, name match, hash), never the
54
+ provider payload; `download_evidence(..., include_identity_proofs=True)` adds
55
+ the full results to the ZIP on explicit request. The organization exposes
56
+ `kyc` (switch state) and `features.kyc`.
57
+ - In-house identity review: `client.identity.pending_reviews()`,
58
+ `client.identity.get(envelope_id, signer_id)`,
59
+ `client.identity.download_document(envelope_id, signer_id, document_id)`
60
+ (every download is recorded in the evidence trail) and
61
+ `client.identity.review(envelope_id, signer_id, decision="approve"|"reject",
62
+ verified_name=..., reason=...)`. `identity_verification` now carries
63
+ `checks`, `documents` (hashes, never the images), `reviewed_by` and
64
+ `images_purge_at`; `download_evidence(..., include_identity_proofs=True)`
65
+ also adds the images still retained.
66
+ - `envelopes.add_signer(..., kyc_document_type=..., kyc_document_number=...)`
67
+ registers what the requester knows about the signer's document; a
68
+ different declaration is refused at capture. Signers expose
69
+ `kyc_expected_document_type` and `kyc_expected_document_number_set`.
70
+ - Identity endpoints (`identity.pending_reviews()`, `identity.get()`) now
71
+ return `declared_document_number`, `verified_name` and `signer_full_name`
72
+ so the tenant can validate on its own platform; the signer summary inside
73
+ envelopes keeps status only. New outbound events `signer.kyc_submitted`,
74
+ `signer.kyc_verified` and `signer.kyc_failed` (webhooks and `GET /events/`).
75
+ - Retention: `envelopes.create(..., document_type=...)` (catalogue in
76
+ `retention.document_types()`, unsuitable types are refused, caution types
77
+ carry `document_type_warning`), `envelopes.set_legal_hold(envelope_id,
78
+ reason=...)` / `release_legal_hold()`, `retention.overview(within_days=90)`
79
+ and `organization.update(retention_policies={...})`. Envelopes expose
80
+ `document_type`, `document_type_warning` and `legal_hold`; new outbound
81
+ event `envelope.blocked` is documented.
82
+
83
+ - `envelopes.download_final_document(envelope_id, document_id, to=None)`:
84
+ downloads the stamped final PDF of one document (PAdES-B-LTA sealed on the
85
+ Enterprise plan). Seal details are exposed under `documents[].final.pades`
86
+ when fetching the envelope with `expand=["documents"]`.
87
+
88
+ ## 0.1.0 (2026-09-25) — published: https://pypi.org/project/easycontract/0.1.0/
89
+
90
+ Initial release.
91
+
92
+ - `EasyContract` client: `X-Api-Key` auth, per-POST `Idempotency-Key`,
93
+ bounded retries (429 + GET 5xx) honoring `Retry-After`, typed RFC 7807
94
+ error hierarchy, context-manager support (`close()`).
95
+ - Full resource coverage of the v1 API (envelopes, templates, webhooks,
96
+ events, organization, members, invitations) with cursor auto-paging and
97
+ anti-loop guards.
98
+ - `webhooks.verify_signature`: fail-closed HMAC-SHA256 with timestamp
99
+ replay check.
100
+
101
+ ### Behaviour notes
102
+
103
+ - `idempotency_key` accepts a non-empty `str` (sent as-is), `None` or
104
+ `True` (a random UUID is generated per call), or `False` (header
105
+ omitted). Any other value is coerced with `str()` (e.g. a `uuid.UUID`
106
+ is sent as its string form).
107
+ - File uploads: seekable files are re-wound and re-sent in full on
108
+ retries; non-seekable streams (pipes, sockets) are sent exactly once —
109
+ a 429 on such an upload raises `RateLimitError` instead of retrying
110
+ with an empty body.
111
+ - `webhooks.verify_signature` verifies over the raw request bytes; the
112
+ payload is decoded only after the HMAC check passes.
113
+ - `tolerance=None` in `verify_signature` disables the timestamp replay
114
+ check (opt-out; not recommended).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: easycontract
3
- Version: 0.1.0
3
+ Version: 0.1.1
4
4
  Summary: SDK oficial de Python para el API de easycontract (firma electrónica eIDAS)
5
5
  Project-URL: Documentation, https://easycontract.online/docs/api
6
6
  Author-email: Román Ramírez <roman@easycontract.online>
@@ -18,7 +18,7 @@ Classifier: Programming Language :: Python :: 3.13
18
18
  Classifier: Topic :: Security
19
19
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
20
  Requires-Python: >=3.10
21
- Requires-Dist: requests<3,>=2.31
21
+ Requires-Dist: requests<3,>=2.33.0
22
22
  Description-Content-Type: text/markdown
23
23
 
24
24
  # easycontract — SDK de Python
@@ -81,8 +81,17 @@ client.templates.add_field(
81
81
 
82
82
  ## Receta 2 — Firma embebida (embedded signing)
83
83
 
84
- Con `deliver_emails=False` recibes el `sign_url` de un solo uso por firmante y
85
- lo integras en tu propia aplicación (redirect o iframe):
84
+ El firmante firma dentro de tu aplicación (el caso RootedArbiter). Tres pasos:
85
+
86
+ 1. Registra una vez los orígenes de tu web (https; `http://localhost:…` solo
87
+ en desarrollo). La lista se sustituye entera:
88
+
89
+ ```python
90
+ client.organization.update(embed_allowed_origins=["https://arbiter.rootedcon.com"])
91
+ ```
92
+
93
+ 2. Envía sin emails y, cuando el ponente esté delante de tu página, acuña una
94
+ sesión efímera para ESE firmante (60 minutos por defecto, caduca al firmar):
86
95
 
87
96
  ```python
88
97
  result = client.templates.create_envelope(
@@ -91,12 +100,41 @@ result = client.templates.create_envelope(
91
100
  send=True,
92
101
  deliver_emails=False,
93
102
  )
94
- sign_url = result["signers"][0]["sign_url"]
95
- # → redirige al ponente a sign_url sin salir de tu flujo
103
+ signer = result["signers"][0]
104
+ session = client.envelopes.create_embedded_session(
105
+ result["envelope"]["id"],
106
+ signer["id"],
107
+ origin="https://arbiter.rootedcon.com",
108
+ return_url="https://arbiter.rootedcon.com/contratos/42/firmado", # opcional
109
+ language="es",
110
+ )
111
+ embed_url = session["url"] # token em_…: un firmante, un origen, caduca
112
+ ```
113
+
114
+ 3. Ábrela en un iframe y escucha los eventos, o redirige al ponente y recíbelo
115
+ de vuelta en `return_url` con `?easycontract_event=signed&envelope_id=…&signer_id=…`:
116
+
117
+ ```html
118
+ <iframe src="{{ embed_url }}" allow="camera" style="width:100%;height:80vh;border:0"></iframe>
119
+ <script>
120
+ window.addEventListener("message", (e) => {
121
+ if (e.origin !== "https://app.easycontract.online") return; // el origen de easycontract
122
+ if (e.data?.source !== "easycontract") return;
123
+ // e.data.event ∈ ready | signed | declined | expired; e.data.envelope_id, signer_id
124
+ });
125
+ </script>
96
126
  ```
97
127
 
98
- El enlace es personal, caduca (14 días por defecto, `expires_in_days` para
99
- cambiarlo) y queda invalidado al firmar o rechazar.
128
+ La página de firma responde con `Content-Security-Policy: frame-ancestors
129
+ <tu origen>` y no depende de cookies (funciona con las de terceros
130
+ bloqueadas); el OTP y la verificación de identidad, si el firmante los exige,
131
+ se piden dentro del mismo marco (`allow="camera"` para la captura del
132
+ documento). El `postMessage` es solo señal de interfaz: confirma siempre en
133
+ servidor con el webhook `signer.signed` o `client.envelopes.get(...)`. Cada
134
+ sesión y su apertura (con el origen) quedan en el expediente de evidencias.
135
+
136
+ El `sign_url` clásico de `deliver_emails=False` sigue existiendo para el
137
+ redirect simple (14 días, `expires_in_days` para cambiarlo).
100
138
 
101
139
  ## Receta 3 — Webhooks vs. polling
102
140
 
@@ -174,3 +212,42 @@ members = client.members.list()
174
212
  invite = client.invitations.create(email="colega@example.com", role="member")
175
213
  # invite["accept_url"] — entrégalo tú o deja que easycontract lo envíe por email
176
214
  ```
215
+
216
+ ## Validar la identidad del firmante en tu plataforma (KYC)
217
+
218
+ Con el proceso propio de easycontract, el firmante fotografía su documento y se hace un
219
+ selfie; la decisión es de la organización. Puedes tomarla desde tu propio sistema:
220
+
221
+ ```python
222
+ from easycontract import EasyContract
223
+
224
+ client = EasyContract(api_key="ec_live_...")
225
+
226
+ # 1. Suscríbete a los eventos (o sondea GET /events/): signer.kyc_submitted avisa de
227
+ # cada caso pendiente; signer.kyc_verified / signer.kyc_failed, de la decisión.
228
+ for case in client.identity.pending_reviews()["data"]:
229
+ envelope_id, signer = case["envelope_id"], case["signer"]
230
+ identity = case["identity_verification"] # estado, comprobaciones, datos declarados
231
+
232
+ # 2. Recupera las imágenes (cada descarga queda en el audit trail del documento)
233
+ for document in identity["documents"]:
234
+ client.identity.download_document(
235
+ envelope_id, signer["id"], document["id"], to=f"/tmp/{document['kind']}.jpg"
236
+ )
237
+
238
+ # 3. Valida con tus reglas: número declarado, caducidad, cotejo visual o tu propio motor
239
+ ok = identity["declared_document_number"] == mi_registro[signer["email"]].dni
240
+
241
+ # 4. Decide; el firmante recibe el resultado por correo
242
+ client.identity.review(
243
+ envelope_id,
244
+ signer["id"],
245
+ decision="approve" if ok else "reject",
246
+ verified_name=signer["full_name"],
247
+ reason="" if ok else "El número de documento no coincide con nuestro registro",
248
+ )
249
+ ```
250
+
251
+ Las imágenes se purgan a los N días de la decisión (`organization.kyc.image_retention_days`);
252
+ el resultado completo sigue disponible en el paquete de evidencias con
253
+ `download_evidence(..., include_identity_proofs=True)`.
@@ -58,8 +58,17 @@ client.templates.add_field(
58
58
 
59
59
  ## Receta 2 — Firma embebida (embedded signing)
60
60
 
61
- Con `deliver_emails=False` recibes el `sign_url` de un solo uso por firmante y
62
- lo integras en tu propia aplicación (redirect o iframe):
61
+ El firmante firma dentro de tu aplicación (el caso RootedArbiter). Tres pasos:
62
+
63
+ 1. Registra una vez los orígenes de tu web (https; `http://localhost:…` solo
64
+ en desarrollo). La lista se sustituye entera:
65
+
66
+ ```python
67
+ client.organization.update(embed_allowed_origins=["https://arbiter.rootedcon.com"])
68
+ ```
69
+
70
+ 2. Envía sin emails y, cuando el ponente esté delante de tu página, acuña una
71
+ sesión efímera para ESE firmante (60 minutos por defecto, caduca al firmar):
63
72
 
64
73
  ```python
65
74
  result = client.templates.create_envelope(
@@ -68,12 +77,41 @@ result = client.templates.create_envelope(
68
77
  send=True,
69
78
  deliver_emails=False,
70
79
  )
71
- sign_url = result["signers"][0]["sign_url"]
72
- # → redirige al ponente a sign_url sin salir de tu flujo
80
+ signer = result["signers"][0]
81
+ session = client.envelopes.create_embedded_session(
82
+ result["envelope"]["id"],
83
+ signer["id"],
84
+ origin="https://arbiter.rootedcon.com",
85
+ return_url="https://arbiter.rootedcon.com/contratos/42/firmado", # opcional
86
+ language="es",
87
+ )
88
+ embed_url = session["url"] # token em_…: un firmante, un origen, caduca
89
+ ```
90
+
91
+ 3. Ábrela en un iframe y escucha los eventos, o redirige al ponente y recíbelo
92
+ de vuelta en `return_url` con `?easycontract_event=signed&envelope_id=…&signer_id=…`:
93
+
94
+ ```html
95
+ <iframe src="{{ embed_url }}" allow="camera" style="width:100%;height:80vh;border:0"></iframe>
96
+ <script>
97
+ window.addEventListener("message", (e) => {
98
+ if (e.origin !== "https://app.easycontract.online") return; // el origen de easycontract
99
+ if (e.data?.source !== "easycontract") return;
100
+ // e.data.event ∈ ready | signed | declined | expired; e.data.envelope_id, signer_id
101
+ });
102
+ </script>
73
103
  ```
74
104
 
75
- El enlace es personal, caduca (14 días por defecto, `expires_in_days` para
76
- cambiarlo) y queda invalidado al firmar o rechazar.
105
+ La página de firma responde con `Content-Security-Policy: frame-ancestors
106
+ <tu origen>` y no depende de cookies (funciona con las de terceros
107
+ bloqueadas); el OTP y la verificación de identidad, si el firmante los exige,
108
+ se piden dentro del mismo marco (`allow="camera"` para la captura del
109
+ documento). El `postMessage` es solo señal de interfaz: confirma siempre en
110
+ servidor con el webhook `signer.signed` o `client.envelopes.get(...)`. Cada
111
+ sesión y su apertura (con el origen) quedan en el expediente de evidencias.
112
+
113
+ El `sign_url` clásico de `deliver_emails=False` sigue existiendo para el
114
+ redirect simple (14 días, `expires_in_days` para cambiarlo).
77
115
 
78
116
  ## Receta 3 — Webhooks vs. polling
79
117
 
@@ -151,3 +189,42 @@ members = client.members.list()
151
189
  invite = client.invitations.create(email="colega@example.com", role="member")
152
190
  # invite["accept_url"] — entrégalo tú o deja que easycontract lo envíe por email
153
191
  ```
192
+
193
+ ## Validar la identidad del firmante en tu plataforma (KYC)
194
+
195
+ Con el proceso propio de easycontract, el firmante fotografía su documento y se hace un
196
+ selfie; la decisión es de la organización. Puedes tomarla desde tu propio sistema:
197
+
198
+ ```python
199
+ from easycontract import EasyContract
200
+
201
+ client = EasyContract(api_key="ec_live_...")
202
+
203
+ # 1. Suscríbete a los eventos (o sondea GET /events/): signer.kyc_submitted avisa de
204
+ # cada caso pendiente; signer.kyc_verified / signer.kyc_failed, de la decisión.
205
+ for case in client.identity.pending_reviews()["data"]:
206
+ envelope_id, signer = case["envelope_id"], case["signer"]
207
+ identity = case["identity_verification"] # estado, comprobaciones, datos declarados
208
+
209
+ # 2. Recupera las imágenes (cada descarga queda en el audit trail del documento)
210
+ for document in identity["documents"]:
211
+ client.identity.download_document(
212
+ envelope_id, signer["id"], document["id"], to=f"/tmp/{document['kind']}.jpg"
213
+ )
214
+
215
+ # 3. Valida con tus reglas: número declarado, caducidad, cotejo visual o tu propio motor
216
+ ok = identity["declared_document_number"] == mi_registro[signer["email"]].dni
217
+
218
+ # 4. Decide; el firmante recibe el resultado por correo
219
+ client.identity.review(
220
+ envelope_id,
221
+ signer["id"],
222
+ decision="approve" if ok else "reject",
223
+ verified_name=signer["full_name"],
224
+ reason="" if ok else "El número de documento no coincide con nuestro registro",
225
+ )
226
+ ```
227
+
228
+ Las imágenes se purgan a los N días de la decisión (`organization.kyc.image_retention_days`);
229
+ el resultado completo sigue disponible en el paquete de evidencias con
230
+ `download_evidence(..., include_identity_proofs=True)`.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "easycontract"
3
- version = "0.1.0"
3
+ version = "0.1.1"
4
4
  description = "SDK oficial de Python para el API de easycontract (firma electrónica eIDAS)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -20,7 +20,9 @@ classifiers = [
20
20
  "Topic :: Software Development :: Libraries :: Python Modules",
21
21
  ]
22
22
  dependencies = [
23
- "requests>=2.31,<3",
23
+ # >=2.33.0: older releases carry CVE-2024-35195, CVE-2024-47081 and
24
+ # CVE-2026-25645 (audit 2026-10-07, A1)
25
+ "requests>=2.33.0,<3",
24
26
  ]
25
27
 
26
28
  [project.urls]
@@ -36,4 +38,4 @@ packages = ["src/easycontract"]
36
38
  [tool.hatch.build.targets.sdist]
37
39
  # Internal material never ships in the sdist. (hatchling still adds the
38
40
  # repo-root .gitignore to the sdist; it holds nothing sensitive.)
39
- exclude = ["SECURITY-AUDIT*.md", "dist/"]
41
+ exclude = ["SECURITY-AUDIT*.md", "dist/", "uv.lock"]
@@ -6,7 +6,7 @@ client = EasyContract(api_key="ec_test_...")
6
6
  envelope = client.envelopes.create(title="Contrato", retention_period_months=72)
7
7
  """
8
8
 
9
- __version__ = "0.1.0"
9
+ __version__ = "0.1.1"
10
10
 
11
11
  from . import webhooks
12
12
  from .client import EasyContract
@@ -25,9 +25,11 @@ from .errors import APIError, error_from_response
25
25
  from .resources import (
26
26
  Envelopes,
27
27
  Events,
28
+ Identity,
28
29
  Invitations,
29
30
  Members,
30
31
  Organization,
32
+ Retention,
31
33
  Templates,
32
34
  Webhooks,
33
35
  )
@@ -102,6 +104,9 @@ class EasyContract:
102
104
  )
103
105
 
104
106
  self.envelopes = Envelopes(self)
107
+
108
+ self.identity = Identity(self)
109
+ self.retention = Retention(self)
105
110
  self.templates = Templates(self)
106
111
  self.webhooks = Webhooks(self)
107
112
  self.events = Events(self)
@@ -7,10 +7,33 @@ asserts full coverage, so the SDK cannot drift from the API contract.
7
7
  from __future__ import annotations
8
8
 
9
9
  import io
10
+ import uuid
10
11
  from pathlib import Path
12
+ from urllib.parse import quote
11
13
 
12
14
  from .errors import EasyContractError
13
15
 
16
+
17
+ def _id(value) -> str:
18
+ """A resource identifier as a path segment: it must be a UUID (every
19
+ resource id of the API is). Anything else — "../webhooks/x", "a?b",
20
+ "a#b" — is refused before a request is built, so an id taken from
21
+ outside can never steer the call to another endpoint (audit
22
+ 2026-10-07, A2)."""
23
+ try:
24
+ return str(uuid.UUID(str(value)))
25
+ except (ValueError, TypeError, AttributeError):
26
+ raise ValueError(f"invalid identifier {value!r}: expected a UUID") from None
27
+
28
+
29
+ def _seg(value) -> str:
30
+ """A free-text path segment (catalog key, language code), fully escaped."""
31
+ text = str(value)
32
+ if text in ("", ".", ".."):
33
+ raise ValueError(f"invalid path segment {value!r}")
34
+ return quote(text, safe="")
35
+
36
+
14
37
  # (HTTP method, spec path) → dotted SDK method. CI-enforced coverage.
15
38
  OPERATIONS: dict[tuple[str, str], str] = {
16
39
  ("GET", "/whoami/"): "client.whoami",
@@ -31,8 +54,46 @@ OPERATIONS: dict[tuple[str, str], str] = {
31
54
  "POST",
32
55
  "/envelopes/{envelope_id}/signers/{signer_id}/remind/",
33
56
  ): "client.envelopes.remind_signer",
57
+ (
58
+ "POST",
59
+ "/envelopes/{envelope_id}/signers/{signer_id}/embedded-session/",
60
+ ): "client.envelopes.create_embedded_session",
34
61
  ("POST", "/envelopes/{envelope_id}/void/"): "client.envelopes.void",
35
62
  ("GET", "/envelopes/{envelope_id}/certificate/"): "client.envelopes.download_certificate",
63
+ (
64
+ "POST",
65
+ "/envelopes/{envelope_id}/documents/{document_id}/rescan/",
66
+ ): "client.envelopes.rescan_document",
67
+ (
68
+ "GET",
69
+ "/envelopes/{envelope_id}/documents/{document_id}/final/",
70
+ ): "client.envelopes.download_final_document",
71
+ ("POST", "/envelopes/{envelope_id}/legal-hold/"): "client.envelopes.set_legal_hold",
72
+ ("DELETE", "/envelopes/{envelope_id}/legal-hold/"): "client.envelopes.release_legal_hold",
73
+ ("GET", "/retention/"): "client.retention.overview",
74
+ ("GET", "/document-types/"): "client.retention.document_types",
75
+ ("PATCH", "/organization/"): "client.organization.update",
76
+ ("GET", "/identity-reviews/"): "client.identity.pending_reviews",
77
+ (
78
+ "GET",
79
+ "/envelopes/{envelope_id}/signers/{signer_id}/identity/",
80
+ ): "client.identity.get",
81
+ (
82
+ "GET",
83
+ "/envelopes/{envelope_id}/signers/{signer_id}/identity/documents/{document_id}/",
84
+ ): "client.identity.download_document",
85
+ (
86
+ "POST",
87
+ "/envelopes/{envelope_id}/signers/{signer_id}/identity/review/",
88
+ ): "client.identity.review",
89
+ (
90
+ "POST",
91
+ "/envelopes/{envelope_id}/signers/{signer_id}/identity/confirm-in-person/",
92
+ ): "client.identity.confirm_in_person",
93
+ (
94
+ "DELETE",
95
+ "/envelopes/{envelope_id}/signers/{signer_id}/biometrics/",
96
+ ): "client.envelopes.erase_signer_biometrics",
36
97
  ("GET", "/envelopes/{envelope_id}/evidence/"): "client.envelopes.download_evidence",
37
98
  ("GET", "/templates/"): "client.templates.list",
38
99
  ("POST", "/templates/"): "client.templates.create",
@@ -133,18 +194,34 @@ class Envelopes(_Resource):
133
194
  retention_period_months: int,
134
195
  signing_order: str = "parallel",
135
196
  metadata: dict | None = None,
197
+ language: str | None = None,
198
+ biometrics: bool = False,
199
+ document_type: str | None = None,
200
+ kyc_images_retention: str | None = None,
136
201
  idempotency_key: str | None = None,
137
202
  ) -> dict:
203
+ """`biometrics=True` offers signers the OPTIONAL stroke capture (only
204
+ effective when the organization enabled it: Enterprise + DPIA).
205
+ `document_type` is a key of `client.retention.document_types()`.
206
+ `kyc_images_retention`: "after_review" (default) or
207
+ "after_in_person_check" — keep the identity images until you record
208
+ the face-to-face check with `client.identity.confirm_in_person`."""
209
+ body: dict = {
210
+ "title": title,
211
+ "retention_period_months": retention_period_months,
212
+ "signing_order": signing_order,
213
+ "metadata": metadata or {},
214
+ }
215
+ if language:
216
+ body["language"] = language
217
+ if biometrics:
218
+ body["biometrics"] = True
219
+ if document_type:
220
+ body["document_type"] = document_type
221
+ if kyc_images_retention:
222
+ body["kyc_images_retention"] = kyc_images_retention
138
223
  return self._client.request(
139
- "POST",
140
- "/envelopes/",
141
- json={
142
- "title": title,
143
- "retention_period_months": retention_period_months,
144
- "signing_order": signing_order,
145
- "metadata": metadata or {},
146
- },
147
- idempotency_key=idempotency_key,
224
+ "POST", "/envelopes/", json=body, idempotency_key=idempotency_key
148
225
  )
149
226
 
150
227
  def list(
@@ -167,7 +244,7 @@ class Envelopes(_Resource):
167
244
 
168
245
  def get(self, envelope_id: str, *, expand: list[str] | None = None) -> dict:
169
246
  params = {"expand": ",".join(expand)} if expand else None
170
- return self._client.request("GET", f"/envelopes/{envelope_id}/", params=params)
247
+ return self._client.request("GET", f"/envelopes/{_id(envelope_id)}/", params=params)
171
248
 
172
249
  def add_document(
173
250
  self,
@@ -179,7 +256,7 @@ class Envelopes(_Resource):
179
256
  ) -> dict:
180
257
  return self._client.request(
181
258
  "POST",
182
- f"/envelopes/{envelope_id}/documents/",
259
+ f"/envelopes/{_id(envelope_id)}/documents/",
183
260
  files={"file": _file_tuple(file, name, content_type)},
184
261
  )
185
262
 
@@ -192,6 +269,8 @@ class Envelopes(_Resource):
192
269
  phone: str = "",
193
270
  order: int | None = None,
194
271
  auth_methods: list[str] | None = None,
272
+ kyc_document_type: str = "",
273
+ kyc_document_number: str = "",
195
274
  idempotency_key: str | None = None,
196
275
  ) -> dict:
197
276
  body = {"full_name": full_name, "email": email, "phone": phone}
@@ -201,7 +280,7 @@ class Envelopes(_Resource):
201
280
  body["auth_methods"] = auth_methods
202
281
  return self._client.request(
203
282
  "POST",
204
- f"/envelopes/{envelope_id}/signers/",
283
+ f"/envelopes/{_id(envelope_id)}/signers/",
205
284
  json=body,
206
285
  idempotency_key=idempotency_key,
207
286
  )
@@ -219,29 +298,121 @@ class Envelopes(_Resource):
219
298
  body["expires_in_days"] = expires_in_days
220
299
  return self._client.request(
221
300
  "POST",
222
- f"/envelopes/{envelope_id}/send/",
301
+ f"/envelopes/{_id(envelope_id)}/send/",
223
302
  json=body,
224
303
  idempotency_key=idempotency_key,
225
304
  )
226
305
 
227
306
  def remind_signer(self, envelope_id: str, signer_id: str) -> dict:
228
307
  """Re-send the signing link (a FRESH one; previous links die)."""
229
- return self._client.request("POST", f"/envelopes/{envelope_id}/signers/{signer_id}/remind/")
308
+ return self._client.request(
309
+ "POST", f"/envelopes/{_id(envelope_id)}/signers/{_id(signer_id)}/remind/"
310
+ )
311
+
312
+ def create_embedded_session(
313
+ self,
314
+ envelope_id: str,
315
+ signer_id: str,
316
+ *,
317
+ origin: str,
318
+ return_url: str | None = None,
319
+ language: str | None = None,
320
+ ttl_minutes: int | None = None,
321
+ idempotency_key: str | None = None,
322
+ ) -> dict:
323
+ """Embedded signing (§10.3): an ephemeral `url` for ONE pending signer
324
+ and ONE origin registered with `client.organization.update(
325
+ embed_allowed_origins=[...])`. Open it in an `<iframe allow="camera">`
326
+ (the page posts ready/signed/declined/expired to your window) or
327
+ redirect to it and get the signer back on `return_url`. Confirm the
328
+ outcome server-side (webhook `signer.signed` or `envelopes.get`)."""
329
+ body: dict = {"origin": origin}
330
+ if return_url is not None:
331
+ body["return_url"] = return_url
332
+ if language is not None:
333
+ body["language"] = language
334
+ if ttl_minutes is not None:
335
+ body["ttl_minutes"] = ttl_minutes
336
+ return self._client.request(
337
+ "POST",
338
+ f"/envelopes/{_id(envelope_id)}/signers/{_id(signer_id)}/embedded-session/",
339
+ json=body,
340
+ idempotency_key=idempotency_key,
341
+ )
342
+
343
+ def set_legal_hold(self, envelope_id: str, *, reason: str) -> dict:
344
+ """Suspend retention blocking/purge (litigation, authority request)."""
345
+ return self._client.request(
346
+ "POST", f"/envelopes/{_id(envelope_id)}/legal-hold/", json={"reason": reason}
347
+ )
348
+
349
+ def release_legal_hold(self, envelope_id: str) -> dict:
350
+ return self._client.request("DELETE", f"/envelopes/{_id(envelope_id)}/legal-hold/")
230
351
 
231
352
  def void(self, envelope_id: str, *, reason: str = "") -> dict:
232
353
  return self._client.request(
233
- "POST", f"/envelopes/{envelope_id}/void/", json={"reason": reason}
354
+ "POST", f"/envelopes/{_id(envelope_id)}/void/", json={"reason": reason}
234
355
  )
235
356
 
236
357
  def download_certificate(self, envelope_id: str, *, to: str | Path | None = None) -> bytes:
237
- content = self._client.request("GET", f"/envelopes/{envelope_id}/certificate/", raw=True)
358
+ content = self._client.request(
359
+ "GET", f"/envelopes/{_id(envelope_id)}/certificate/", raw=True
360
+ )
238
361
  if to is not None:
239
362
  Path(to).write_bytes(content)
240
363
  return content
241
364
 
242
- def download_evidence(self, envelope_id: str, *, to: str | Path | None = None) -> bytes:
365
+ def erase_signer_biometrics(
366
+ self, envelope_id: str, signer_id: str, *, reason: str = ""
367
+ ) -> dict:
368
+ """Erase a signer's biometric sample (consent withdrawn). The hash and
369
+ the audit trail stay; `signer["biometrics"]["erased_at"]` is set."""
370
+ return self._client.request(
371
+ "DELETE",
372
+ f"/envelopes/{_id(envelope_id)}/signers/{_id(signer_id)}/biometrics/",
373
+ params={"reason": reason} if reason else None,
374
+ )
375
+
376
+ def rescan_document(self, envelope_id: str, document_id: str) -> dict:
377
+ """Retry the asynchronous malware scan of a document whose scan ended
378
+ in `error` (or a legacy `not_scanned` one). The verdict arrives later:
379
+ `document["scan"]["status"]` or the `document.scanned` event."""
380
+ return self._client.request(
381
+ "POST", f"/envelopes/{_id(envelope_id)}/documents/{_id(document_id)}/rescan/"
382
+ )
383
+
384
+ def download_final_document(
385
+ self, envelope_id: str, document_id: str, *, to: str | Path | None = None
386
+ ) -> bytes:
387
+ """The stamped final PDF of one document (PAdES-B-LTA sealed on
388
+ Enterprise). Seal details come with `get(..., expand=["documents"])`
389
+ under `documents[].final.pades`."""
390
+ content = self._client.request(
391
+ "GET", f"/envelopes/{_id(envelope_id)}/documents/{_id(document_id)}/final/", raw=True
392
+ )
393
+ if to is not None:
394
+ Path(to).write_bytes(content)
395
+ return content
396
+
397
+ def download_evidence(
398
+ self,
399
+ envelope_id: str,
400
+ *,
401
+ to: str | Path | None = None,
402
+ include_biometrics: bool = False,
403
+ include_identity_proofs: bool = False,
404
+ ) -> bytes:
243
405
  """The full evidence ZIP — what a tenant would take to court."""
244
- content = self._client.request("GET", f"/envelopes/{envelope_id}/evidence/", raw=True)
406
+ content = self._client.request(
407
+ "GET",
408
+ f"/envelopes/{_id(envelope_id)}/evidence/",
409
+ params={
410
+ **({"include_biometrics": "true"} if include_biometrics else {}),
411
+ **({"include_identity_proofs": "true"} if include_identity_proofs else {}),
412
+ }
413
+ or None,
414
+ raw=True,
415
+ )
245
416
  if to is not None:
246
417
  Path(to).write_bytes(content)
247
418
  return content
@@ -257,26 +428,30 @@ class Templates(_Resource):
257
428
  signing_order: str = "parallel",
258
429
  metadata: dict | None = None,
259
430
  variables: list[dict] | None = None,
431
+ document_type: str | None = None,
432
+ kyc_images_retention: str | None = None,
260
433
  idempotency_key: str | None = None,
261
434
  ) -> dict:
435
+ body: dict = {
436
+ "name": name,
437
+ "retention_period_months": retention_period_months,
438
+ "description": description,
439
+ "signing_order": signing_order,
440
+ "metadata": metadata or {},
441
+ "variables": variables or [],
442
+ }
443
+ if document_type:
444
+ body["document_type"] = document_type
445
+ if kyc_images_retention:
446
+ body["kyc_images_retention"] = kyc_images_retention
262
447
  return self._client.request(
263
- "POST",
264
- "/templates/",
265
- json={
266
- "name": name,
267
- "retention_period_months": retention_period_months,
268
- "description": description,
269
- "signing_order": signing_order,
270
- "metadata": metadata or {},
271
- "variables": variables or [],
272
- },
273
- idempotency_key=idempotency_key,
448
+ "POST", "/templates/", json=body, idempotency_key=idempotency_key
274
449
  )
275
450
 
276
451
  def update(self, template_id: str, **changes) -> dict:
277
452
  """PATCH settings: name, description, signing_order,
278
453
  retention_period_months, variables (schema list), metadata."""
279
- return self._client.request("PATCH", f"/templates/{template_id}/", json=changes)
454
+ return self._client.request("PATCH", f"/templates/{_id(template_id)}/", json=changes)
280
455
 
281
456
  def catalog(self, *, language: str | None = None) -> dict:
282
457
  """Starter templates shipped with the platform (house drafts)."""
@@ -288,7 +463,7 @@ class Templates(_Resource):
288
463
  ) -> dict:
289
464
  return self._client.request(
290
465
  "POST",
291
- f"/templates/catalog/{catalog_key}/clone/",
466
+ f"/templates/catalog/{_seg(catalog_key)}/clone/",
292
467
  json={"name": name},
293
468
  idempotency_key=idempotency_key,
294
469
  )
@@ -298,7 +473,7 @@ class Templates(_Resource):
298
473
  ) -> dict:
299
474
  return self._client.request(
300
475
  "POST",
301
- f"/templates/{template_id}/duplicate/",
476
+ f"/templates/{_id(template_id)}/duplicate/",
302
477
  json={"name": name},
303
478
  idempotency_key=idempotency_key,
304
479
  )
@@ -325,10 +500,12 @@ class Templates(_Resource):
325
500
  ):
326
501
  if value is not None:
327
502
  body[key] = value
328
- return self._client.request("POST", f"/templates/{template_id}/documents/", json=body)
503
+ return self._client.request("POST", f"/templates/{_id(template_id)}/documents/", json=body)
329
504
 
330
505
  def get_document(self, template_id: str, document_id: str) -> dict:
331
- return self._client.request("GET", f"/templates/{template_id}/documents/{document_id}/")
506
+ return self._client.request(
507
+ "GET", f"/templates/{_id(template_id)}/documents/{_id(document_id)}/"
508
+ )
332
509
 
333
510
  def update_document(
334
511
  self,
@@ -353,15 +530,18 @@ class Templates(_Resource):
353
530
  if v is not None
354
531
  }
355
532
  return self._client.request(
356
- "PATCH", f"/templates/{template_id}/documents/{document_id}/", json=body
533
+ "PATCH", f"/templates/{_id(template_id)}/documents/{_id(document_id)}/", json=body
357
534
  )
358
535
 
359
536
  def delete_document(self, template_id: str, document_id: str) -> None:
360
- self._client.request("DELETE", f"/templates/{template_id}/documents/{document_id}/")
537
+ self._client.request(
538
+ "DELETE", f"/templates/{_id(template_id)}/documents/{_id(document_id)}/"
539
+ )
361
540
 
362
541
  def get_translation(self, template_id: str, document_id: str, language: str) -> dict:
363
542
  return self._client.request(
364
- "GET", f"/templates/{template_id}/documents/{document_id}/translations/{language}/"
543
+ "GET",
544
+ f"/templates/{_id(template_id)}/documents/{_id(document_id)}/translations/{_seg(language)}/",
365
545
  )
366
546
 
367
547
  def put_translation(
@@ -388,14 +568,14 @@ class Templates(_Resource):
388
568
  }
389
569
  return self._client.request(
390
570
  "PUT",
391
- f"/templates/{template_id}/documents/{document_id}/translations/{language}/",
571
+ f"/templates/{_id(template_id)}/documents/{_id(document_id)}/translations/{_seg(language)}/",
392
572
  json=body,
393
573
  )
394
574
 
395
575
  def delete_translation(self, template_id: str, document_id: str, language: str) -> None:
396
576
  self._client.request(
397
577
  "DELETE",
398
- f"/templates/{template_id}/documents/{document_id}/translations/{language}/",
578
+ f"/templates/{_id(template_id)}/documents/{_id(document_id)}/translations/{_seg(language)}/",
399
579
  )
400
580
 
401
581
  def download_docx(
@@ -403,7 +583,7 @@ class Templates(_Resource):
403
583
  ) -> bytes:
404
584
  """Editable DOCX of an authored document (edit in Word, upload back)."""
405
585
  content = self._client.request(
406
- "GET", f"/templates/{template_id}/documents/{document_id}/docx/", raw=True
586
+ "GET", f"/templates/{_id(template_id)}/documents/{_id(document_id)}/docx/", raw=True
407
587
  )
408
588
  if to is not None:
409
589
  Path(to).write_bytes(content)
@@ -415,7 +595,7 @@ class Templates(_Resource):
415
595
  """Replace an authored document's text with the uploaded DOCX."""
416
596
  return self._client.request(
417
597
  "POST",
418
- f"/templates/{template_id}/documents/{document_id}/docx/",
598
+ f"/templates/{_id(template_id)}/documents/{_id(document_id)}/docx/",
419
599
  files={"file": _file_tuple(file, name or "document.docx", DOCX_MIME)},
420
600
  data={"name": name} if name else None,
421
601
  )
@@ -439,7 +619,7 @@ class Templates(_Resource):
439
619
  if language:
440
620
  body["language"] = language
441
621
  content = self._client.request(
442
- "POST", f"/templates/{template_id}/preview/", json=body, raw=True
622
+ "POST", f"/templates/{_id(template_id)}/preview/", json=body, raw=True
443
623
  )
444
624
  if to is not None:
445
625
  Path(to).write_bytes(content)
@@ -451,10 +631,10 @@ class Templates(_Resource):
451
631
 
452
632
  def get(self, template_id: str, *, expand: list[str] | None = None) -> dict:
453
633
  params = {"expand": ",".join(expand)} if expand else None
454
- return self._client.request("GET", f"/templates/{template_id}/", params=params)
634
+ return self._client.request("GET", f"/templates/{_id(template_id)}/", params=params)
455
635
 
456
636
  def delete(self, template_id: str) -> None:
457
- self._client.request("DELETE", f"/templates/{template_id}/")
637
+ self._client.request("DELETE", f"/templates/{_id(template_id)}/")
458
638
 
459
639
  def add_document(
460
640
  self,
@@ -466,7 +646,7 @@ class Templates(_Resource):
466
646
  ) -> dict:
467
647
  return self._client.request(
468
648
  "POST",
469
- f"/templates/{template_id}/documents/",
649
+ f"/templates/{_id(template_id)}/documents/",
470
650
  files={"file": _file_tuple(file, name, content_type)},
471
651
  )
472
652
 
@@ -484,7 +664,7 @@ class Templates(_Resource):
484
664
  body["order"] = order
485
665
  if auth_methods is not None:
486
666
  body["auth_methods"] = auth_methods
487
- return self._client.request("POST", f"/templates/{template_id}/roles/", json=body)
667
+ return self._client.request("POST", f"/templates/{_id(template_id)}/roles/", json=body)
488
668
 
489
669
  def add_field(
490
670
  self,
@@ -504,7 +684,7 @@ class Templates(_Resource):
504
684
  ) -> dict:
505
685
  return self._client.request(
506
686
  "POST",
507
- f"/templates/{template_id}/fields/",
687
+ f"/templates/{_id(template_id)}/fields/",
508
688
  json={
509
689
  "document_id": document_id,
510
690
  "role": role,
@@ -532,6 +712,8 @@ class Templates(_Resource):
532
712
  expires_in_days: int | None = None,
533
713
  deliver_emails: bool = True,
534
714
  language: str | None = None,
715
+ kyc_images_retention: str | None = None,
716
+ biometrics: bool = False,
535
717
  idempotency_key: str | None = None,
536
718
  ) -> dict:
537
719
  """The one-call automation flow (§10.4): instantiate and optionally
@@ -548,11 +730,15 @@ class Templates(_Resource):
548
730
  }
549
731
  if language is not None:
550
732
  body["language"] = language
733
+ if kyc_images_retention:
734
+ body["kyc_images_retention"] = kyc_images_retention
735
+ if biometrics:
736
+ body["biometrics"] = True
551
737
  if expires_in_days is not None:
552
738
  body["expires_in_days"] = expires_in_days
553
739
  return self._client.request(
554
740
  "POST",
555
- f"/templates/{template_id}/envelopes/",
741
+ f"/templates/{_id(template_id)}/envelopes/",
556
742
  json=body,
557
743
  idempotency_key=idempotency_key,
558
744
  )
@@ -574,10 +760,10 @@ class Webhooks(_Resource):
574
760
  return self._client.request("GET", "/webhooks/", params=params)
575
761
 
576
762
  def delete(self, endpoint_id: str) -> None:
577
- self._client.request("DELETE", f"/webhooks/{endpoint_id}/")
763
+ self._client.request("DELETE", f"/webhooks/{_id(endpoint_id)}/")
578
764
 
579
765
  def redeliver(self, delivery_id: str) -> dict:
580
- return self._client.request("POST", f"/webhooks/deliveries/{delivery_id}/redeliver/")
766
+ return self._client.request("POST", f"/webhooks/deliveries/{_id(delivery_id)}/redeliver/")
581
767
 
582
768
 
583
769
  class Events(_Resource):
@@ -609,21 +795,107 @@ class Events(_Resource):
609
795
  return self._auto_paging("/events/", **params)
610
796
 
611
797
 
798
+ class Retention(_Resource):
799
+ """Evidence retention: what ends soon (export it!), blocked, legal holds,
800
+ and the document type catalogue with suggested retention."""
801
+
802
+ def overview(self, *, within_days: int = 90) -> dict:
803
+ return self._client.request("GET", "/retention/", params={"within_days": within_days})
804
+
805
+ def document_types(self) -> dict:
806
+ return self._client.request("GET", "/document-types/")
807
+
808
+
809
+ class Identity(_Resource):
810
+ """Signer identity proofing (factor `kyc`). With the in-house process the
811
+ tenant reviews: list pending cases, look at the images (every view is
812
+ recorded in the evidence trail) and decide."""
813
+
814
+ def pending_reviews(self) -> dict:
815
+ return self._client.request("GET", "/identity-reviews/")
816
+
817
+ def get(self, envelope_id: str, signer_id: str) -> dict:
818
+ return self._client.request(
819
+ "GET", f"/envelopes/{_id(envelope_id)}/signers/{_id(signer_id)}/identity/"
820
+ )
821
+
822
+ def download_document(
823
+ self, envelope_id: str, signer_id: str, document_id: str, *, to: str | Path | None = None
824
+ ) -> bytes:
825
+ content = self._client.request(
826
+ "GET",
827
+ f"/envelopes/{_id(envelope_id)}/signers/{_id(signer_id)}/identity/documents/{_id(document_id)}/",
828
+ raw=True,
829
+ )
830
+ if to is not None:
831
+ Path(to).write_bytes(content)
832
+ return content
833
+
834
+ def review(
835
+ self,
836
+ envelope_id: str,
837
+ signer_id: str,
838
+ *,
839
+ decision: str,
840
+ verified_name: str = "",
841
+ reason: str = "",
842
+ ) -> dict:
843
+ """`decision` is "approve" or "reject" (a rejection needs a reason)."""
844
+ body = {"decision": decision}
845
+ if verified_name:
846
+ body["verified_name"] = verified_name
847
+ if reason:
848
+ body["reason"] = reason
849
+ return self._client.request(
850
+ "POST",
851
+ f"/envelopes/{_id(envelope_id)}/signers/{_id(signer_id)}/identity/review/",
852
+ json=body,
853
+ )
854
+
855
+ def confirm_in_person(self, envelope_id: str, signer_id: str, *, note: str = "") -> dict:
856
+ """Record that the signer was matched against the document face to
857
+ face (access control, registration desk). On envelopes created with
858
+ `kyc_images_retention="after_in_person_check"` this starts the image
859
+ purge clock; it is always recorded in the evidence trail."""
860
+ body = {"note": note} if note else {}
861
+ return self._client.request(
862
+ "POST",
863
+ f"/envelopes/{_id(envelope_id)}/signers/{_id(signer_id)}/identity/confirm-in-person/",
864
+ json=body,
865
+ )
866
+
867
+
612
868
  class Organization(_Resource):
613
869
  def get(self) -> dict:
614
870
  """Org info including plan limits and features."""
615
871
  return self._client.request("GET", "/organization/")
616
872
 
873
+ def update(
874
+ self,
875
+ *,
876
+ retention_policies: dict | None = None,
877
+ embed_allowed_origins: list[str] | None = None,
878
+ ) -> dict:
879
+ """Organization settings an API key may change: retention policy per
880
+ document type (months within the plan's range) and the web origins
881
+ allowed to embed the signer flow (the FULL list; [] disables it)."""
882
+ body: dict = {}
883
+ if retention_policies is not None:
884
+ body["retention_policies"] = retention_policies
885
+ if embed_allowed_origins is not None:
886
+ body["embed_allowed_origins"] = list(embed_allowed_origins)
887
+ return self._client.request("PATCH", "/organization/", json=body)
888
+
617
889
 
618
890
  class Members(_Resource):
619
891
  def list(self) -> dict:
620
892
  return self._client.request("GET", "/members/")
621
893
 
622
894
  def change_role(self, membership_id: str, *, role: str) -> dict:
623
- return self._client.request("PATCH", f"/members/{membership_id}/", json={"role": role})
895
+ return self._client.request("PATCH", f"/members/{_id(membership_id)}/", json={"role": role})
624
896
 
625
897
  def remove(self, membership_id: str) -> None:
626
- self._client.request("DELETE", f"/members/{membership_id}/")
898
+ self._client.request("DELETE", f"/members/{_id(membership_id)}/")
627
899
 
628
900
 
629
901
  class Invitations(_Resource):
@@ -650,4 +922,4 @@ class Invitations(_Resource):
650
922
  return self._client.request("GET", "/invitations/", params=params)
651
923
 
652
924
  def cancel(self, invite_id: str) -> None:
653
- self._client.request("DELETE", f"/invitations/{invite_id}/")
925
+ self._client.request("DELETE", f"/invitations/{_id(invite_id)}/")
@@ -255,6 +255,25 @@ expect_sig_err(
255
255
  secret=SECRET,
256
256
  )
257
257
 
258
+ # ------------------------------------------- 6 bis. path identifiers (A2)
259
+ from easycontract import EasyContract as ProbeClient # noqa: E402
260
+
261
+ probe = ProbeClient(api_key="ec_test_probe", base_url="https://api.example.com/api/v1")
262
+ sent_paths: list[str] = []
263
+ probe.request = lambda method, path, **kw: sent_paths.append(path) or {}
264
+ good = "6F9619FF-8B86-D011-B42D-00C04FC964FF"
265
+ probe.envelopes.get(good)
266
+ ok("UUID ids are normalised into the path", sent_paths[-1] == f"/envelopes/{good.lower()}/")
267
+ for bad in ("abc/../../webhooks/123", "x?expand=all", "x#frag", "", None):
268
+ try:
269
+ probe.envelopes.release_legal_hold(bad)
270
+ ok(f"non-UUID id refused ({bad!r})", False)
271
+ except ValueError:
272
+ ok(f"non-UUID id refused ({bad!r})", True)
273
+ probe.templates.get_translation(good, good, "es/../x")
274
+ ok("text segments are escaped", sent_paths[-1].endswith("/translations/es%2F..%2Fx/"))
275
+ ok("no request was built for refused ids", len(sent_paths) == 2)
276
+
258
277
  # --------------------------------------------------------- 7. version wiring
259
278
  from easycontract import __version__ # noqa: E402
260
279
 
@@ -1,19 +0,0 @@
1
- # Changelog — easycontract Python SDK
2
-
3
- ## 0.1.0 (unreleased)
4
-
5
- First public release. Notes for integrators:
6
-
7
- - `EasyContract.request(..., idempotency_key=...)`: `None` (default) and `True`
8
- generate a UUID per logical request, reused across retries; `False` sends no
9
- header; any other value (a `str`, a `uuid.UUID`…) is sent as `str(value)`.
10
- - Uploads: file objects passed in `files` are closed once the request finishes.
11
- A non-seekable stream (pipe, socket) is sent once and never retried; pass a
12
- path, `bytes` or a seekable file to get 429 retries.
13
- - `webhooks.verify_signature`: `tolerance=0` is strict; only `tolerance=None`
14
- disables the replay window. Verification runs over the raw bytes and every
15
- failure raises `SignatureVerificationError`, never a decode/parse error.
16
- - Auto-pagination raises `EasyContractError` on a page without `data` or on a
17
- cursor that does not advance, instead of looping or crashing.
18
- - Runtime dependency pinned to `requests>=2.31,<3`. Licence declared as
19
- `LicenseRef-easycontract-proprietary` (PEP 639) with `LICENSE.txt`.
File without changes
File without changes