easycontract 0.1.1__tar.gz → 0.1.7__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.
@@ -2,6 +2,132 @@
2
2
 
3
3
  All notable changes to the `easycontract` Python SDK.
4
4
 
5
+ ## 0.1.7 (2026-11-10)
6
+
7
+ **Features**
8
+
9
+ - Template versioning: envelopes are created from the latest APPROVED
10
+ version of a template, never from its draft.
11
+ `client.templates.list_versions/approve_version/get_version`;
12
+ `create_envelope` on a template with no approved version raises the
13
+ `template_not_approved` error (409). Templates and envelopes carry the
14
+ version (`current_version`, `envelope["template"]["version"]`).
15
+ - `client.templates.update_role` (a new key rewrites the role's tokens in
16
+ the documents; `adopt_tokens_of` gives a role the tokens of a key no role
17
+ has, listed in the template's `unknown_roles` with `expand=roles`) and
18
+ `client.templates.delete_role`.
19
+ - `client.templates.set_margin_image` / `delete_margin_image`: a logo or
20
+ seal in the header or footer of an authored document (PNG, JPEG or WebP,
21
+ 2 MB at most; `align` start, center or end). A new image is checked at
22
+ once (400 if refused), then waits for the malware scan before it is
23
+ published: the template document carries `header_image` /
24
+ `footer_image` (the published one: type, size, SHA-256, place) and
25
+ `header_image_upload` / `footer_image_upload` (one still in the scan, or
26
+ refused).
27
+ - `client.templates.preview(technical=True)`: the technical view of the
28
+ template draft (every fillable field explained, highlighted in the
29
+ documents with example values and how the API passes each one).
30
+ - `client.templates.compare_versions` (what changed between two versions,
31
+ or a version and the draft) and `client.templates.restore_version` (copy
32
+ an earlier version into the draft; approving it makes a new version).
33
+ - `client.organization.group()`: the organization group (Enterprise,
34
+ read only), its members and the invitations received; `features` gains
35
+ `organization_groups`. `client.organization.group_templates()` and
36
+ `copy_group_template()`: templates the other organizations of the group
37
+ offer, copied as a draft of your own; `templates.update(group_shared=...)`
38
+ offers one, and templates carry `group_shared` and `copied_from`.
39
+ A copy follows its source: `client.templates.upstream()` (newer approved
40
+ version?), `compare_upstream()` and `pull_upstream()` (into the draft);
41
+ `templates.update(follow_updates=...)` switches the e-mail notices.
42
+ - Attention points in other languages: `client.templates.list_attention_translations`,
43
+ `get_attention_translation`, `set_attention_translation` (drafts),
44
+ `delete_attention_translation` and `autotranslate_attention_points` (a
45
+ machine draft; a document's autotranslation drafts them too). Template
46
+ points carry `translations` (status per language), translation jobs
47
+ carry `scope`, and the version comparison `attention_point_translations`.
48
+ A template is usable in a language only when its attention points are
49
+ approved in it too (approval stays in the panel).
50
+ - `templates.request_countersign_authorization(scope=...)`: `"version"`
51
+ (default) or `"future_versions"` (the authorization also covers the
52
+ versions approved later). Authorizations carry `template_version` and
53
+ `scope`.
54
+
55
+ ## 0.1.6 (2026-11-08)
56
+
57
+ **Features**
58
+
59
+ - Attention points: parts of the contract the signer must notice, shown on
60
+ the signing page before the documents (never stamped in the PDF).
61
+ `client.templates.list_attention_points/add_attention_point/
62
+ update_attention_point/delete_attention_point` (the defaults of every
63
+ envelope created from the template) and the same four on
64
+ `client.envelopes` (draft envelopes; on one created from a template, Pro
65
+ and Enterprise). `requires_ack=True` makes the signer confirm it before
66
+ signing. `templates.create_envelope(attention_points=[…])` replaces the
67
+ template's for that envelope only (Pro and Enterprise).
68
+
69
+ ## 0.1.5 (2026-10-23)
70
+
71
+ **Features**
72
+
73
+ - Automatic countersignature of the organization (Pro+):
74
+ `client.templates.request_countersign_authorization(template_id,
75
+ authorizer_email=…, authorizer_title=…, valid_until="YYYY-MM-DD",
76
+ max_uses=None, variables={…})` (the authorizer signs it by e-mail),
77
+ `client.templates.list_countersign_authorizations`,
78
+ `client.countersign_authorizations.list/get/revoke`,
79
+ `client.envelopes.retry_countersign` and
80
+ `client.envelopes.skip_countersign(reason=…)`. `templates.create_envelope`
81
+ accepts `countersign="none"`; envelopes carry `countersign`.
82
+
83
+ ## 0.1.4 (2026-10-20)
84
+
85
+ **Features**
86
+
87
+ - Projects (paid plans): `client.projects.list/create/get/update`. An
88
+ organization key sees everything; a project key only its project.
89
+ `project=` on `envelopes.create`, `envelopes.list`, `templates.create_envelope`
90
+ and `webhooks.create` (organization keys only). `client.whoami()` now returns
91
+ `project` (null = organization key) and `key`.
92
+ - Webhooks: `client.webhooks.get`, `update` (url, event_types, is_active,
93
+ description) and `rotate_secret(grace_period_hours=24)`, which returns the
94
+ new `secret` once.
95
+
96
+ **Fixes**
97
+
98
+ - `easycontract.webhooks.verify_signature` accepts ANY `v1=` in the header
99
+ and `secret` may be a list: after a rotation, during the grace period,
100
+ deliveries carry `v1=<new>,v1=<old>`. 0.1.3 and earlier only checked the
101
+ last `v1` (the old secret), so they failed once you switched to the new one
102
+ before the grace period ended.
103
+
104
+ ## 0.1.3 (2026-10-20)
105
+
106
+ **Features**
107
+
108
+ - Cryptographic restrictions: `client.organization.update(seal_profile=
109
+ "hybrid" | "classic", acknowledge=True)` chooses how the platform seals
110
+ the certificate, the final PDFs and the evidence package (hybrid ECDSA +
111
+ ML-DSA-87, or ECDSA only). `client.organization.get()["cryptography"]`
112
+ shows the choice and what each option implies; every envelope reports the
113
+ profile frozen at send in `seal_profile`.
114
+ - The evidence package (`client.envelopes.download_evidence(...)`) is now an ASiC-E
115
+ container (still a `.zip`) signed by the platform.
116
+
117
+ ## 0.1.2 (2026-10-19)
118
+
119
+ **Features**
120
+
121
+ - Machine-drafted template translations: `client.templates.autotranslate(
122
+ template_id, document_id, language, acknowledge=True)` queues a draft made
123
+ by the platform's translators and reviewed by a panel with quorum;
124
+ `client.templates.get_translation_job(job_id)` returns its progress,
125
+ `verdict` (`consensus`, `quorum`, `no_quorum`, `unreviewed`, `simulated`)
126
+ and the reviewers' notes with English back-translations. The result is
127
+ always a draft that a person approves in the panel.
128
+ - The platform now has 16 languages (es, en, ca, gl, eu, ast, an, oc, pt, fr,
129
+ it, de, ru, zh-hans, ar, he) for `language` everywhere.
130
+
5
131
  ## 0.1.1 (2026-10-07)
6
132
 
7
133
  **Security (audit 2026-10-07)**
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: easycontract
3
- Version: 0.1.1
3
+ Version: 0.1.7
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>
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "easycontract"
3
- version = "0.1.1"
3
+ version = "0.1.7"
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"
@@ -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.1"
9
+ __version__ = "0.1.7"
10
10
 
11
11
  from . import webhooks
12
12
  from .client import EasyContract
@@ -23,12 +23,14 @@ import requests
23
23
  from . import __version__
24
24
  from .errors import APIError, error_from_response
25
25
  from .resources import (
26
+ CountersignAuthorizations,
26
27
  Envelopes,
27
28
  Events,
28
29
  Identity,
29
30
  Invitations,
30
31
  Members,
31
32
  Organization,
33
+ Projects,
32
34
  Retention,
33
35
  Templates,
34
36
  Webhooks,
@@ -108,7 +110,9 @@ class EasyContract:
108
110
  self.identity = Identity(self)
109
111
  self.retention = Retention(self)
110
112
  self.templates = Templates(self)
113
+ self.countersign_authorizations = CountersignAuthorizations(self)
111
114
  self.webhooks = Webhooks(self)
115
+ self.projects = Projects(self)
112
116
  self.events = Events(self)
113
117
  self.organization = Organization(self)
114
118
  self.members = Members(self)
@@ -14,6 +14,15 @@ from urllib.parse import quote
14
14
  from .errors import EasyContractError
15
15
 
16
16
 
17
+ def _attention_body(title, body, document_id, page, requires_ack) -> dict:
18
+ payload: dict = {"title": title, "body": body, "requires_ack": requires_ack}
19
+ if document_id is not None:
20
+ payload["document_id"] = document_id
21
+ if page is not None:
22
+ payload["page"] = page
23
+ return payload
24
+
25
+
17
26
  def _id(value) -> str:
18
27
  """A resource identifier as a path segment: it must be a UUID (every
19
28
  resource id of the API is). Anything else — "../webhooks/x", "a?b",
@@ -38,6 +47,12 @@ def _seg(value) -> str:
38
47
  OPERATIONS: dict[tuple[str, str], str] = {
39
48
  ("GET", "/whoami/"): "client.whoami",
40
49
  ("GET", "/organization/"): "client.organization.get",
50
+ ("GET", "/organization/group/"): "client.organization.group",
51
+ ("GET", "/organization/group/templates/"): "client.organization.group_templates",
52
+ (
53
+ "POST",
54
+ "/organization/group/templates/{template_id}/copy/",
55
+ ): "client.organization.copy_group_template",
41
56
  ("GET", "/members/"): "client.members.list",
42
57
  ("PATCH", "/members/{membership_id}/"): "client.members.change_role",
43
58
  ("DELETE", "/members/{membership_id}/"): "client.members.remove",
@@ -59,6 +74,31 @@ OPERATIONS: dict[tuple[str, str], str] = {
59
74
  "/envelopes/{envelope_id}/signers/{signer_id}/embedded-session/",
60
75
  ): "client.envelopes.create_embedded_session",
61
76
  ("POST", "/envelopes/{envelope_id}/void/"): "client.envelopes.void",
77
+ (
78
+ "POST",
79
+ "/envelopes/{envelope_id}/countersign/retry/",
80
+ ): "client.envelopes.retry_countersign",
81
+ (
82
+ "POST",
83
+ "/envelopes/{envelope_id}/countersign/skip/",
84
+ ): "client.envelopes.skip_countersign",
85
+ ("GET", "/countersign-authorizations/"): "client.countersign_authorizations.list",
86
+ (
87
+ "GET",
88
+ "/countersign-authorizations/{authorization_id}/",
89
+ ): "client.countersign_authorizations.get",
90
+ (
91
+ "POST",
92
+ "/countersign-authorizations/{authorization_id}/revoke/",
93
+ ): "client.countersign_authorizations.revoke",
94
+ (
95
+ "GET",
96
+ "/templates/{template_id}/countersign-authorizations/",
97
+ ): "client.templates.list_countersign_authorizations",
98
+ (
99
+ "POST",
100
+ "/templates/{template_id}/countersign-authorizations/",
101
+ ): "client.templates.request_countersign_authorization",
62
102
  ("GET", "/envelopes/{envelope_id}/certificate/"): "client.envelopes.download_certificate",
63
103
  (
64
104
  "POST",
@@ -135,15 +175,103 @@ OPERATIONS: dict[tuple[str, str], str] = {
135
175
  "DELETE",
136
176
  "/templates/{template_id}/documents/{document_id}/translations/{language}/",
137
177
  ): "client.templates.delete_translation",
178
+ (
179
+ "POST",
180
+ "/templates/{template_id}/documents/{document_id}/translations/{language}/autotranslate/",
181
+ ): "client.templates.autotranslate",
182
+ ("GET", "/translation-jobs/{job_id}/"): "client.templates.get_translation_job",
138
183
  ("POST", "/templates/{template_id}/preview/"): "client.templates.preview",
184
+ (
185
+ "POST",
186
+ "/templates/{template_id}/documents/{document_id}/margin-images/{slot}/",
187
+ ): "client.templates.set_margin_image",
188
+ (
189
+ "DELETE",
190
+ "/templates/{template_id}/documents/{document_id}/margin-images/{slot}/",
191
+ ): "client.templates.delete_margin_image",
139
192
  ("POST", "/templates/{template_id}/duplicate/"): "client.templates.duplicate",
140
193
  ("POST", "/templates/{template_id}/roles/"): "client.templates.add_role",
194
+ ("PATCH", "/templates/{template_id}/roles/{role_id}/"): "client.templates.update_role",
195
+ ("DELETE", "/templates/{template_id}/roles/{role_id}/"): "client.templates.delete_role",
196
+ ("GET", "/templates/{template_id}/versions/"): "client.templates.list_versions",
197
+ ("POST", "/templates/{template_id}/versions/"): "client.templates.approve_version",
198
+ ("GET", "/templates/{template_id}/versions/{number}/"): "client.templates.get_version",
199
+ ("GET", "/templates/{template_id}/versions/compare/"): "client.templates.compare_versions",
200
+ (
201
+ "POST",
202
+ "/templates/{template_id}/versions/{number}/restore/",
203
+ ): "client.templates.restore_version",
141
204
  ("POST", "/templates/{template_id}/fields/"): "client.templates.add_field",
205
+ ("GET", "/templates/{template_id}/upstream/"): "client.templates.upstream",
206
+ (
207
+ "GET",
208
+ "/templates/{template_id}/upstream/compare/",
209
+ ): "client.templates.compare_upstream",
210
+ ("POST", "/templates/{template_id}/upstream/pull/"): "client.templates.pull_upstream",
211
+ (
212
+ "GET",
213
+ "/templates/{template_id}/attention-points/",
214
+ ): "client.templates.list_attention_points",
215
+ (
216
+ "POST",
217
+ "/templates/{template_id}/attention-points/",
218
+ ): "client.templates.add_attention_point",
219
+ (
220
+ "PATCH",
221
+ "/templates/{template_id}/attention-points/{point_id}/",
222
+ ): "client.templates.update_attention_point",
223
+ (
224
+ "DELETE",
225
+ "/templates/{template_id}/attention-points/{point_id}/",
226
+ ): "client.templates.delete_attention_point",
227
+ (
228
+ "GET",
229
+ "/templates/{template_id}/attention-points/translations/",
230
+ ): "client.templates.list_attention_translations",
231
+ (
232
+ "GET",
233
+ "/templates/{template_id}/attention-points/translations/{language}/",
234
+ ): "client.templates.get_attention_translation",
235
+ (
236
+ "PUT",
237
+ "/templates/{template_id}/attention-points/translations/{language}/",
238
+ ): "client.templates.set_attention_translation",
239
+ (
240
+ "DELETE",
241
+ "/templates/{template_id}/attention-points/translations/{language}/",
242
+ ): "client.templates.delete_attention_translation",
243
+ (
244
+ "POST",
245
+ "/templates/{template_id}/attention-points/translations/{language}/autotranslate/",
246
+ ): "client.templates.autotranslate_attention_points",
247
+ (
248
+ "GET",
249
+ "/envelopes/{envelope_id}/attention-points/",
250
+ ): "client.envelopes.list_attention_points",
251
+ (
252
+ "POST",
253
+ "/envelopes/{envelope_id}/attention-points/",
254
+ ): "client.envelopes.add_attention_point",
255
+ (
256
+ "PATCH",
257
+ "/envelopes/{envelope_id}/attention-points/{point_id}/",
258
+ ): "client.envelopes.update_attention_point",
259
+ (
260
+ "DELETE",
261
+ "/envelopes/{envelope_id}/attention-points/{point_id}/",
262
+ ): "client.envelopes.delete_attention_point",
142
263
  ("POST", "/templates/{template_id}/envelopes/"): "client.templates.create_envelope",
143
264
  ("GET", "/events/"): "client.events.list",
144
265
  ("GET", "/webhooks/"): "client.webhooks.list",
145
266
  ("POST", "/webhooks/"): "client.webhooks.create",
267
+ ("GET", "/webhooks/{endpoint_id}/"): "client.webhooks.get",
268
+ ("PATCH", "/webhooks/{endpoint_id}/"): "client.webhooks.update",
146
269
  ("DELETE", "/webhooks/{endpoint_id}/"): "client.webhooks.delete",
270
+ ("POST", "/webhooks/{endpoint_id}/rotate-secret/"): "client.webhooks.rotate_secret",
271
+ ("GET", "/projects/"): "client.projects.list",
272
+ ("POST", "/projects/"): "client.projects.create",
273
+ ("GET", "/projects/{project_id}/"): "client.projects.get",
274
+ ("PATCH", "/projects/{project_id}/"): "client.projects.update",
147
275
  ("POST", "/webhooks/deliveries/{delivery_id}/redeliver/"): "client.webhooks.redeliver",
148
276
  }
149
277
 
@@ -161,6 +289,36 @@ def _file_tuple(file, name: str | None, content_type: str = "application/pdf"):
161
289
  return (name or getattr(file, "name", "document.pdf"), file, content_type)
162
290
 
163
291
 
292
+ def _image_mime(file, name: str | None) -> str:
293
+ """Content type of an image upload, from its first bytes when it can be
294
+ read without consuming it, else from the file name (the server decides
295
+ anyway: it decodes and re-encodes every image)."""
296
+ head = b""
297
+ if isinstance(file, bytes):
298
+ head = file[:12]
299
+ elif isinstance(file, (str, Path)):
300
+ with Path(file).open("rb") as fh:
301
+ head = fh.read(12)
302
+ if head.startswith(b"\x89PNG"):
303
+ return "image/png"
304
+ if head.startswith(b"\xff\xd8"):
305
+ return "image/jpeg"
306
+ if head[:4] == b"RIFF" and head[8:12] == b"WEBP":
307
+ return "image/webp"
308
+ if name:
309
+ label = name
310
+ elif isinstance(file, (str, Path)):
311
+ label = str(file)
312
+ else:
313
+ label = str(getattr(file, "name", "") or "")
314
+ lower = label.lower()
315
+ if lower.endswith((".jpg", ".jpeg")):
316
+ return "image/jpeg"
317
+ if lower.endswith(".webp"):
318
+ return "image/webp"
319
+ return "image/png"
320
+
321
+
164
322
  class _Resource:
165
323
  def __init__(self, client):
166
324
  self._client = client
@@ -198,9 +356,12 @@ class Envelopes(_Resource):
198
356
  biometrics: bool = False,
199
357
  document_type: str | None = None,
200
358
  kyc_images_retention: str | None = None,
359
+ project: str | None = None,
201
360
  idempotency_key: str | None = None,
202
361
  ) -> dict:
203
- """`biometrics=True` offers signers the OPTIONAL stroke capture (only
362
+ """`project` (organization keys only): create inside that project; a
363
+ project key always creates in its own.
364
+ `biometrics=True` offers signers the OPTIONAL stroke capture (only
204
365
  effective when the organization enabled it: Enterprise + DPIA).
205
366
  `document_type` is a key of `client.retention.document_types()`.
206
367
  `kyc_images_retention`: "after_review" (default) or
@@ -220,17 +381,25 @@ class Envelopes(_Resource):
220
381
  body["document_type"] = document_type
221
382
  if kyc_images_retention:
222
383
  body["kyc_images_retention"] = kyc_images_retention
384
+ if project:
385
+ body["project"] = project
223
386
  return self._client.request(
224
387
  "POST", "/envelopes/", json=body, idempotency_key=idempotency_key
225
388
  )
226
389
 
227
390
  def list(
228
- self, *, status: str | None = None, limit: int | None = None, cursor: str | None = None
391
+ self,
392
+ *,
393
+ status: str | None = None,
394
+ project: str | None = None,
395
+ limit: int | None = None,
396
+ cursor: str | None = None,
229
397
  ) -> dict:
230
398
  params = {
231
399
  k: v
232
400
  for k, v in {
233
401
  "status": status,
402
+ "project": project,
234
403
  "limit": limit,
235
404
  "cursor": cursor,
236
405
  }.items()
@@ -349,11 +518,64 @@ class Envelopes(_Resource):
349
518
  def release_legal_hold(self, envelope_id: str) -> dict:
350
519
  return self._client.request("DELETE", f"/envelopes/{_id(envelope_id)}/legal-hold/")
351
520
 
521
+ def list_attention_points(self, envelope_id: str) -> dict:
522
+ """Attention points shown to the signers before the documents, with
523
+ who confirmed each one."""
524
+ return self._client.request("GET", f"/envelopes/{_id(envelope_id)}/attention-points/")
525
+
526
+ def add_attention_point(
527
+ self,
528
+ envelope_id: str,
529
+ *,
530
+ title: str,
531
+ body: str = "",
532
+ document_id: str | None = None,
533
+ page: int | None = None,
534
+ requires_ack: bool = False,
535
+ idempotency_key: str | None = None,
536
+ ) -> dict:
537
+ """Draft envelopes only. On an envelope created from a template it is
538
+ Pro and Enterprise (403 feature_not_available otherwise)."""
539
+ return self._client.request(
540
+ "POST",
541
+ f"/envelopes/{_id(envelope_id)}/attention-points/",
542
+ json=_attention_body(title, body, document_id, page, requires_ack),
543
+ idempotency_key=idempotency_key,
544
+ )
545
+
546
+ def update_attention_point(self, envelope_id: str, point_id: str, **changes) -> dict:
547
+ """title, body, document_id, page, requires_ack (drafts only)."""
548
+ return self._client.request(
549
+ "PATCH",
550
+ f"/envelopes/{_id(envelope_id)}/attention-points/{_id(point_id)}/",
551
+ json=changes,
552
+ )
553
+
554
+ def delete_attention_point(self, envelope_id: str, point_id: str):
555
+ return self._client.request(
556
+ "DELETE", f"/envelopes/{_id(envelope_id)}/attention-points/{_id(point_id)}/"
557
+ )
558
+
352
559
  def void(self, envelope_id: str, *, reason: str = "") -> dict:
353
560
  return self._client.request(
354
561
  "POST", f"/envelopes/{_id(envelope_id)}/void/", json={"reason": reason}
355
562
  )
356
563
 
564
+ def retry_countersign(self, envelope_id: str) -> dict:
565
+ """Signed by everybody and waiting (countersign.status == "failed"):
566
+ try the organization's countersignature again, e.g. after a new
567
+ authorization was signed."""
568
+ return self._client.request("POST", f"/envelopes/{_id(envelope_id)}/countersign/retry/")
569
+
570
+ def skip_countersign(self, envelope_id: str, *, reason: str) -> dict:
571
+ """Complete it WITHOUT the organization's countersignature (the
572
+ reason is required and stated in the certificate)."""
573
+ return self._client.request(
574
+ "POST",
575
+ f"/envelopes/{_id(envelope_id)}/countersign/skip/",
576
+ json={"reason": reason},
577
+ )
578
+
357
579
  def download_certificate(self, envelope_id: str, *, to: str | Path | None = None) -> bytes:
358
580
  content = self._client.request(
359
581
  "GET", f"/envelopes/{_id(envelope_id)}/certificate/", raw=True
@@ -402,7 +624,9 @@ class Envelopes(_Resource):
402
624
  include_biometrics: bool = False,
403
625
  include_identity_proofs: bool = False,
404
626
  ) -> bytes:
405
- """The full evidence ZIP — what a tenant would take to court."""
627
+ """The full evidence ZIP — what a tenant would take to court. With the
628
+ platform seal on, it is an ASiC-E container signed by the platform
629
+ (manifest.json → `container` describes it)."""
406
630
  content = self._client.request(
407
631
  "GET",
408
632
  f"/envelopes/{_id(envelope_id)}/evidence/",
@@ -450,7 +674,9 @@ class Templates(_Resource):
450
674
 
451
675
  def update(self, template_id: str, **changes) -> dict:
452
676
  """PATCH settings: name, description, signing_order,
453
- retention_period_months, variables (schema list), metadata."""
677
+ retention_period_months, variables (schema list), metadata,
678
+ group_shared (offer it to the organization group), follow_updates
679
+ (a group copy: notices of the source's new versions)."""
454
680
  return self._client.request("PATCH", f"/templates/{_id(template_id)}/", json=changes)
455
681
 
456
682
  def catalog(self, *, language: str | None = None) -> dict:
@@ -578,6 +804,34 @@ class Templates(_Resource):
578
804
  f"/templates/{_id(template_id)}/documents/{_id(document_id)}/translations/{_seg(language)}/",
579
805
  )
580
806
 
807
+ def autotranslate(
808
+ self,
809
+ template_id: str,
810
+ document_id: str,
811
+ language: str,
812
+ *,
813
+ acknowledge: bool,
814
+ overwrite: bool = False,
815
+ idempotency_key: str | None = None,
816
+ ) -> dict:
817
+ """Queue a MACHINE DRAFT of a language version: translators plus a
818
+ reviewer panel with quorum. Returns the job (202); poll
819
+ `get_translation_job`. `acknowledge=True` is required: the result is
820
+ a draft that a person reviews and approves in the panel. `overwrite`
821
+ replaces a translation that already has its own text."""
822
+ return self._client.request(
823
+ "POST",
824
+ f"/templates/{_id(template_id)}/documents/{_id(document_id)}/translations/{_seg(language)}/autotranslate/",
825
+ json={"acknowledge": acknowledge, "overwrite": overwrite},
826
+ idempotency_key=idempotency_key,
827
+ )
828
+
829
+ def get_translation_job(self, job_id: str) -> dict:
830
+ """Status of a machine translation: `verdict` (consensus, quorum,
831
+ no_quorum, unreviewed, simulated) and, when done, the reviewers'
832
+ `notes` with an English back-translation."""
833
+ return self._client.request("GET", f"/translation-jobs/{_id(job_id)}/")
834
+
581
835
  def download_docx(
582
836
  self, template_id: str, document_id: str, *, to: str | Path | None = None
583
837
  ) -> bytes:
@@ -600,6 +854,47 @@ class Templates(_Resource):
600
854
  data={"name": name} if name else None,
601
855
  )
602
856
 
857
+ def set_margin_image(
858
+ self,
859
+ template_id: str,
860
+ document_id: str,
861
+ slot: str,
862
+ *,
863
+ file=None,
864
+ align: str | None = None,
865
+ name: str | None = None,
866
+ ) -> dict:
867
+ """Put, replace or move the image of the ``slot`` margin (``"header"``
868
+ or ``"footer"``) of an authored document of the template draft: a
869
+ PNG, JPEG or WebP of 2 MB at most (``file``: path, bytes or file
870
+ object) and/or ``align`` (``"start"``, ``"center"``, ``"end"``).
871
+ A new image is published after its malware scan (see
872
+ ``header_image_upload`` / ``footer_image_upload`` in the returned
873
+ document); approving a new version then puts it in new envelopes."""
874
+ if slot not in ("header", "footer"):
875
+ raise ValueError("slot must be 'header' or 'footer'")
876
+ path = f"/templates/{_id(template_id)}/documents/{_id(document_id)}/margin-images/{slot}/"
877
+ if file is None:
878
+ if align is None:
879
+ raise ValueError("give file, align or both")
880
+ return self._client.request("POST", path, json={"align": align})
881
+ mime = _image_mime(file, name)
882
+ return self._client.request(
883
+ "POST",
884
+ path,
885
+ files={"file": _file_tuple(file, name or f"{slot}.{mime.split('/')[1]}", mime)},
886
+ data={"align": align} if align else None,
887
+ )
888
+
889
+ def delete_margin_image(self, template_id: str, document_id: str, slot: str) -> dict:
890
+ """Remove the image of the ``slot`` margin (``"header"`` or ``"footer"``)."""
891
+ if slot not in ("header", "footer"):
892
+ raise ValueError("slot must be 'header' or 'footer'")
893
+ return self._client.request(
894
+ "DELETE",
895
+ f"/templates/{_id(template_id)}/documents/{_id(document_id)}/margin-images/{slot}/",
896
+ )
897
+
603
898
  def preview(
604
899
  self,
605
900
  template_id: str,
@@ -609,11 +904,16 @@ class Templates(_Resource):
609
904
  signers: dict[str, dict] | None = None,
610
905
  title: str = "",
611
906
  language: str | None = None,
907
+ technical: bool = False,
612
908
  to: str | Path | None = None,
613
909
  ) -> bytes:
614
910
  """PDF preview of the authored documents (sample values where
615
- none are given). Nothing is persisted."""
911
+ none are given). ``technical=True``: the technical view of the
912
+ draft instead (every fillable field explained, with examples and
913
+ how the API passes each one). Nothing is persisted."""
616
914
  body: dict = {"variables": variables or {}, "signers": signers or {}, "title": title}
915
+ if technical:
916
+ body["technical"] = True
617
917
  if document_id:
618
918
  body["document_id"] = document_id
619
919
  if language:
@@ -666,6 +966,186 @@ class Templates(_Resource):
666
966
  body["auth_methods"] = auth_methods
667
967
  return self._client.request("POST", f"/templates/{_id(template_id)}/roles/", json=body)
668
968
 
969
+ def update_role(
970
+ self,
971
+ template_id: str,
972
+ role_id: str,
973
+ *,
974
+ key: str | None = None,
975
+ label: str | None = None,
976
+ order: int | None = None,
977
+ auth_methods: list[str] | None = None,
978
+ adopt_tokens_of: str | None = None,
979
+ ) -> dict:
980
+ """Edit a role of the template draft. A new ``key`` rewrites the
981
+ role's tokens in every authored document and translation (changed
982
+ translations go back to draft); ``adopt_tokens_of`` gives the role
983
+ the tokens of a key no role has (``unknown_roles`` of the template
984
+ with ``expand=roles``); ``renamed_in`` says where."""
985
+ body: dict = {}
986
+ for name, value in (
987
+ ("key", key),
988
+ ("label", label),
989
+ ("order", order),
990
+ ("auth_methods", auth_methods),
991
+ ("adopt_tokens_of", adopt_tokens_of),
992
+ ):
993
+ if value is not None:
994
+ body[name] = value
995
+ return self._client.request(
996
+ "PATCH", f"/templates/{_id(template_id)}/roles/{_id(role_id)}/", json=body
997
+ )
998
+
999
+ def delete_role(self, template_id: str, role_id: str) -> None:
1000
+ """Remove a role from the draft (refused while a document uses its tokens)."""
1001
+ self._client.request("DELETE", f"/templates/{_id(template_id)}/roles/{_id(role_id)}/")
1002
+
1003
+ def list_versions(self, template_id: str) -> dict:
1004
+ """Approved versions kept, newest first, plus ``current_version`` and
1005
+ ``has_unapproved_changes`` (the draft differs from the version in use)."""
1006
+ return self._client.request("GET", f"/templates/{_id(template_id)}/versions/")
1007
+
1008
+ def approve_version(
1009
+ self, template_id: str, *, note: str = "", idempotency_key: str | None = None
1010
+ ) -> dict:
1011
+ """Approve the draft as the next version: envelopes created from now
1012
+ on use it. Raises on 409 ``template_unchanged`` when nothing changed."""
1013
+ return self._client.request(
1014
+ "POST",
1015
+ f"/templates/{_id(template_id)}/versions/",
1016
+ json={"note": note} if note else {},
1017
+ idempotency_key=idempotency_key,
1018
+ )
1019
+
1020
+ def compare_versions(
1021
+ self, template_id: str, *, from_: int | str | None = None, to: int | str = "draft"
1022
+ ) -> dict:
1023
+ """What changed from ``from_`` to ``to`` (a version number or
1024
+ ``"draft"``). Default: from the version in use to the draft."""
1025
+ params: dict = {"to": str(to)}
1026
+ if from_ is not None:
1027
+ params["from"] = str(from_)
1028
+ return self._client.request(
1029
+ "GET", f"/templates/{_id(template_id)}/versions/compare/", params=params
1030
+ )
1031
+
1032
+ def restore_version(
1033
+ self, template_id: str, number: int, *, idempotency_key: str | None = None
1034
+ ) -> dict:
1035
+ """Copy version ``number`` into the draft (unapproved draft work is
1036
+ lost). Approve the draft afterwards: it becomes a new version."""
1037
+ return self._client.request(
1038
+ "POST",
1039
+ f"/templates/{_id(template_id)}/versions/{int(number)}/restore/",
1040
+ idempotency_key=idempotency_key,
1041
+ )
1042
+
1043
+ def get_version(self, template_id: str, number: int) -> dict:
1044
+ return self._client.request("GET", f"/templates/{_id(template_id)}/versions/{int(number)}/")
1045
+
1046
+ def list_attention_points(self, template_id: str) -> dict:
1047
+ return self._client.request("GET", f"/templates/{_id(template_id)}/attention-points/")
1048
+
1049
+ def add_attention_point(
1050
+ self,
1051
+ template_id: str,
1052
+ *,
1053
+ title: str,
1054
+ body: str = "",
1055
+ document_id: str | None = None,
1056
+ page: int | None = None,
1057
+ requires_ack: bool = False,
1058
+ idempotency_key: str | None = None,
1059
+ ) -> dict:
1060
+ """Default attention point of every envelope created from the template."""
1061
+ return self._client.request(
1062
+ "POST",
1063
+ f"/templates/{_id(template_id)}/attention-points/",
1064
+ json=_attention_body(title, body, document_id, page, requires_ack),
1065
+ idempotency_key=idempotency_key,
1066
+ )
1067
+
1068
+ def update_attention_point(self, template_id: str, point_id: str, **changes) -> dict:
1069
+ """title, body, document_id, page, requires_ack."""
1070
+ return self._client.request(
1071
+ "PATCH",
1072
+ f"/templates/{_id(template_id)}/attention-points/{_id(point_id)}/",
1073
+ json=changes,
1074
+ )
1075
+
1076
+ def delete_attention_point(self, template_id: str, point_id: str):
1077
+ return self._client.request(
1078
+ "DELETE", f"/templates/{_id(template_id)}/attention-points/{_id(point_id)}/"
1079
+ )
1080
+
1081
+ def upstream(self, template_id: str) -> dict:
1082
+ """A copy of a group template: its source and whether it has a newer
1083
+ approved version (`update_available`)."""
1084
+ return self._client.request("GET", f"/templates/{_id(template_id)}/upstream/")
1085
+
1086
+ def compare_upstream(self, template_id: str) -> dict:
1087
+ """What pulling the source's latest approved version would change."""
1088
+ return self._client.request("GET", f"/templates/{_id(template_id)}/upstream/compare/")
1089
+
1090
+ def pull_upstream(self, template_id: str, *, idempotency_key: str | None = None) -> dict:
1091
+ """The source's latest approved version replaces the copy's draft
1092
+ (translations as drafts); approve the draft to use it."""
1093
+ return self._client.request(
1094
+ "POST",
1095
+ f"/templates/{_id(template_id)}/upstream/pull/",
1096
+ json={},
1097
+ idempotency_key=idempotency_key,
1098
+ )
1099
+
1100
+ def list_attention_translations(self, template_id: str) -> dict:
1101
+ """Per language: how many attention points are approved, stale, draft
1102
+ or missing. `complete` = usable for envelopes in that language."""
1103
+ return self._client.request(
1104
+ "GET", f"/templates/{_id(template_id)}/attention-points/translations/"
1105
+ )
1106
+
1107
+ def get_attention_translation(self, template_id: str, language: str) -> dict:
1108
+ """The attention points in one language, with their source text."""
1109
+ return self._client.request(
1110
+ "GET", f"/templates/{_id(template_id)}/attention-points/translations/{_seg(language)}/"
1111
+ )
1112
+
1113
+ def set_attention_translation(self, template_id: str, language: str, points: list) -> dict:
1114
+ """Write the text of one or more points in a language, as drafts:
1115
+ `points=[{"point_id": ..., "title": ..., "body": ...}]`. Approval is
1116
+ a human act in the panel."""
1117
+ return self._client.request(
1118
+ "PUT",
1119
+ f"/templates/{_id(template_id)}/attention-points/translations/{_seg(language)}/",
1120
+ json={"points": points},
1121
+ )
1122
+
1123
+ def delete_attention_translation(self, template_id: str, language: str):
1124
+ return self._client.request(
1125
+ "DELETE",
1126
+ f"/templates/{_id(template_id)}/attention-points/translations/{_seg(language)}/",
1127
+ )
1128
+
1129
+ def autotranslate_attention_points(
1130
+ self,
1131
+ template_id: str,
1132
+ language: str,
1133
+ *,
1134
+ acknowledge: bool,
1135
+ overwrite: bool = False,
1136
+ idempotency_key: str | None = None,
1137
+ ) -> dict:
1138
+ """Queue a MACHINE DRAFT of the attention points in `language` (202,
1139
+ poll `get_translation_job`). A document's autotranslation drafts
1140
+ them too. `overwrite` also replaces points with their own or an
1141
+ approved text."""
1142
+ return self._client.request(
1143
+ "POST",
1144
+ f"/templates/{_id(template_id)}/attention-points/translations/{_seg(language)}/autotranslate/",
1145
+ json={"acknowledge": acknowledge, "overwrite": overwrite},
1146
+ idempotency_key=idempotency_key,
1147
+ )
1148
+
669
1149
  def add_field(
670
1150
  self,
671
1151
  template_id: str,
@@ -714,12 +1194,20 @@ class Templates(_Resource):
714
1194
  language: str | None = None,
715
1195
  kyc_images_retention: str | None = None,
716
1196
  biometrics: bool = False,
1197
+ project: str | None = None,
1198
+ countersign: str | None = None,
1199
+ attention_points: list[dict] | None = None,
717
1200
  idempotency_key: str | None = None,
718
1201
  ) -> dict:
719
1202
  """The one-call automation flow (§10.4): instantiate and optionally
720
1203
  send, getting each signer's sign_url back for embedded flows.
721
1204
  `variables` fills the tokens of authored documents; `language` picks
722
- the source or an approved translation."""
1205
+ the source or an approved translation. `countersign="none"` creates it
1206
+ without the organization's automatic countersignature (default
1207
+ "auto": countersigned when the template has an active authorization;
1208
+ other values than the authorized ones give 409). `attention_points`
1209
+ replaces the template's for this envelope only (Pro and Enterprise;
1210
+ None = the template's, [] = none)."""
723
1211
  body: dict = {
724
1212
  "signers": signers,
725
1213
  "title": title,
@@ -736,6 +1224,12 @@ class Templates(_Resource):
736
1224
  body["biometrics"] = True
737
1225
  if expires_in_days is not None:
738
1226
  body["expires_in_days"] = expires_in_days
1227
+ if project:
1228
+ body["project"] = project
1229
+ if countersign:
1230
+ body["countersign"] = countersign
1231
+ if attention_points is not None:
1232
+ body["attention_points"] = attention_points
739
1233
  return self._client.request(
740
1234
  "POST",
741
1235
  f"/templates/{_id(template_id)}/envelopes/",
@@ -743,22 +1237,125 @@ class Templates(_Resource):
743
1237
  idempotency_key=idempotency_key,
744
1238
  )
745
1239
 
1240
+ def request_countersign_authorization(
1241
+ self,
1242
+ template_id: str,
1243
+ *,
1244
+ authorizer_email: str,
1245
+ authorizer_title: str,
1246
+ valid_until: str,
1247
+ max_uses: int | None = None,
1248
+ variables: dict | None = None,
1249
+ project: str | None = None,
1250
+ scope: str | None = None,
1251
+ ) -> dict:
1252
+ """Ask an owner/admin to sign the standing authorization for the
1253
+ organization's automatic countersignature (Pro+). The signing link
1254
+ goes to the authorizer by e-mail only; it becomes active when signed.
1255
+ ``scope``: ``"version"`` (default, the approved version in use only) or
1256
+ ``"future_versions"`` (also the versions approved later, except one
1257
+ adding variables whose values it does not fix)."""
1258
+ body: dict = {
1259
+ "authorizer_email": authorizer_email,
1260
+ "authorizer_title": authorizer_title,
1261
+ "valid_until": valid_until,
1262
+ "variables": variables or {},
1263
+ }
1264
+ if max_uses is not None:
1265
+ body["max_uses"] = max_uses
1266
+ if project:
1267
+ body["project"] = project
1268
+ if scope:
1269
+ body["scope"] = scope
1270
+ return self._client.request(
1271
+ "POST", f"/templates/{_id(template_id)}/countersign-authorizations/", json=body
1272
+ )
746
1273
 
747
- class Webhooks(_Resource):
748
- def create(
749
- self, *, url: str, event_types: list[str] | None = None, description: str = ""
1274
+ def list_countersign_authorizations(
1275
+ self, template_id: str, *, limit: int | None = None, cursor: str | None = None
750
1276
  ) -> dict:
751
- """The response includes `secret` (whsec_…) ONCE — store it now."""
1277
+ params = {k: v for k, v in {"limit": limit, "cursor": cursor}.items() if v is not None}
1278
+ return self._client.request(
1279
+ "GET", f"/templates/{_id(template_id)}/countersign-authorizations/", params=params
1280
+ )
1281
+
1282
+
1283
+ class CountersignAuthorizations(_Resource):
1284
+ """Standing authorizations for the organization's automatic
1285
+ countersignature (docs: «Contrafirma automática»)."""
1286
+
1287
+ def list(self, *, limit: int | None = None, cursor: str | None = None) -> dict:
1288
+ params = {k: v for k, v in {"limit": limit, "cursor": cursor}.items() if v is not None}
1289
+ return self._client.request("GET", "/countersign-authorizations/", params=params)
1290
+
1291
+ def get(self, authorization_id: str) -> dict:
1292
+ return self._client.request("GET", f"/countersign-authorizations/{_id(authorization_id)}/")
1293
+
1294
+ def revoke(self, authorization_id: str, *, reason: str = "") -> dict:
1295
+ """Revoke an active one (what was countersigned stays) or cancel a
1296
+ pending one (its signing envelope is voided)."""
752
1297
  return self._client.request(
753
1298
  "POST",
754
- "/webhooks/",
755
- json={"url": url, "event_types": event_types or [], "description": description},
1299
+ f"/countersign-authorizations/{_id(authorization_id)}/revoke/",
1300
+ json={"reason": reason},
756
1301
  )
757
1302
 
1303
+
1304
+ class Webhooks(_Resource):
1305
+ def create(
1306
+ self,
1307
+ *,
1308
+ url: str,
1309
+ event_types: list[str] | None = None,
1310
+ description: str = "",
1311
+ project: str | None = None,
1312
+ ) -> dict:
1313
+ """The response includes `secret` (whsec_…) ONCE — store it now.
1314
+ Mode = the key's. `project` (organization keys only): receive only
1315
+ that project's events; a project key always registers for its own."""
1316
+ body: dict = {"url": url, "event_types": event_types or [], "description": description}
1317
+ if project:
1318
+ body["project"] = project
1319
+ return self._client.request("POST", "/webhooks/", json=body)
1320
+
758
1321
  def list(self, *, limit: int | None = None, cursor: str | None = None) -> dict:
759
1322
  params = {k: v for k, v in {"limit": limit, "cursor": cursor}.items() if v}
760
1323
  return self._client.request("GET", "/webhooks/", params=params)
761
1324
 
1325
+ def get(self, endpoint_id: str) -> dict:
1326
+ return self._client.request("GET", f"/webhooks/{_id(endpoint_id)}/")
1327
+
1328
+ def update(
1329
+ self,
1330
+ endpoint_id: str,
1331
+ *,
1332
+ url: str | None = None,
1333
+ event_types: list[str] | None = None,
1334
+ is_active: bool | None = None,
1335
+ description: str | None = None,
1336
+ ) -> dict:
1337
+ """Change only what you pass. Reactivating counts against the plan."""
1338
+ body = {
1339
+ k: v
1340
+ for k, v in {
1341
+ "url": url,
1342
+ "event_types": event_types,
1343
+ "is_active": is_active,
1344
+ "description": description,
1345
+ }.items()
1346
+ if v is not None
1347
+ }
1348
+ return self._client.request("PATCH", f"/webhooks/{_id(endpoint_id)}/", json=body)
1349
+
1350
+ def rotate_secret(self, endpoint_id: str, *, grace_period_hours: int = 24) -> dict:
1351
+ """New `secret` returned ONCE. For `grace_period_hours` (0-72) deliveries
1352
+ carry both signatures: verify with [new, old] until you switch."""
1353
+ return self._client.request(
1354
+ "POST",
1355
+ f"/webhooks/{_id(endpoint_id)}/rotate-secret/",
1356
+ json={"grace_period_hours": grace_period_hours},
1357
+ )
1358
+
762
1359
  def delete(self, endpoint_id: str) -> None:
763
1360
  self._client.request("DELETE", f"/webhooks/{_id(endpoint_id)}/")
764
1361
 
@@ -766,6 +1363,69 @@ class Webhooks(_Resource):
766
1363
  return self._client.request("POST", f"/webhooks/deliveries/{_id(delivery_id)}/redeliver/")
767
1364
 
768
1365
 
1366
+ class Projects(_Resource):
1367
+ """Projects inside the organization (paid plans). An organization key
1368
+ manages them all; a project key only reads its own."""
1369
+
1370
+ def list(
1371
+ self,
1372
+ *,
1373
+ is_active: bool | None = None,
1374
+ limit: int | None = None,
1375
+ cursor: str | None = None,
1376
+ ) -> dict:
1377
+ params = {
1378
+ k: v
1379
+ for k, v in {
1380
+ "is_active": None if is_active is None else str(is_active).lower(),
1381
+ "limit": limit,
1382
+ "cursor": cursor,
1383
+ }.items()
1384
+ if v is not None
1385
+ }
1386
+ return self._client.request("GET", "/projects/", params=params)
1387
+
1388
+ def create(
1389
+ self,
1390
+ *,
1391
+ name: str,
1392
+ description: str = "",
1393
+ embed_allowed_origins: list[str] | None = None,
1394
+ idempotency_key: str | None = None,
1395
+ ) -> dict:
1396
+ body: dict = {"name": name, "description": description}
1397
+ if embed_allowed_origins is not None:
1398
+ body["embed_allowed_origins"] = list(embed_allowed_origins)
1399
+ return self._client.request(
1400
+ "POST", "/projects/", json=body, idempotency_key=idempotency_key
1401
+ )
1402
+
1403
+ def get(self, project_id: str) -> dict:
1404
+ return self._client.request("GET", f"/projects/{_id(project_id)}/")
1405
+
1406
+ def update(
1407
+ self,
1408
+ project_id: str,
1409
+ *,
1410
+ name: str | None = None,
1411
+ description: str | None = None,
1412
+ is_active: bool | None = None,
1413
+ embed_allowed_origins: list[str] | None = None,
1414
+ ) -> dict:
1415
+ """`is_active=False` archives: its keys and webhooks stop working."""
1416
+ body = {
1417
+ k: v
1418
+ for k, v in {
1419
+ "name": name,
1420
+ "description": description,
1421
+ "is_active": is_active,
1422
+ "embed_allowed_origins": embed_allowed_origins,
1423
+ }.items()
1424
+ if v is not None
1425
+ }
1426
+ return self._client.request("PATCH", f"/projects/{_id(project_id)}/", json=body)
1427
+
1428
+
769
1429
  class Events(_Resource):
770
1430
  """The consultable event log — reconciliation source when a webhook is
771
1431
  lost (poll `list` and compare against what you received)."""
@@ -870,20 +1530,54 @@ class Organization(_Resource):
870
1530
  """Org info including plan limits and features."""
871
1531
  return self._client.request("GET", "/organization/")
872
1532
 
1533
+ def group(self) -> dict:
1534
+ """The organization group (Enterprise): the group, its members and the
1535
+ invitations received. Read only: the acts happen in the panel."""
1536
+ return self._client.request("GET", "/organization/group/")
1537
+
1538
+ def group_templates(self) -> dict:
1539
+ """Templates the other organizations of the group offer (approved
1540
+ version, names only)."""
1541
+ return self._client.request("GET", "/organization/group/templates/")
1542
+
1543
+ def copy_group_template(
1544
+ self, template_id: str, *, name: str = "", idempotency_key: str | None = None
1545
+ ) -> dict:
1546
+ """Copy an offered template into this organization as a new DRAFT
1547
+ (translations too): approve it and request its own countersign."""
1548
+ return self._client.request(
1549
+ "POST",
1550
+ f"/organization/group/templates/{_id(template_id)}/copy/",
1551
+ json={"name": name} if name else {},
1552
+ idempotency_key=idempotency_key,
1553
+ )
1554
+
873
1555
  def update(
874
1556
  self,
875
1557
  *,
876
1558
  retention_policies: dict | None = None,
877
1559
  embed_allowed_origins: list[str] | None = None,
1560
+ seal_profile: str | None = None,
1561
+ acknowledge: bool = False,
878
1562
  ) -> dict:
879
1563
  """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)."""
1564
+ document type (months within the plan's range), the web origins
1565
+ allowed to embed the signer flow (the FULL list; [] disables it) and
1566
+ the cryptographic restrictions of the platform seal.
1567
+
1568
+ `seal_profile`: "hybrid" (ECDSA P-384 + ML-DSA-87, quantum resistant;
1569
+ readers without ML-DSA show that signature as unknown) or "classic"
1570
+ (ECDSA only, no warnings, not quantum resistant). Changing it needs
1571
+ `acknowledge=True`: you confirm the trade-off described in
1572
+ `get()["cryptography"]["options"]`. It applies to envelopes sent
1573
+ afterwards."""
882
1574
  body: dict = {}
883
1575
  if retention_policies is not None:
884
1576
  body["retention_policies"] = retention_policies
885
1577
  if embed_allowed_origins is not None:
886
1578
  body["embed_allowed_origins"] = list(embed_allowed_origins)
1579
+ if seal_profile is not None:
1580
+ body["cryptography"] = {"seal_profile": seal_profile, "acknowledge": acknowledge}
887
1581
  return self._client.request("PATCH", "/organization/", json=body)
888
1582
 
889
1583
 
@@ -5,12 +5,16 @@ easycontract signs every delivery with
5
5
  X-EasyContract-Signature: t=<unix>,v1=<hex>
6
6
 
7
7
  where v1 = HMAC-SHA256(secret, f"{t}.{body}"). Verify with your endpoint's
8
- whsec_… secret (shown once at creation):
8
+ whsec_… secret (shown once at creation or rotation):
9
9
 
10
10
  event = easycontract.webhooks.verify_signature(
11
11
  payload=request.body, header=request.headers["X-EasyContract-Signature"],
12
12
  secret=WEBHOOK_SECRET,
13
13
  )
14
+
15
+ After a secret rotation, during the grace period, the header carries one v1
16
+ per valid secret (`t=…,v1=<new>,v1=<old>`): the delivery is valid if ANY v1
17
+ matches. `secret` may also be a list (e.g. [new, old]) while you switch.
14
18
  """
15
19
 
16
20
  from __future__ import annotations
@@ -29,7 +33,7 @@ def verify_signature(
29
33
  *,
30
34
  payload: bytes | str,
31
35
  header: str,
32
- secret: bytes | str,
36
+ secret: bytes | str | list | tuple,
33
37
  tolerance: int | None = DEFAULT_TOLERANCE_SECONDS,
34
38
  ) -> dict:
35
39
  """Verify a delivery and return the parsed event. Raises
@@ -51,20 +55,34 @@ def verify_signature(
51
55
  body = payload.encode("utf-8")
52
56
  else:
53
57
  raise SignatureVerificationError("payload must be bytes or str")
54
- secret_bytes = secret if isinstance(secret, (bytes, bytearray)) else str(secret).encode("utf-8")
58
+ secrets = list(secret) if isinstance(secret, (list, tuple)) else [secret]
59
+ if not secrets:
60
+ raise SignatureVerificationError("no secret given")
61
+ secret_keys = [
62
+ s if isinstance(s, (bytes, bytearray)) else str(s).encode("utf-8") for s in secrets
63
+ ]
55
64
 
56
65
  try:
57
- parts = dict(part.split("=", 1) for part in header.split(","))
58
- timestamp = int(parts["t"])
59
- signature = parts["v1"]
66
+ pairs = [part.split("=", 1) for part in header.split(",")]
67
+ timestamps = [value for key, value in pairs if key.strip() == "t"]
68
+ signatures = [value.strip() for key, value in pairs if key.strip() == "v1"]
69
+ if len(timestamps) != 1 or not signatures:
70
+ raise KeyError("t/v1")
71
+ timestamp = int(timestamps[0])
60
72
  except (ValueError, KeyError) as exc:
61
73
  raise SignatureVerificationError("malformed signature header") from exc
62
74
 
63
75
  if tolerance is not None and abs(time.time() - timestamp) > tolerance:
64
76
  raise SignatureVerificationError("timestamp outside tolerance (possible replay)")
65
77
 
66
- expected = hmac.new(secret_bytes, b"%d." % timestamp + body, hashlib.sha256).hexdigest()
67
- if not hmac.compare_digest(expected.encode(), signature.encode()):
78
+ signed = b"%d." % timestamp + body
79
+ matched = False
80
+ for key in secret_keys:
81
+ expected = hmac.new(key, signed, hashlib.sha256).hexdigest().encode()
82
+ for signature in signatures:
83
+ # no early exit: constant work whatever matches
84
+ matched |= hmac.compare_digest(expected, signature.encode())
85
+ if not matched:
68
86
  raise SignatureVerificationError("signature mismatch")
69
87
  try:
70
88
  return json.loads(body.decode("utf-8"))
File without changes
File without changes
File without changes