doconda 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,65 @@
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ .pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # env files (can opt-in for committing if needed)
16
+ .env*
17
+ !.env.example
18
+
19
+ # turbo
20
+ .turbo
21
+
22
+ # Editor directories and files
23
+ .vscode/*
24
+ !.vscode/extensions.json
25
+ .idea
26
+ .DS_Store
27
+ *.suo
28
+ *.ntvs*
29
+ *.njsproj
30
+ *.sln
31
+ *.sw?
32
+
33
+ # typescript
34
+ *.tsbuildinfo
35
+
36
+ # python
37
+ .venv
38
+ __pycache__
39
+ .pytest_cache
40
+ .ruff_cache
41
+
42
+ # next
43
+ .next
44
+ out
45
+ next-env.d.ts
46
+
47
+ # ===== Infra =====
48
+ infra/**/.terraform/
49
+ *.tfstate
50
+ *.tfstate.*
51
+ sensitive.auto.tfvars.json
52
+ ci.auto.tfvars.json
53
+ backend.hcl
54
+
55
+ # SlidesCarnival originals (large): see engine/themes/README.md
56
+ engine/themes/sources/
57
+
58
+ # Local template library (large, not in git)
59
+ /library/
60
+ infra/**/.build/
61
+ evals/.cache/
62
+ evals/runs/
63
+ evals/.venv/
64
+ evals/.env
65
+ evals/manual/
doconda-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Doconda
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
doconda-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,83 @@
1
+ Metadata-Version: 2.5
2
+ Name: doconda
3
+ Version: 0.1.0
4
+ Summary: Python SDK for Doconda: generate, review and edit Word, PowerPoint, Excel and PDF documents from AI agents (Amazon Bedrock, Vertex AI, OpenAI, Claude, LangChain).
5
+ Project-URL: Homepage, https://doconda.com
6
+ Project-URL: Documentation, https://docs.doconda.com/en/sdk/python
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: ai-agents,anthropic,bedrock,document-generation,docx,excel,langchain,llm,mcp,openai,pdf,powerpoint,pptx,tools,vertex-ai,word,xlsx
10
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Office/Business
13
+ Classifier: Typing :: Typed
14
+ Requires-Python: >=3.9
15
+ Requires-Dist: httpx>=0.27
16
+ Description-Content-Type: text/markdown
17
+
18
+ # doconda
19
+
20
+ Generate, review and edit **Word (DOCX), PowerPoint (PPTX), Excel (XLSX) and PDF** documents from your AI agent or chatbot, with one call. Editable files with real Word styles, reviewed before they are delivered.
21
+
22
+ Use it when your agent has to hand the user a document and you don't want to maintain python-docx, python-pptx, openpyxl or templates. Works with Amazon Bedrock, Vertex AI, OpenAI, Claude, Gemini, LangChain, CrewAI and any model with tool calling.
23
+
24
+ ```bash
25
+ pip install doconda
26
+ ```
27
+
28
+ ```python
29
+ from doconda import Doconda
30
+
31
+ doconda = Doconda() # reads DOCONDA_API_KEY
32
+ doc = doconda.documents.create(format="docx", prompt="Report on the housing law")
33
+ print(doc["outputs"]) # links to the Word file and the PDF
34
+
35
+ open("report.docx", "wb").write(doconda.documents.download(doc["id"], "docx"))
36
+ ```
37
+
38
+ ## As tools for your agent
39
+
40
+ `doconda.tools()` returns ready-made tools (name, description, JSON Schema, `run`) to create, review and edit documents. With Amazon Bedrock (Converse API):
41
+
42
+ ```python
43
+ import json
44
+ import boto3
45
+ from doconda import Doconda
46
+
47
+ bedrock = boto3.client("bedrock-runtime")
48
+ tools = Doconda().tools()
49
+ tool_config = {
50
+ "tools": [
51
+ {"toolSpec": {"name": t.name, "description": t.description, "inputSchema": {"json": t.input_schema}}}
52
+ for t in tools
53
+ ]
54
+ }
55
+ messages = [{"role": "user", "content": [{"text": "Make an Excel with the event budget"}]}]
56
+
57
+ while True:
58
+ res = bedrock.converse(modelId=MODEL_ID, messages=messages, toolConfig=tool_config)
59
+ message = res["output"]["message"]
60
+ messages.append(message)
61
+ if res["stopReason"] != "tool_use":
62
+ break
63
+ results = []
64
+ for block in message["content"]:
65
+ if "toolUse" in block:
66
+ use = block["toolUse"]
67
+ out = next(t for t in tools if t.name == use["name"]).run(use["input"])
68
+ results.append(
69
+ {"toolResult": {"toolUseId": use["toolUseId"], "content": [{"text": json.dumps(out)}]}}
70
+ )
71
+ messages.append({"role": "user", "content": results})
72
+ ```
73
+
74
+ The same tools plug into Vertex AI / Gemini, OpenAI, Claude, LangChain or CrewAI: see the guides.
75
+
76
+ ## What else
77
+
78
+ - `documents.create(operation="review", file=…)` and `operation="edit"`: review and fix, or change, an existing Word, PowerPoint, Excel or PDF.
79
+ - `documents.stream(…)`: the document's events as they happen.
80
+ - `extract(file)`: any file to Markdown for your model (free).
81
+ - `files.upload(path_or_bytes)`, `webhooks.create(url)` and `verify_webhook(body, headers, secret)`.
82
+
83
+ Docs: https://docs.doconda.com/en/sdk/python · Guides per stack: https://docs.doconda.com/en/agents/introduction · Get an API key: https://doconda.com
@@ -0,0 +1,66 @@
1
+ # doconda
2
+
3
+ Generate, review and edit **Word (DOCX), PowerPoint (PPTX), Excel (XLSX) and PDF** documents from your AI agent or chatbot, with one call. Editable files with real Word styles, reviewed before they are delivered.
4
+
5
+ Use it when your agent has to hand the user a document and you don't want to maintain python-docx, python-pptx, openpyxl or templates. Works with Amazon Bedrock, Vertex AI, OpenAI, Claude, Gemini, LangChain, CrewAI and any model with tool calling.
6
+
7
+ ```bash
8
+ pip install doconda
9
+ ```
10
+
11
+ ```python
12
+ from doconda import Doconda
13
+
14
+ doconda = Doconda() # reads DOCONDA_API_KEY
15
+ doc = doconda.documents.create(format="docx", prompt="Report on the housing law")
16
+ print(doc["outputs"]) # links to the Word file and the PDF
17
+
18
+ open("report.docx", "wb").write(doconda.documents.download(doc["id"], "docx"))
19
+ ```
20
+
21
+ ## As tools for your agent
22
+
23
+ `doconda.tools()` returns ready-made tools (name, description, JSON Schema, `run`) to create, review and edit documents. With Amazon Bedrock (Converse API):
24
+
25
+ ```python
26
+ import json
27
+ import boto3
28
+ from doconda import Doconda
29
+
30
+ bedrock = boto3.client("bedrock-runtime")
31
+ tools = Doconda().tools()
32
+ tool_config = {
33
+ "tools": [
34
+ {"toolSpec": {"name": t.name, "description": t.description, "inputSchema": {"json": t.input_schema}}}
35
+ for t in tools
36
+ ]
37
+ }
38
+ messages = [{"role": "user", "content": [{"text": "Make an Excel with the event budget"}]}]
39
+
40
+ while True:
41
+ res = bedrock.converse(modelId=MODEL_ID, messages=messages, toolConfig=tool_config)
42
+ message = res["output"]["message"]
43
+ messages.append(message)
44
+ if res["stopReason"] != "tool_use":
45
+ break
46
+ results = []
47
+ for block in message["content"]:
48
+ if "toolUse" in block:
49
+ use = block["toolUse"]
50
+ out = next(t for t in tools if t.name == use["name"]).run(use["input"])
51
+ results.append(
52
+ {"toolResult": {"toolUseId": use["toolUseId"], "content": [{"text": json.dumps(out)}]}}
53
+ )
54
+ messages.append({"role": "user", "content": results})
55
+ ```
56
+
57
+ The same tools plug into Vertex AI / Gemini, OpenAI, Claude, LangChain or CrewAI: see the guides.
58
+
59
+ ## What else
60
+
61
+ - `documents.create(operation="review", file=…)` and `operation="edit"`: review and fix, or change, an existing Word, PowerPoint, Excel or PDF.
62
+ - `documents.stream(…)`: the document's events as they happen.
63
+ - `extract(file)`: any file to Markdown for your model (free).
64
+ - `files.upload(path_or_bytes)`, `webhooks.create(url)` and `verify_webhook(body, headers, secret)`.
65
+
66
+ Docs: https://docs.doconda.com/en/sdk/python · Guides per stack: https://docs.doconda.com/en/agents/introduction · Get an API key: https://doconda.com
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "@doconda/sdk-python",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "description": "Thin wrapper so turbo runs the Python SDK's checks. The SDK itself is managed with uv.",
6
+ "scripts": {
7
+ "test": "uv run pytest -q",
8
+ "lint": "uv run ruff check . && uv run ruff format --check ."
9
+ }
10
+ }
@@ -0,0 +1,37 @@
1
+ [project]
2
+ name = "doconda"
3
+ version = "0.1.0"
4
+ description = "Python SDK for Doconda: generate, review and edit Word, PowerPoint, Excel and PDF documents from AI agents (Amazon Bedrock, Vertex AI, OpenAI, Claude, LangChain)."
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.9"
9
+ dependencies = ["httpx>=0.27"]
10
+ keywords = [
11
+ "docx", "pptx", "xlsx", "pdf", "word", "powerpoint", "excel", "document-generation", "ai-agents", "llm",
12
+ "tools", "bedrock", "vertex-ai", "openai", "anthropic", "langchain", "mcp",
13
+ ]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Operating System :: OS Independent",
17
+ "Topic :: Office/Business",
18
+ "Typing :: Typed",
19
+ ]
20
+
21
+ [project.urls]
22
+ Homepage = "https://doconda.com"
23
+ Documentation = "https://docs.doconda.com/en/sdk/python"
24
+
25
+ [dependency-groups]
26
+ dev = ["pytest>=8", "ruff>=0.12"]
27
+
28
+ [build-system]
29
+ requires = ["hatchling"]
30
+ build-backend = "hatchling.build"
31
+
32
+ [tool.ruff]
33
+ line-length = 110
34
+ target-version = "py39"
35
+
36
+ [tool.pytest.ini_options]
37
+ testpaths = ["tests"]
@@ -0,0 +1,6 @@
1
+ from ._client import Doconda, DocondaError, verify_webhook
2
+ from ._tools import Tool
3
+
4
+ # ===== Public API =====
5
+
6
+ __all__ = ["Doconda", "DocondaError", "Tool", "verify_webhook"]
@@ -0,0 +1,320 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import hashlib
5
+ import hmac
6
+ import json
7
+ import os
8
+ import re
9
+ import time
10
+ import uuid
11
+ from collections.abc import Iterator, Mapping
12
+ from pathlib import Path
13
+ from typing import IO, Any, Union
14
+ from urllib.parse import quote
15
+
16
+ import httpx
17
+
18
+ # ===== Types =====
19
+
20
+ FileLike = Union[str, bytes, os.PathLike, IO[bytes]]
21
+
22
+ # ===== Constants =====
23
+
24
+ TERMINAL = {"ready", "ready_with_warnings", "needs_review", "failed", "canceled"}
25
+ TERMINAL_EVENTS = {"document.ready", "document.failed", "document.canceled"}
26
+ TIMEOUT = httpx.Timeout(60.0, read=600.0)
27
+ STREAM_TIMEOUT = httpx.Timeout(60.0, read=None)
28
+
29
+ # ===== Helpers =====
30
+
31
+
32
+ def _sse(response: httpx.Response) -> Iterator[dict]:
33
+ data: list[str] = []
34
+ for line in response.iter_lines():
35
+ if not line:
36
+ if data:
37
+ yield json.loads("\n".join(data))
38
+ data = []
39
+ elif line.startswith("data:"):
40
+ data.append(line[5:].removeprefix(" "))
41
+ if data:
42
+ yield json.loads("\n".join(data))
43
+
44
+
45
+ def _read(file: FileLike, filename: str | None) -> tuple[bytes, str]:
46
+ if isinstance(file, bytes):
47
+ if not filename:
48
+ raise ValueError("Doconda: pass filename (with its extension) when uploading bytes.")
49
+ return file, filename
50
+ if isinstance(file, (str, os.PathLike)):
51
+ path = Path(file)
52
+ return path.read_bytes(), filename or path.name
53
+ name = filename or Path(getattr(file, "name", "") or "").name
54
+ if not name:
55
+ raise ValueError("Doconda: pass filename (with its extension) for this file object.")
56
+ return file.read(), name
57
+
58
+
59
+ def _file_input(file: FileLike | Mapping[str, str], filename: str | None) -> Any:
60
+ if isinstance(file, Mapping):
61
+ return dict(file)
62
+ if isinstance(file, str) and re.match(r"^(file|doc)_", file):
63
+ return file
64
+ if isinstance(file, str) and re.match(r"^https?://", file):
65
+ return {"url": file, **({"filename": filename} if filename else {})}
66
+ data, name = _read(file, filename)
67
+ return {"data": base64.b64encode(data).decode(), "filename": name}
68
+
69
+
70
+ def _query(params: Mapping[str, Any]) -> dict[str, Any]:
71
+ return {k: v for k, v in params.items() if v is not None}
72
+
73
+
74
+ # ===== Public API =====
75
+
76
+
77
+ class DocondaError(Exception):
78
+ def __init__(self, status: int, body: Any):
79
+ body = body if isinstance(body, dict) else {}
80
+ super().__init__(body.get("title") or f"Doconda API answered {status}")
81
+ self.status = status
82
+ self.code = body.get("code") or "http_error"
83
+ self.request_id = body.get("request_id")
84
+ self.errors = body.get("errors") or []
85
+
86
+
87
+ class Doconda:
88
+ def __init__(
89
+ self,
90
+ api_key: str | None = None,
91
+ *,
92
+ base_url: str | None = None,
93
+ max_retries: int = 2,
94
+ http_client: httpx.Client | None = None,
95
+ ):
96
+ key = api_key or os.environ.get("DOCONDA_API_KEY")
97
+ if not key:
98
+ raise ValueError("Doconda: pass api_key or set DOCONDA_API_KEY.")
99
+ self.base_url = (
100
+ base_url or os.environ.get("DOCONDA_BASE_URL") or "https://api.eu.doconda.com/v1"
101
+ ).rstrip("/")
102
+ self.max_retries = max_retries
103
+ self._key = key
104
+ self._http = http_client or httpx.Client(timeout=TIMEOUT)
105
+ self.documents = Documents(self)
106
+ self.files = Files(self)
107
+ self.webhooks = Webhooks(self)
108
+
109
+ def tools(self, *, local_files: bool = False) -> list:
110
+ from ._tools import doconda_tools
111
+
112
+ return doconda_tools(self, local_files=local_files)
113
+
114
+ def extract(self, file: FileLike | Mapping[str, str], *, filename: str | None = None) -> dict:
115
+ result = self.json("POST", "/extract", json={"file": _file_input(file, filename)})
116
+ while "markdown" not in result:
117
+ time.sleep(2)
118
+ result = self.json("GET", f"/extract/{result['id']}")
119
+ return result
120
+
121
+ def request(
122
+ self,
123
+ method: str,
124
+ path: str,
125
+ *,
126
+ json: Any = None,
127
+ files: Any = None,
128
+ params: Mapping[str, Any] | None = None,
129
+ headers: Mapping[str, str] | None = None,
130
+ stream: bool = False,
131
+ ) -> httpx.Response:
132
+ all_headers = {"authorization": f"Bearer {self._key}", **(headers or {})}
133
+ for attempt in range(self.max_retries + 1):
134
+ try:
135
+ request = self._http.build_request(
136
+ method,
137
+ f"{self.base_url}{path}",
138
+ json=json,
139
+ files=files,
140
+ params=params,
141
+ headers=all_headers,
142
+ timeout=STREAM_TIMEOUT if stream else self._http.timeout,
143
+ )
144
+ response = self._http.send(request, stream=stream)
145
+ except httpx.TransportError:
146
+ if attempt >= self.max_retries:
147
+ raise
148
+ time.sleep(0.5 * 2**attempt)
149
+ continue
150
+ if response.is_success:
151
+ return response
152
+ if stream:
153
+ response.read()
154
+ retryable = response.status_code == 429 or response.status_code >= 500
155
+ if not retryable or attempt >= self.max_retries:
156
+ try:
157
+ body = response.json()
158
+ except ValueError:
159
+ body = None
160
+ raise DocondaError(response.status_code, body)
161
+ wait = response.headers.get("retry-after")
162
+ time.sleep(float(wait) if wait and wait.isdigit() else 0.5 * 2**attempt)
163
+ raise AssertionError("unreachable")
164
+
165
+ def json(self, method: str, path: str, **kwargs: Any) -> Any:
166
+ response = self.request(method, path, **kwargs)
167
+ return None if response.status_code == 204 else response.json()
168
+
169
+ def close(self) -> None:
170
+ self._http.close()
171
+
172
+ def __enter__(self):
173
+ return self
174
+
175
+ def __exit__(self, *_: object) -> None:
176
+ self.close()
177
+
178
+
179
+ class Documents:
180
+ def __init__(self, client: Doconda):
181
+ self._c = client
182
+
183
+ def create(self, *, idempotency_key: str | None = None, **body: Any) -> dict:
184
+ doc = self._post(body, idempotency_key)
185
+ return doc if doc["status"] in TERMINAL else self.wait(doc["id"])
186
+
187
+ def create_in_background(self, *, idempotency_key: str | None = None, **body: Any) -> dict:
188
+ return self._post({**body, "background": True}, idempotency_key)
189
+
190
+ def stream(self, *, idempotency_key: str | None = None, **body: Any) -> Iterator[dict]:
191
+ response = self._c.request(
192
+ "POST",
193
+ "/documents",
194
+ json={**body, "stream": True},
195
+ headers={"idempotency-key": idempotency_key or str(uuid.uuid4()), "accept": "text/event-stream"},
196
+ stream=True,
197
+ )
198
+ try:
199
+ yield from _sse(response)
200
+ finally:
201
+ response.close()
202
+
203
+ def get(self, document_id: str) -> dict:
204
+ return self._c.json("GET", f"/documents/{document_id}")
205
+
206
+ def page(self, *, limit: int | None = None, cursor: str | None = None, status: str | None = None) -> dict:
207
+ return self._c.json(
208
+ "GET", "/documents", params=_query({"limit": limit, "cursor": cursor, "status": status})
209
+ )
210
+
211
+ def list(self, *, status: str | None = None) -> Iterator[dict]:
212
+ cursor = None
213
+ while True:
214
+ page = self.page(limit=100, cursor=cursor, status=status)
215
+ yield from page["data"]
216
+ cursor = page.get("next_cursor")
217
+ if not cursor:
218
+ return
219
+
220
+ def cancel(self, document_id: str) -> dict:
221
+ return self._c.json(
222
+ "POST", f"/documents/{document_id}/cancel", headers={"idempotency-key": str(uuid.uuid4())}
223
+ )
224
+
225
+ def delete(self, document_id: str) -> None:
226
+ self._c.json("DELETE", f"/documents/{document_id}")
227
+
228
+ def events(self, document_id: str, *, after: int = 0) -> Iterator[dict]:
229
+ response = self._c.request(
230
+ "GET", f"/documents/{document_id}/events", params=_query({"after": after or None}), stream=True
231
+ )
232
+ try:
233
+ yield from _sse(response)
234
+ finally:
235
+ response.close()
236
+
237
+ def wait(self, document_id: str) -> dict:
238
+ after = 0
239
+ while True:
240
+ for event in self.events(document_id, after=after):
241
+ after = event["sequence"]
242
+ if event["type"] in TERMINAL_EVENTS:
243
+ return self.get(document_id)
244
+ doc = self.get(document_id)
245
+ if doc["status"] in TERMINAL:
246
+ return doc
247
+
248
+ def outputs(self, document_id: str) -> list:
249
+ return self._c.json("GET", f"/documents/{document_id}/outputs")["data"]
250
+
251
+ def download(self, document_id: str, kind: str) -> bytes:
252
+ output = next((o for o in self.outputs(document_id) if o["kind"] == kind), None)
253
+ if output is None:
254
+ raise ValueError(f"Doconda: document {document_id} has no {kind}.")
255
+ response = httpx.get(output["url"], timeout=TIMEOUT, follow_redirects=True)
256
+ response.raise_for_status()
257
+ return response.content
258
+
259
+ def report(self, document_id: str) -> dict:
260
+ return self._c.json("GET", f"/documents/{document_id}/report")
261
+
262
+ def data(self, document_id: str, collection: str | None = None) -> list:
263
+ path = f"/documents/{document_id}/data" + (f"/{quote(collection, safe='')}" if collection else "")
264
+ return self._c.json("GET", path)["data"]
265
+
266
+ def _post(self, body: dict, idempotency_key: str | None) -> dict:
267
+ return self._c.json(
268
+ "POST", "/documents", json=body, headers={"idempotency-key": idempotency_key or str(uuid.uuid4())}
269
+ )
270
+
271
+
272
+ class Files:
273
+ def __init__(self, client: Doconda):
274
+ self._c = client
275
+
276
+ def upload(self, file: FileLike, filename: str | None = None) -> dict:
277
+ data, name = _read(file, filename)
278
+ return self._c.json("POST", "/files", files={"file": (name, data)})
279
+
280
+ def get(self, file_id: str) -> dict:
281
+ return self._c.json("GET", f"/files/{file_id}")
282
+
283
+ def delete(self, file_id: str) -> None:
284
+ self._c.json("DELETE", f"/files/{file_id}")
285
+
286
+
287
+ class Webhooks:
288
+ def __init__(self, client: Doconda):
289
+ self._c = client
290
+
291
+ def create(self, url: str, *, event_types: list[str] | None = None) -> dict:
292
+ return self._c.json(
293
+ "POST", "/webhook-endpoints", json=_query({"url": url, "event_types": event_types})
294
+ )
295
+
296
+ def list(self) -> list:
297
+ return self._c.json("GET", "/webhook-endpoints")["data"]
298
+
299
+ def delete(self, endpoint_id: str) -> None:
300
+ self._c.json("DELETE", f"/webhook-endpoints/{endpoint_id}")
301
+
302
+
303
+ def verify_webhook(
304
+ body: str | bytes, headers: Mapping[str, str], secret: str, tolerance_seconds: int = 300
305
+ ) -> dict:
306
+ lower = {k.lower(): v for k, v in headers.items()}
307
+ msg_id, timestamp, signatures = (
308
+ lower.get(h) for h in ("webhook-id", "webhook-timestamp", "webhook-signature")
309
+ )
310
+ if not msg_id or not timestamp or not signatures:
311
+ raise ValueError("Doconda webhook: missing webhook-id, -timestamp or -signature.")
312
+ if abs(time.time() - int(timestamp)) > tolerance_seconds:
313
+ raise ValueError("Doconda webhook: the timestamp is too old or in the future.")
314
+ text = body.decode() if isinstance(body, bytes) else body
315
+ key = base64.b64decode(secret.removeprefix("whsec_"))
316
+ mac = hmac.new(key, f"{msg_id}.{timestamp}.{text}".encode(), hashlib.sha256).digest()
317
+ expected = base64.b64encode(mac).decode()
318
+ if not any(s.startswith("v1,") and hmac.compare_digest(s[3:], expected) for s in signatures.split(" ")):
319
+ raise ValueError("Doconda webhook: the signature does not match.")
320
+ return json.loads(text)