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.
- easycontract-0.1.1/CHANGELOG.md +114 -0
- {easycontract-0.1.0 → easycontract-0.1.1}/PKG-INFO +85 -8
- {easycontract-0.1.0 → easycontract-0.1.1}/README.md +83 -6
- {easycontract-0.1.0 → easycontract-0.1.1}/pyproject.toml +5 -3
- {easycontract-0.1.0 → easycontract-0.1.1}/src/easycontract/__init__.py +1 -1
- {easycontract-0.1.0 → easycontract-0.1.1}/src/easycontract/client.py +5 -0
- {easycontract-0.1.0 → easycontract-0.1.1}/src/easycontract/resources.py +325 -53
- {easycontract-0.1.0 → easycontract-0.1.1}/tests/smoke_test.py +19 -0
- easycontract-0.1.0/CHANGELOG.md +0 -19
- {easycontract-0.1.0 → easycontract-0.1.1}/.gitignore +0 -0
- {easycontract-0.1.0 → easycontract-0.1.1}/LICENSE.txt +0 -0
- {easycontract-0.1.0 → easycontract-0.1.1}/src/easycontract/errors.py +0 -0
- {easycontract-0.1.0 → easycontract-0.1.1}/src/easycontract/webhooks.py +0 -0
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
-
|
|
62
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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.
|
|
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
|
-
|
|
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"]
|
|
@@ -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(
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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",
|
|
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
|
|
easycontract-0.1.0/CHANGELOG.md
DELETED
|
@@ -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
|
|
File without changes
|
|
File without changes
|