autosignly 0.1.0.dev0__tar.gz → 0.1.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: autosignly
3
- Version: 0.1.0.dev0
3
+ Version: 0.1.3
4
4
  Summary: Python client for the Autosignly API - eIDAS electronic signatures and document workflows
5
5
  Project-URL: Homepage, https://autosignly.eu
6
6
  Project-URL: Documentation, https://docs.16it.eu/docs/intro/
@@ -69,6 +69,13 @@ for summary in client.iter_documents(status="SIGNED"):
69
69
  print(summary.id, summary.name)
70
70
  ```
71
71
 
72
+ Both listing calls take `tag_id` as well. Several tags narrow the result — a document has to
73
+ carry all of them — and a tag that does not exist gives an empty page rather than an error:
74
+
75
+ ```python
76
+ page = client.list_documents(tag_id=["contracts", "2026"], status="SIGNED")
77
+ ```
78
+
72
79
  ## Downloading the file
73
80
 
74
81
  A document carries a short-lived link to its file. The link expires, so fetch the document again
@@ -85,6 +92,44 @@ open("signed.pdf", "wb").write(pdf)
85
92
  A document that is still being signed can be downloaded as well - it then carries only the
86
93
  signatures collected so far.
87
94
 
95
+ ## Attachments
96
+
97
+ Files attached to a document are converted to PDF and merged into it when it is sent for
98
+ signing, behind an index page listing each one with its checksum — so a single signature
99
+ covers the document and everything attached to it.
100
+
101
+ Attachments can only be added before the document is sent, so upload it first and send it
102
+ afterwards instead of using `upload_and_sign`:
103
+
104
+ ```python
105
+ document_id = client.upload_pdf(
106
+ pdf=open("protocol.pdf", "rb").read(),
107
+ document_name="Handover protocol",
108
+ )
109
+
110
+ attachment = client.add_attachment(
111
+ document_id,
112
+ content=open("site-photo.jpg", "rb").read(),
113
+ file_name="site-photo.jpg",
114
+ )
115
+ print(attachment.order_index, attachment.sha256)
116
+
117
+ for existing in client.list_attachments(document_id):
118
+ print(existing.file_name, existing.page_count)
119
+
120
+ client.send_for_signing(document_id, signers=[signer])
121
+ ```
122
+
123
+ An attachment can be dropped again while the document is still unsent:
124
+
125
+ ```python
126
+ client.delete_attachment(document_id, attachment.id)
127
+ ```
128
+
129
+ PDF, JPEG and PNG are accepted, recognised from the content rather than the file name.
130
+ Attachments merge in the order they were added, and can only be changed before the document
131
+ is sent for signing.
132
+
88
133
  ## Tags
89
134
 
90
135
  ```python
@@ -95,17 +140,63 @@ client.set_document_tags(document_id, tag_ids=[tag.id], names=["2026"])
95
140
  Setting tags replaces the whole set: tags left out are removed, and names that do not exist yet are
96
141
  added to the company tag pool.
97
142
 
143
+ ## Parties
144
+
145
+ A party is the other side of a document — a business or a natural person the company signs with.
146
+
147
+ ```python
148
+ from autosignly import Party, PartyAddress, PartyType
149
+
150
+ acme = client.create_party(Party(
151
+ type=PartyType.COMPANY,
152
+ name="Acme Sp. z o.o.",
153
+ tax_id="5842831253",
154
+ email="kontakt@acme.pl",
155
+ address=PartyAddress(street="Marszalkowska", number="12/34",
156
+ postal_code="00-001", city="Warszawa", country_code="PL"),
157
+ ))
158
+
159
+ for party in client.list_parties(name="acme", type=PartyType.COMPANY):
160
+ print(party.id, party.name, party.tax_id)
161
+
162
+ client.update_party(acme.id, Party(type=PartyType.COMPANY, name="Acme Renamed",
163
+ tax_id="5842831253"))
164
+ client.delete_party(acme.id)
165
+ ```
166
+
167
+ A `COMPANY` needs a `tax_id` and an `address`; a `PERSON` needs a `firstname` and an `email`. A
168
+ Polish address makes the tax id subject to the NIP checksum.
169
+
170
+ `update_party` replaces the whole party, so send every field you want to keep. Creating a party
171
+ that already exists — same tax id for a `COMPANY`, same e-mail for a `PERSON` — is rejected rather
172
+ than deduplicated, so look the party up before retrying a failed create.
173
+
174
+ Parties belong to the environment of the key that created them: a sandbox key never sees a
175
+ production party. Listing has no `sort` — the searchable fields are stored encrypted, so the
176
+ server cannot order by them.
177
+
98
178
  ## Verifying webhooks
99
179
 
100
- Autosignly signs every webhook delivery. Check the signature against the raw request body, before
101
- parsing it - re-serialising the JSON changes the bytes and the signature will not match.
180
+ Autosignly signs every delivery. Check the signature against the raw request body, before parsing
181
+ it - re-serialising the JSON changes the bytes and the signature will not match.
102
182
 
103
183
  ```python
104
184
  from autosignly import webhooks
105
185
 
106
- webhooks.verify(request.body, request.headers["X-Webhook-Signature"], webhook_key)
186
+ webhooks.verify(
187
+ request.body,
188
+ request.headers["X-Webhook-Signature"],
189
+ webhook_key,
190
+ request.headers["X-Webhook-Timestamp"],
191
+ )
107
192
  ```
108
193
 
194
+ The signature covers the timestamp as well as the body, and a delivery older than five minutes is
195
+ rejected even when its signature matches, so a captured request cannot be replayed later.
196
+
197
+ While a webhook key is being rotated a delivery carries several signatures; it is accepted when any
198
+ of them matches, so rotation needs no change on your side.
199
+
109
200
  `verify` raises `InvalidSignatureError` on a mismatch; `webhooks.is_valid(...)` returns a boolean
110
201
  instead.
111
202
 
@@ -0,0 +1,218 @@
1
+ # autosignly
2
+
3
+ Python client for the [Autosignly](https://autosignly.eu) API - eIDAS electronic signatures and
4
+ document workflows.
5
+
6
+ > **Not published yet.** This package is being built. Install from source for now.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ pip install autosignly
12
+ ```
13
+
14
+ Requires Python 3.10 or newer.
15
+
16
+ ## Quickstart
17
+
18
+ ```python
19
+ from autosignly import AutosignlyClient, Signer
20
+
21
+ with AutosignlyClient(api_key="api_key_...", api_secret="api_sct_...") as client:
22
+ document_id = client.upload_and_sign(
23
+ pdf=open("contract.pdf", "rb").read(),
24
+ document_name="Consulting agreement",
25
+ signers=[
26
+ Signer(
27
+ first_name="Anna",
28
+ last_name="Nowak",
29
+ email="anna@example.com",
30
+ country="PL",
31
+ )
32
+ ],
33
+ )
34
+ print(document_id)
35
+ ```
36
+
37
+ The key and secret decide which environment you are working in. Every environment, production or
38
+ sandbox, has its own pair, so pointing a script at the sandbox is a matter of swapping credentials.
39
+
40
+ The secret must stay on your server. It must never be shipped to a browser or a mobile app.
41
+
42
+ ## Reading documents
43
+
44
+ ```python
45
+ document = client.get_document(document_id)
46
+ print(document.status, [s.email for s in document.signers])
47
+
48
+ for summary in client.iter_documents(status="SIGNED"):
49
+ print(summary.id, summary.name)
50
+ ```
51
+
52
+ Both listing calls take `tag_id` as well. Several tags narrow the result — a document has to
53
+ carry all of them — and a tag that does not exist gives an empty page rather than an error:
54
+
55
+ ```python
56
+ page = client.list_documents(tag_id=["contracts", "2026"], status="SIGNED")
57
+ ```
58
+
59
+ ## Downloading the file
60
+
61
+ A document carries a short-lived link to its file. The link expires, so fetch the document again
62
+ for a fresh one rather than storing it.
63
+
64
+ ```python
65
+ document = client.get_document(document_id)
66
+ print(document.file_url)
67
+
68
+ pdf = client.download_document(document_id)
69
+ open("signed.pdf", "wb").write(pdf)
70
+ ```
71
+
72
+ A document that is still being signed can be downloaded as well - it then carries only the
73
+ signatures collected so far.
74
+
75
+ ## Attachments
76
+
77
+ Files attached to a document are converted to PDF and merged into it when it is sent for
78
+ signing, behind an index page listing each one with its checksum — so a single signature
79
+ covers the document and everything attached to it.
80
+
81
+ Attachments can only be added before the document is sent, so upload it first and send it
82
+ afterwards instead of using `upload_and_sign`:
83
+
84
+ ```python
85
+ document_id = client.upload_pdf(
86
+ pdf=open("protocol.pdf", "rb").read(),
87
+ document_name="Handover protocol",
88
+ )
89
+
90
+ attachment = client.add_attachment(
91
+ document_id,
92
+ content=open("site-photo.jpg", "rb").read(),
93
+ file_name="site-photo.jpg",
94
+ )
95
+ print(attachment.order_index, attachment.sha256)
96
+
97
+ for existing in client.list_attachments(document_id):
98
+ print(existing.file_name, existing.page_count)
99
+
100
+ client.send_for_signing(document_id, signers=[signer])
101
+ ```
102
+
103
+ An attachment can be dropped again while the document is still unsent:
104
+
105
+ ```python
106
+ client.delete_attachment(document_id, attachment.id)
107
+ ```
108
+
109
+ PDF, JPEG and PNG are accepted, recognised from the content rather than the file name.
110
+ Attachments merge in the order they were added, and can only be changed before the document
111
+ is sent for signing.
112
+
113
+ ## Tags
114
+
115
+ ```python
116
+ tag = client.create_tag("contracts")
117
+ client.set_document_tags(document_id, tag_ids=[tag.id], names=["2026"])
118
+ ```
119
+
120
+ Setting tags replaces the whole set: tags left out are removed, and names that do not exist yet are
121
+ added to the company tag pool.
122
+
123
+ ## Parties
124
+
125
+ A party is the other side of a document — a business or a natural person the company signs with.
126
+
127
+ ```python
128
+ from autosignly import Party, PartyAddress, PartyType
129
+
130
+ acme = client.create_party(Party(
131
+ type=PartyType.COMPANY,
132
+ name="Acme Sp. z o.o.",
133
+ tax_id="5842831253",
134
+ email="kontakt@acme.pl",
135
+ address=PartyAddress(street="Marszalkowska", number="12/34",
136
+ postal_code="00-001", city="Warszawa", country_code="PL"),
137
+ ))
138
+
139
+ for party in client.list_parties(name="acme", type=PartyType.COMPANY):
140
+ print(party.id, party.name, party.tax_id)
141
+
142
+ client.update_party(acme.id, Party(type=PartyType.COMPANY, name="Acme Renamed",
143
+ tax_id="5842831253"))
144
+ client.delete_party(acme.id)
145
+ ```
146
+
147
+ A `COMPANY` needs a `tax_id` and an `address`; a `PERSON` needs a `firstname` and an `email`. A
148
+ Polish address makes the tax id subject to the NIP checksum.
149
+
150
+ `update_party` replaces the whole party, so send every field you want to keep. Creating a party
151
+ that already exists — same tax id for a `COMPANY`, same e-mail for a `PERSON` — is rejected rather
152
+ than deduplicated, so look the party up before retrying a failed create.
153
+
154
+ Parties belong to the environment of the key that created them: a sandbox key never sees a
155
+ production party. Listing has no `sort` — the searchable fields are stored encrypted, so the
156
+ server cannot order by them.
157
+
158
+ ## Verifying webhooks
159
+
160
+ Autosignly signs every delivery. Check the signature against the raw request body, before parsing
161
+ it - re-serialising the JSON changes the bytes and the signature will not match.
162
+
163
+ ```python
164
+ from autosignly import webhooks
165
+
166
+ webhooks.verify(
167
+ request.body,
168
+ request.headers["X-Webhook-Signature"],
169
+ webhook_key,
170
+ request.headers["X-Webhook-Timestamp"],
171
+ )
172
+ ```
173
+
174
+ The signature covers the timestamp as well as the body, and a delivery older than five minutes is
175
+ rejected even when its signature matches, so a captured request cannot be replayed later.
176
+
177
+ While a webhook key is being rotated a delivery carries several signatures; it is accepted when any
178
+ of them matches, so rotation needs no change on your side.
179
+
180
+ `verify` raises `InvalidSignatureError` on a mismatch; `webhooks.is_valid(...)` returns a boolean
181
+ instead.
182
+
183
+ ## Errors
184
+
185
+ Every failure raises a subclass of `AutosignlyError` carrying the HTTP status and the error type
186
+ returned by the API.
187
+
188
+ ```python
189
+ from autosignly import AutosignlyError, NotFoundError
190
+
191
+ try:
192
+ client.get_document("does-not-exist")
193
+ except NotFoundError:
194
+ ...
195
+ except AutosignlyError as error:
196
+ print(error.status_code, error.error_type, error.error_id)
197
+ ```
198
+
199
+ Connection problems and server errors are retried automatically, with an exponential backoff and
200
+ jitter. Client errors are not retried, since repeating a rejected request cannot change its outcome.
201
+
202
+ Rate limits are retried too, honouring the delay the API asks for. When that delay is longer than a
203
+ minute the call fails instead of blocking your thread, and `RateLimitError.retry_after` tells you
204
+ how long to wait.
205
+
206
+ The client does not implement a circuit breaker. It runs inside your process, on calls you asked
207
+ for, so refusing to even attempt one would be surprising - and your own infrastructure is the right
208
+ place for that policy. Pass your own `http_client` if you want to add one.
209
+
210
+ ## Links
211
+
212
+ - Website: <https://autosignly.eu>
213
+ - API documentation: <https://docs.16it.eu/docs/intro/>
214
+ - Source and issues: <https://github.com/16it-pl/autosignly-sdk>
215
+
216
+ ## License
217
+
218
+ Apache-2.0
@@ -26,10 +26,19 @@ from .errors import (
26
26
  ValidationError,
27
27
  )
28
28
  from .models import (
29
+ Attachment,
30
+ AttachmentFormat,
31
+ AttachmentStatus,
32
+ Credentials,
29
33
  Document,
30
34
  DocumentStatus,
35
+ EnvironmentType,
31
36
  DocumentSummary,
32
37
  Page,
38
+ Party,
39
+ PartyAddress,
40
+ PartyType,
41
+ SignatureMode,
33
42
  SignatureType,
34
43
  Signer,
35
44
  SignerDetails,
@@ -53,10 +62,19 @@ __all__ = [
53
62
  "RateLimitError",
54
63
  "ServerError",
55
64
  "ValidationError",
65
+ "Attachment",
66
+ "AttachmentFormat",
67
+ "AttachmentStatus",
68
+ "Credentials",
56
69
  "Document",
57
70
  "DocumentStatus",
71
+ "EnvironmentType",
58
72
  "DocumentSummary",
59
73
  "Page",
74
+ "Party",
75
+ "PartyAddress",
76
+ "PartyType",
77
+ "SignatureMode",
60
78
  "SignatureType",
61
79
  "Signer",
62
80
  "SignerDetails",
@@ -0,0 +1 @@
1
+ __version__ = "0.1.3"
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import mimetypes
6
7
  import random
7
8
  import time
8
9
  import uuid
@@ -13,9 +14,12 @@ import httpx
13
14
  from . import errors
14
15
  from ._version import __version__
15
16
  from .models import (
17
+ Attachment,
18
+ Credentials,
16
19
  Document,
17
20
  DocumentSummary,
18
21
  Page,
22
+ Party,
19
23
  Signer,
20
24
  SigningRequestResult,
21
25
  Tag,
@@ -87,23 +91,40 @@ class AutosignlyClient:
87
91
  payload = self._request("GET", "/api-key")
88
92
  return bool(payload.get("valid", False))
89
93
 
94
+ def describe_credentials(self) -> Credentials:
95
+ """Report which company and environment this key and secret resolve to.
96
+
97
+ Useful before a first call: it says whether the pair points at
98
+ production or at a sandbox, without touching any document.
99
+ """
100
+ payload = self._request("GET", "/credentials")
101
+ return Credentials.from_payload(payload)
102
+
90
103
  # -- documents -----------------------------------------------------------
91
104
 
92
105
  def list_documents(
93
106
  self,
94
107
  *,
95
108
  status: str | Sequence[str] | None = None,
109
+ tag_id: str | Sequence[str] | None = None,
96
110
  page: int = 0,
97
111
  size: int = 20,
98
112
  sort: str | None = None,
99
113
  ) -> Page[DocumentSummary]:
100
- """Return one page of documents belonging to this environment."""
114
+ """Return one page of documents belonging to this environment.
115
+
116
+ Several tags narrow the result: a document has to carry all of them. A
117
+ tag that does not exist yields an empty page rather than an error.
118
+ """
101
119
  params: list[tuple[str, Any]] = [("page", page), ("size", size)]
102
120
  if sort:
103
121
  params.append(("sort", sort))
104
122
  if status:
105
123
  values = [status] if isinstance(status, str) else list(status)
106
124
  params.extend(("status", value) for value in values)
125
+ if tag_id:
126
+ tags = [tag_id] if isinstance(tag_id, str) else list(tag_id)
127
+ params.extend(("tagId", tag) for tag in tags)
107
128
 
108
129
  payload = self._request("GET", "/documents", params=params)
109
130
  return _to_page(payload, DocumentSummary.from_payload)
@@ -112,13 +133,15 @@ class AutosignlyClient:
112
133
  self,
113
134
  *,
114
135
  status: str | Sequence[str] | None = None,
136
+ tag_id: str | Sequence[str] | None = None,
115
137
  size: int = 50,
116
138
  sort: str | None = None,
117
139
  ) -> Iterator[DocumentSummary]:
118
140
  """Walk every document, fetching further pages as needed."""
119
141
  page_number = 0
120
142
  while True:
121
- page = self.list_documents(status=status, page=page_number, size=size, sort=sort)
143
+ page = self.list_documents(
144
+ status=status, tag_id=tag_id, page=page_number, size=size, sort=sort)
122
145
  yield from page.content
123
146
  if not page.has_next:
124
147
  return
@@ -143,14 +166,68 @@ class AutosignlyClient:
143
166
  status_code=404,
144
167
  )
145
168
 
146
- try:
147
- response = self._http.get(document.file_url)
148
- except httpx.TransportError as exc:
149
- raise errors.ConnectionError(f"Could not download {document_id}: {exc}") from exc
169
+ return self._download(document.file_url, document_id)
150
170
 
151
- if response.status_code >= 400:
152
- raise _to_error(response)
153
- return response.content
171
+ def upload_pdf(self, *, pdf: bytes, document_name: str, file_name: str = "document.pdf") -> str:
172
+ """Store a PDF as a document without sending it to anyone.
173
+
174
+ Returns the identifier of the created document. Use this when the
175
+ document needs attachments before it goes out: upload it, attach the
176
+ files with :meth:`add_attachment`, then call :meth:`send_for_signing`.
177
+ A document that has already been sent can no longer take attachments.
178
+ """
179
+ files = {
180
+ "file": (file_name, pdf, "application/pdf"),
181
+ "request": (None, json.dumps({"documentName": document_name}), "application/json"),
182
+ }
183
+ payload = self._request("POST", "/documents", files=files)
184
+ return payload.get("documentId", "")
185
+
186
+ # -- attachments ---------------------------------------------------------
187
+
188
+ def list_attachments(self, document_id: str) -> list[Attachment]:
189
+ """Return the attachments of a document, in the order they will merge."""
190
+ payload = self._request("GET", f"/documents/{document_id}/attachments")
191
+ return [Attachment.from_payload(item) for item in payload or []]
192
+
193
+ def add_attachment(
194
+ self,
195
+ document_id: str,
196
+ *,
197
+ content: bytes,
198
+ file_name: str,
199
+ ) -> Attachment:
200
+ """Attach a file to a document that has not been sent for signing yet.
201
+
202
+ The file is converted to PDF and merged into the document when it is
203
+ sent, behind an index page carrying its checksum, so one signature
204
+ covers the document and everything attached to it. PDF, JPEG and PNG
205
+ are accepted, recognised from the content rather than the file name.
206
+ Attachments merge in the order they were added.
207
+ """
208
+ files = {"file": (file_name, content, _content_type(file_name))}
209
+ payload = self._request("POST", f"/documents/{document_id}/attachments", files=files)
210
+ return Attachment.from_payload(payload)
211
+
212
+ def delete_attachment(self, document_id: str, attachment_id: str) -> None:
213
+ """Remove an attachment from a document not yet sent for signing."""
214
+ self._request("DELETE", f"/documents/{document_id}/attachments/{attachment_id}")
215
+
216
+ def download_attachment(self, document_id: str, attachment_id: str) -> bytes:
217
+ """Fetch one attachment converted to PDF — the rendition that gets merged."""
218
+ for attachment in self.list_attachments(document_id):
219
+ if attachment.id != attachment_id:
220
+ continue
221
+ if not attachment.file_url:
222
+ raise errors.NotFoundError(
223
+ f"Attachment {attachment_id} is not converted yet",
224
+ status_code=404,
225
+ )
226
+ return self._download(attachment.file_url, attachment_id)
227
+ raise errors.NotFoundError(
228
+ f"Document {document_id} has no attachment {attachment_id}",
229
+ status_code=404,
230
+ )
154
231
 
155
232
  def send_for_signing(
156
233
  self,
@@ -158,6 +235,7 @@ class AutosignlyClient:
158
235
  *,
159
236
  signers: Sequence[Signer] | None = None,
160
237
  signature_type: str | None = None,
238
+ signature_mode: str | None = None,
161
239
  verification_method: str | None = None,
162
240
  initiator_email: str | None = None,
163
241
  initiator_locale: str | None = None,
@@ -173,6 +251,8 @@ class AutosignlyClient:
173
251
  body["signers"] = [signer.to_payload() for signer in signers]
174
252
  if signature_type:
175
253
  body["signatureType"] = signature_type
254
+ if signature_mode:
255
+ body["signatureMode"] = signature_mode
176
256
  if verification_method:
177
257
  body["verificationMethod"] = verification_method
178
258
  if initiator_email:
@@ -181,7 +261,7 @@ class AutosignlyClient:
181
261
  "locale": initiator_locale,
182
262
  }
183
263
 
184
- payload = self._request("POST", f"/documents/{document_id}/send-for-signing", json_body=body)
264
+ payload = self._request("POST", f"/documents/{document_id}/signings", json_body=body)
185
265
  return SigningRequestResult.from_payload(payload)
186
266
 
187
267
  def upload_and_sign(
@@ -191,6 +271,7 @@ class AutosignlyClient:
191
271
  document_name: str,
192
272
  signers: Sequence[Signer],
193
273
  signature_type: str | None = None,
274
+ signature_mode: str | None = None,
194
275
  verification_method: str | None = None,
195
276
  initiator_email: str | None = None,
196
277
  initiator_locale: str | None = None,
@@ -207,6 +288,8 @@ class AutosignlyClient:
207
288
  }
208
289
  if signature_type:
209
290
  request["signatureType"] = signature_type
291
+ if signature_mode:
292
+ request["signatureMode"] = signature_mode
210
293
  if verification_method:
211
294
  request["verificationMethod"] = verification_method
212
295
  if initiator_email:
@@ -222,6 +305,58 @@ class AutosignlyClient:
222
305
  payload = self._request("POST", "/documents/signings", files=files)
223
306
  return payload.get("documentId", "")
224
307
 
308
+ # -- parties -------------------------------------------------------------
309
+
310
+ def list_parties(
311
+ self,
312
+ *,
313
+ name: str | None = None,
314
+ type: str | None = None,
315
+ page: int = 0,
316
+ size: int = 20,
317
+ ) -> Page[Party]:
318
+ """Return one page of the company parties for this environment.
319
+
320
+ ``name`` matches a fragment of the name, given name, tax id, e-mail or
321
+ phone. ``type`` narrows the page to ``COMPANY`` or ``PERSON``. There is no sort: the searchable fields are stored
322
+ encrypted, so the server cannot order by them.
323
+ """
324
+ params: list[tuple[str, Any]] = [("page", page), ("size", size)]
325
+ if name:
326
+ params.append(("name", name))
327
+ if type:
328
+ params.append(("type", type))
329
+ payload = self._request("GET", "/parties", params=params)
330
+ return _to_page(payload, Party.from_payload)
331
+
332
+ def get_party(self, party_id: str) -> Party:
333
+ """Return one party. Unknown in this environment raises ``NotFoundError``."""
334
+ payload = self._request("GET", f"/parties/{party_id}")
335
+ return Party.from_payload(payload)
336
+
337
+ def create_party(self, party: Party) -> Party:
338
+ """Add a party to the company in this environment.
339
+
340
+ A party with the same tax id (``COMPANY``) or e-mail (``PERSON``) is
341
+ rejected rather than duplicated, so this call is not safe to repeat
342
+ blindly — look the party up first when retrying.
343
+ """
344
+ payload = self._request("POST", "/parties", json_body=party.to_payload())
345
+ return Party.from_payload(payload)
346
+
347
+ def update_party(self, party_id: str, party: Party) -> Party:
348
+ """Replace the party data.
349
+
350
+ Every field is taken from ``party``, so send the whole party, not only
351
+ what changed.
352
+ """
353
+ payload = self._request("PUT", f"/parties/{party_id}", json_body=party.to_payload())
354
+ return Party.from_payload(payload)
355
+
356
+ def delete_party(self, party_id: str) -> None:
357
+ """Remove the party. Documents already signed keep their copy of the data."""
358
+ self._request("DELETE", f"/parties/{party_id}")
359
+
225
360
  # -- tags ----------------------------------------------------------------
226
361
 
227
362
  def list_tags(self, *, name: str | None = None, page: int = 0, size: int = 20) -> Page[Tag]:
@@ -266,6 +401,16 @@ class AutosignlyClient:
266
401
 
267
402
  # -- transport -----------------------------------------------------------
268
403
 
404
+ def _download(self, url: str, subject: str) -> bytes:
405
+ try:
406
+ response = self._http.get(url)
407
+ except httpx.TransportError as exc:
408
+ raise errors.ConnectionError(f"Could not download {subject}: {exc}") from exc
409
+
410
+ if response.status_code >= 400:
411
+ raise _to_error(response)
412
+ return response.content
413
+
269
414
  def _request(
270
415
  self,
271
416
  method: str,
@@ -312,6 +457,12 @@ class AutosignlyClient:
312
457
  raise errors.ConnectionError(f"Could not reach {url}: {last_error}")
313
458
 
314
459
 
460
+ def _content_type(file_name: str) -> str:
461
+ """The server detects the real format from the bytes; this is only a hint."""
462
+ guessed, _ = mimetypes.guess_type(file_name)
463
+ return guessed or "application/octet-stream"
464
+
465
+
315
466
  def _backoff(attempt: int) -> float:
316
467
  """Exponential backoff with jitter.
317
468