formfeed 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,19 @@
1
+ node_modules
2
+ dist
3
+ tmp
4
+ out-tsc
5
+ coverage
6
+ .nx/cache
7
+ .nx/workspace-data
8
+
9
+ # Python
10
+ __pycache__/
11
+ .pytest_cache/
12
+ *.egg-info/
13
+ .venv/
14
+
15
+ # editors and systems
16
+ .idea
17
+ .vscode
18
+ .DS_Store
19
+ Thumbs.db
formfeed-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Philipp Staudt IT-Dienstleistungen
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.
@@ -0,0 +1,70 @@
1
+ Metadata-Version: 2.5
2
+ Name: formfeed
3
+ Version: 0.1.0
4
+ Summary: Formfeed API client: template-based PDF and image generation (sync and async, httpx).
5
+ Project-URL: Homepage, https://formfeed.dev
6
+ Project-URL: Documentation, https://docs.formfeed.dev/api/sdks/python
7
+ Project-URL: Source, https://github.com/formfeed-dev/formfeed/tree/main/packages/sdk-python
8
+ Project-URL: Issues, https://github.com/formfeed-dev/formfeed/issues
9
+ Project-URL: Changelog, https://docs.formfeed.dev/changelog
10
+ Author-email: Formfeed <support@formfeed.dev>
11
+ License-Expression: MIT
12
+ License-File: LICENSE
13
+ Keywords: api,formfeed,html-to-pdf,pdf,pdf-generation,sdk,templates
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Office/Business
24
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.10
27
+ Requires-Dist: httpx>=0.27
28
+ Requires-Dist: pydantic>=2.5
29
+ Description-Content-Type: text/markdown
30
+
31
+ # formfeed (Python)
32
+
33
+ Client for the Formfeed API: template-based PDF and image generation. Sync and async, built on
34
+ httpx, pydantic models, retries on 429 and 503 with the server's `Retry-After`, an
35
+ `Idempotency-Key` on every render, typed errors and webhook verification.
36
+
37
+ ```bash
38
+ pip install formfeed
39
+ ```
40
+
41
+ ```python
42
+ from formfeed import Formfeed
43
+
44
+ client = Formfeed("ff_test_…") # region="us" or base_url="…" for other hosts
45
+ render = client.renders.create(template="invoice-de", data={"invoice": {"number": "2026-001"}})
46
+ open("invoice.pdf", "wb").write(client.renders.download(render))
47
+
48
+ job = client.renders.batch([{"data": d} for d in rows], template="invoice-de", zip=True)
49
+ job = client.jobs.wait_for(job.id)
50
+ ```
51
+
52
+ ```python
53
+ from formfeed import AsyncFormfeed
54
+
55
+ async with AsyncFormfeed("ff_test_…") as client:
56
+ render = await client.renders.create(html="<h1>Hello</h1>", output="png")
57
+ ```
58
+
59
+ Webhooks:
60
+
61
+ ```python
62
+ from formfeed import parse_webhook_event
63
+
64
+ event = parse_webhook_event(secret, request.headers["Webhook-Signature"], request.body)
65
+ ```
66
+
67
+ Errors raise `FormfeedError` with `code`, `status`, `problem` and `request_id`. Templates:
68
+ `client.templates.all()`, `.version("invoice-de", "latest")`, `.create_version(...)`, `.publish(...)`.
69
+
70
+ Documentation: <https://docs.formfeed.dev/api/sdks/python>
@@ -0,0 +1,40 @@
1
+ # formfeed (Python)
2
+
3
+ Client for the Formfeed API: template-based PDF and image generation. Sync and async, built on
4
+ httpx, pydantic models, retries on 429 and 503 with the server's `Retry-After`, an
5
+ `Idempotency-Key` on every render, typed errors and webhook verification.
6
+
7
+ ```bash
8
+ pip install formfeed
9
+ ```
10
+
11
+ ```python
12
+ from formfeed import Formfeed
13
+
14
+ client = Formfeed("ff_test_…") # region="us" or base_url="…" for other hosts
15
+ render = client.renders.create(template="invoice-de", data={"invoice": {"number": "2026-001"}})
16
+ open("invoice.pdf", "wb").write(client.renders.download(render))
17
+
18
+ job = client.renders.batch([{"data": d} for d in rows], template="invoice-de", zip=True)
19
+ job = client.jobs.wait_for(job.id)
20
+ ```
21
+
22
+ ```python
23
+ from formfeed import AsyncFormfeed
24
+
25
+ async with AsyncFormfeed("ff_test_…") as client:
26
+ render = await client.renders.create(html="<h1>Hello</h1>", output="png")
27
+ ```
28
+
29
+ Webhooks:
30
+
31
+ ```python
32
+ from formfeed import parse_webhook_event
33
+
34
+ event = parse_webhook_event(secret, request.headers["Webhook-Signature"], request.body)
35
+ ```
36
+
37
+ Errors raise `FormfeedError` with `code`, `status`, `problem` and `request_id`. Templates:
38
+ `client.templates.all()`, `.version("invoice-de", "latest")`, `.create_version(...)`, `.publish(...)`.
39
+
40
+ Documentation: <https://docs.formfeed.dev/api/sdks/python>
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.25"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "formfeed"
7
+ version = "0.1.0"
8
+ description = "Formfeed API client: template-based PDF and image generation (sync and async, httpx)."
9
+ readme = "README.md"
10
+ # PEP 639: the SPDX expression replaces the licence classifier, the two must not be combined
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ requires-python = ">=3.10"
14
+ authors = [{ name = "Formfeed", email = "support@formfeed.dev" }]
15
+ keywords = ["formfeed", "pdf", "pdf-generation", "html-to-pdf", "templates", "api", "sdk"]
16
+ classifiers = [
17
+ "Development Status :: 4 - Beta",
18
+ "Intended Audience :: Developers",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3 :: Only",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: Office/Business",
27
+ "Topic :: Software Development :: Libraries :: Python Modules",
28
+ "Typing :: Typed",
29
+ ]
30
+ dependencies = ["httpx>=0.27", "pydantic>=2.5"]
31
+
32
+ [project.urls]
33
+ Homepage = "https://formfeed.dev"
34
+ Documentation = "https://docs.formfeed.dev/api/sdks/python"
35
+ Source = "https://github.com/formfeed-dev/formfeed/tree/main/packages/sdk-python"
36
+ Issues = "https://github.com/formfeed-dev/formfeed/issues"
37
+ Changelog = "https://docs.formfeed.dev/changelog"
38
+
39
+ [tool.hatch.build.targets.wheel]
40
+ packages = ["src/formfeed"]
41
+
42
+ # the source archive carries what a rebuild and the tests need, not the workspace's project.json
43
+ [tool.hatch.build.targets.sdist]
44
+ include = ["src/formfeed", "tests", "README.md", "LICENSE", "pyproject.toml"]
45
+
46
+ [tool.pytest.ini_options]
47
+ testpaths = ["tests"]
48
+ pythonpath = ["src"]
@@ -0,0 +1,33 @@
1
+ """Formfeed API client (spec 10 §1): sync and async, retries on 429/503, idempotency keys, typed errors."""
2
+
3
+ from .client import AsyncFormfeed, Formfeed
4
+ from .errors import FormfeedError
5
+ from .models import (
6
+ Job,
7
+ Render,
8
+ RenderPage,
9
+ Template,
10
+ Usage,
11
+ TemplateVersion,
12
+ WebhookEndpoint,
13
+ WebhookEvent,
14
+ )
15
+ from .webhooks import parse_webhook_event, verify_webhook_signature
16
+
17
+ __all__ = [
18
+ "AsyncFormfeed",
19
+ "Formfeed",
20
+ "FormfeedError",
21
+ "Job",
22
+ "Render",
23
+ "RenderPage",
24
+ "Template",
25
+ "Usage",
26
+ "TemplateVersion",
27
+ "WebhookEndpoint",
28
+ "WebhookEvent",
29
+ "parse_webhook_event",
30
+ "verify_webhook_signature",
31
+ ]
32
+
33
+ __version__ = "0.1.0"
@@ -0,0 +1,552 @@
1
+ """Sync and async clients (httpx). Every behaviour mirrors ``@formfeed/sdk`` so the two SDKs stay in step."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import random
7
+ import time
8
+ import uuid
9
+ from typing import Any, Generic, TypeVar
10
+
11
+ import httpx
12
+
13
+ from .errors import FormfeedError
14
+ from .models import Job, Region, Render, RenderPage, Template, TemplatePage, TemplateVersion, Usage, WebhookEndpoint
15
+
16
+ HOSTS: dict[str, str] = {"eu": "https://api-eu.formfeed.dev/v1", "us": "https://api-us.formfeed.dev/v1"}
17
+ USER_AGENT = "formfeed-sdk-python/0.1"
18
+ RETRY_STATUSES = (429, 503)
19
+
20
+ T = TypeVar("T")
21
+
22
+
23
+ def _backoff(attempt: int, retry_after: str | None) -> float:
24
+ """Seconds to wait: the server's Retry-After when present, else 0.5 s doubling, plus jitter, capped at 30 s."""
25
+ base = 0.5 * 2 ** (attempt - 1)
26
+ if retry_after:
27
+ try:
28
+ value = float(retry_after)
29
+ if value > 0:
30
+ base = value
31
+ except ValueError:
32
+ pass
33
+ return min(30.0, base + random.random() * 0.25)
34
+
35
+
36
+ def _problem_error(res: httpx.Response) -> FormfeedError:
37
+ try:
38
+ problem = res.json() if res.content else {}
39
+ except ValueError:
40
+ problem = {}
41
+ if not isinstance(problem, dict):
42
+ problem = {}
43
+ return FormfeedError(
44
+ str(problem.get("code") or f"http_{res.status_code}"),
45
+ str(problem.get("detail") or problem.get("title") or f"HTTP {res.status_code}"),
46
+ res.status_code,
47
+ problem,
48
+ res.headers.get("x-request-id"),
49
+ )
50
+
51
+
52
+ def _query(**params: Any) -> dict[str, Any]:
53
+ return {k: v for k, v in params.items() if v is not None and v != ""}
54
+
55
+
56
+ class _Base(Generic[T]):
57
+ def __init__(
58
+ self,
59
+ api_key: str,
60
+ *,
61
+ region: Region = "eu",
62
+ base_url: str | None = None,
63
+ max_retries: int = 3,
64
+ timeout: float = 120.0,
65
+ transport: httpx.BaseTransport | httpx.AsyncBaseTransport | None = None,
66
+ ) -> None:
67
+ if not api_key:
68
+ raise FormfeedError("invalid_request", "api_key is required")
69
+ self.api_key = api_key
70
+ self.base_url = (base_url or HOSTS[region]).rstrip("/")
71
+ self.max_retries = max_retries
72
+ self.timeout = timeout
73
+ self._transport = transport
74
+
75
+ def _headers(self, body: Any, idempotency_key: str | None) -> dict[str, str]:
76
+ headers = {"authorization": f"Bearer {self.api_key}", "accept": "application/json", "user-agent": USER_AGENT}
77
+ if body is not None:
78
+ headers["content-type"] = "application/json"
79
+ if idempotency_key:
80
+ headers["idempotency-key"] = idempotency_key
81
+ return headers
82
+
83
+ @staticmethod
84
+ def _decode(res: httpx.Response) -> Any:
85
+ if res.status_code == 204 or not res.content:
86
+ return None
87
+ return res.json()
88
+
89
+
90
+ class Formfeed(_Base[Any]):
91
+ """Synchronous client.
92
+
93
+ >>> client = Formfeed("ff_test_…")
94
+ >>> render = client.renders.create(template="invoice-de", data={"invoice": {...}})
95
+ >>> pdf = client.renders.download(render)
96
+ """
97
+
98
+ def __init__(self, api_key: str, **options: Any) -> None:
99
+ super().__init__(api_key, **options)
100
+ self._http = httpx.Client(timeout=self.timeout, transport=self._transport) # type: ignore[arg-type]
101
+ self.renders = _Renders(self)
102
+ self.jobs = _Jobs(self)
103
+ self.webhooks = _Webhooks(self)
104
+ self.templates = _Templates(self)
105
+ self.account = _Account(self)
106
+
107
+ def close(self) -> None:
108
+ self._http.close()
109
+
110
+ def __enter__(self) -> "Formfeed":
111
+ return self
112
+
113
+ def __exit__(self, *exc: object) -> None:
114
+ self.close()
115
+
116
+ def request(self, method: str, path: str, body: Any = None, *, idempotency_key: str | None = None) -> Any:
117
+ """Raw request with auth, retries and problem mapping; for endpoints without a helper."""
118
+ url = f"{self.base_url}{path}"
119
+ headers = self._headers(body, idempotency_key)
120
+ attempt = 0
121
+ while True:
122
+ try:
123
+ res = self._http.request(method, url, json=body, headers=headers)
124
+ except httpx.HTTPError as e:
125
+ if attempt < self.max_retries:
126
+ attempt += 1
127
+ time.sleep(_backoff(attempt, None))
128
+ continue
129
+ raise FormfeedError("network_error", str(e)) from e
130
+ if res.status_code in RETRY_STATUSES and attempt < self.max_retries:
131
+ attempt += 1
132
+ time.sleep(_backoff(attempt, res.headers.get("retry-after")))
133
+ continue
134
+ if res.is_error:
135
+ raise _problem_error(res)
136
+ return self._decode(res)
137
+
138
+ def download_url(self, url: str) -> bytes:
139
+ res = self._http.get(url)
140
+ if res.is_error:
141
+ raise FormfeedError("download_failed", f"download answered HTTP {res.status_code}", res.status_code)
142
+ return res.content
143
+
144
+
145
+ class AsyncFormfeed(_Base[Any]):
146
+ """Asynchronous client with the same surface as :class:`Formfeed`; every method is awaitable."""
147
+
148
+ def __init__(self, api_key: str, **options: Any) -> None:
149
+ super().__init__(api_key, **options)
150
+ self._http = httpx.AsyncClient(timeout=self.timeout, transport=self._transport) # type: ignore[arg-type]
151
+ self.renders = _AsyncRenders(self)
152
+ self.jobs = _AsyncJobs(self)
153
+ self.webhooks = _AsyncWebhooks(self)
154
+ self.templates = _AsyncTemplates(self)
155
+ self.account = _AsyncAccount(self)
156
+
157
+ async def aclose(self) -> None:
158
+ await self._http.aclose()
159
+
160
+ async def __aenter__(self) -> "AsyncFormfeed":
161
+ return self
162
+
163
+ async def __aexit__(self, *exc: object) -> None:
164
+ await self.aclose()
165
+
166
+ async def request(self, method: str, path: str, body: Any = None, *, idempotency_key: str | None = None) -> Any:
167
+ url = f"{self.base_url}{path}"
168
+ headers = self._headers(body, idempotency_key)
169
+ attempt = 0
170
+ while True:
171
+ try:
172
+ res = await self._http.request(method, url, json=body, headers=headers)
173
+ except httpx.HTTPError as e:
174
+ if attempt < self.max_retries:
175
+ attempt += 1
176
+ await asyncio.sleep(_backoff(attempt, None))
177
+ continue
178
+ raise FormfeedError("network_error", str(e)) from e
179
+ if res.status_code in RETRY_STATUSES and attempt < self.max_retries:
180
+ attempt += 1
181
+ await asyncio.sleep(_backoff(attempt, res.headers.get("retry-after")))
182
+ continue
183
+ if res.is_error:
184
+ raise _problem_error(res)
185
+ return self._decode(res)
186
+
187
+ async def download_url(self, url: str) -> bytes:
188
+ res = await self._http.get(url)
189
+ if res.is_error:
190
+ raise FormfeedError("download_failed", f"download answered HTTP {res.status_code}", res.status_code)
191
+ return res.content
192
+
193
+
194
+ # --- sync namespaces --------------------------------------------------------------------------
195
+
196
+
197
+ def _render_body(template: str | None, html: str | None, url: str | None, data: dict[str, Any] | None, options: dict[str, Any]) -> dict[str, Any]:
198
+ body: dict[str, Any] = {k: v for k, v in options.items() if v is not None}
199
+ if template is not None:
200
+ body["template"] = template
201
+ if html is not None:
202
+ body["html"] = html
203
+ if url is not None:
204
+ body["url"] = url
205
+ if data is not None:
206
+ body["data"] = data
207
+ return body
208
+
209
+
210
+ class _Renders:
211
+ def __init__(self, client: Formfeed) -> None:
212
+ self._c = client
213
+
214
+ def create(
215
+ self,
216
+ *,
217
+ template: str | None = None,
218
+ html: str | None = None,
219
+ url: str | None = None,
220
+ data: dict[str, Any] | None = None,
221
+ idempotency_key: str | None = None,
222
+ **options: Any,
223
+ ) -> Render:
224
+ """Renders a template, HTML or URL. Sync by default; ``mode="async"`` returns a queued render."""
225
+ body = _render_body(template, html, url, data, options)
226
+ return Render.model_validate(self._c.request("POST", "/renders", body, idempotency_key=idempotency_key or str(uuid.uuid4())))
227
+
228
+ def get(self, render_id: str) -> Render:
229
+ return Render.model_validate(self._c.request("GET", f"/renders/{render_id}"))
230
+
231
+ def wait_for(self, render_id: str, *, timeout: float = 120.0, interval: float = 1.0) -> Render:
232
+ """Polls until the render succeeded or failed."""
233
+ deadline = time.monotonic() + timeout
234
+ while True:
235
+ render = self.get(render_id)
236
+ if render.finished:
237
+ return render
238
+ if time.monotonic() + interval > deadline:
239
+ raise FormfeedError("timeout", f"render {render_id} did not finish within the wait time")
240
+ time.sleep(interval)
241
+
242
+ def download(self, render: Render | str) -> bytes:
243
+ target = self.get(render) if isinstance(render, str) else render
244
+ if not target.download_url:
245
+ raise FormfeedError("not_ready", f"render {target.id} has no output ({target.status})")
246
+ return self._c.download_url(target.download_url)
247
+
248
+ def list(
249
+ self,
250
+ *,
251
+ template: str | None = None,
252
+ status: str | None = None,
253
+ environment: str | None = None,
254
+ since: str | None = None,
255
+ until: str | None = None,
256
+ limit: int | None = None,
257
+ cursor: str | None = None,
258
+ ) -> RenderPage:
259
+ """Page of renders, newest first."""
260
+ params = httpx.QueryParams(_query(template=template, status=status, environment=environment, since=since, until=until, limit=limit, cursor=cursor))
261
+ return RenderPage.model_validate(self._c.request("GET", f"/renders{'?' + str(params) if params else ''}"))
262
+
263
+ def all(self, **filters: Any) -> list[Render]:
264
+ """Every render matching the filters, following the cursor."""
265
+ out: list[Render] = []
266
+ cursor: str | None = None
267
+ while True:
268
+ page = self.list(cursor=cursor, **filters)
269
+ out.extend(page.data)
270
+ cursor = page.next_cursor
271
+ if not cursor:
272
+ return out
273
+
274
+ def delete_outputs(self, render_id: str) -> None:
275
+ """Removes the stored files of a render before they expire (needs the file:delete scope)."""
276
+ self._c.request("DELETE", f"/renders/{render_id}/outputs")
277
+
278
+ def batch(self, items: list[dict[str, Any]], *, template: str | None = None, idempotency_key: str | None = None, **options: Any) -> Job:
279
+ body: dict[str, Any] = {"items": items, **{k: v for k, v in options.items() if v is not None}}
280
+ if template is not None:
281
+ body["template"] = template
282
+ return Job.model_validate(self._c.request("POST", "/renders/batch", body, idempotency_key=idempotency_key or str(uuid.uuid4())))
283
+
284
+
285
+ class _Jobs:
286
+ def __init__(self, client: Formfeed) -> None:
287
+ self._c = client
288
+
289
+ def get(self, job_id: str) -> Job:
290
+ return Job.model_validate(self._c.request("GET", f"/jobs/{job_id}"))
291
+
292
+ def wait_for(self, job_id: str, *, timeout: float = 600.0, interval: float = 2.0) -> Job:
293
+ deadline = time.monotonic() + timeout
294
+ while True:
295
+ job = self.get(job_id)
296
+ if job.finished:
297
+ return job
298
+ if time.monotonic() + interval > deadline:
299
+ raise FormfeedError("timeout", f"job {job_id} did not finish within the wait time")
300
+ time.sleep(interval)
301
+
302
+
303
+ class _Webhooks:
304
+ def __init__(self, client: Formfeed) -> None:
305
+ self._c = client
306
+
307
+ def list(self) -> list[WebhookEndpoint]:
308
+ return [WebhookEndpoint.model_validate(e) for e in self._c.request("GET", "/webhooks")["data"]]
309
+
310
+ def create(self, url: str, *, events: list[str] | None = None, description: str | None = None) -> WebhookEndpoint:
311
+ return WebhookEndpoint.model_validate(self._c.request("POST", "/webhooks", _query(url=url, events=events, description=description)))
312
+
313
+ def update(self, endpoint_id: str, **patch: Any) -> WebhookEndpoint:
314
+ return WebhookEndpoint.model_validate(self._c.request("PUT", f"/webhooks/{endpoint_id}", patch))
315
+
316
+ def delete(self, endpoint_id: str) -> None:
317
+ self._c.request("DELETE", f"/webhooks/{endpoint_id}")
318
+
319
+ def test(self, endpoint_id: str) -> dict[str, Any]:
320
+ return self._c.request("POST", f"/webhooks/{endpoint_id}/test")
321
+
322
+
323
+ class _Templates:
324
+ def __init__(self, client: Formfeed) -> None:
325
+ self._c = client
326
+
327
+ def list(self, *, kind: str | None = None, engine: str | None = None, tag: str | None = None, q: str | None = None, limit: int | None = None, cursor: str | None = None) -> TemplatePage:
328
+ params = httpx.QueryParams(_query(kind=kind, engine=engine, tag=tag, q=q, limit=limit, cursor=cursor))
329
+ return TemplatePage.model_validate(self._c.request("GET", f"/templates{'?' + str(params) if params else ''}"))
330
+
331
+ def all(self, **filters: Any) -> list[Template]:
332
+ """Every template of the workspace, following the cursor."""
333
+ out: list[Template] = []
334
+ cursor: str | None = None
335
+ while True:
336
+ page = self.list(cursor=cursor, **filters)
337
+ out.extend(page.data)
338
+ cursor = page.next_cursor
339
+ if not cursor:
340
+ return out
341
+
342
+ def get(self, id_or_slug: str) -> Template:
343
+ return Template.model_validate(self._c.request("GET", f"/templates/{id_or_slug}"))
344
+
345
+ def create(self, **template: Any) -> Template:
346
+ return Template.model_validate(self._c.request("POST", "/templates", template))
347
+
348
+ def update(self, id_or_slug: str, **patch: Any) -> Template:
349
+ return Template.model_validate(self._c.request("PUT", f"/templates/{id_or_slug}", patch))
350
+
351
+ def archive(self, id_or_slug: str) -> None:
352
+ self._c.request("DELETE", f"/templates/{id_or_slug}")
353
+
354
+ def version(self, id_or_slug: str, which: str | int = "published") -> TemplateVersion:
355
+ """``which``: ``published``, ``latest`` or a version number; carries the files."""
356
+ return TemplateVersion.model_validate(self._c.request("GET", f"/templates/{id_or_slug}/versions/{which}"))
357
+
358
+ def versions(self, id_or_slug: str) -> list[TemplateVersion]:
359
+ return [TemplateVersion.model_validate(v) for v in self._c.request("GET", f"/templates/{id_or_slug}/versions")["data"]]
360
+
361
+ def create_version(self, id_or_slug: str, **version: Any) -> TemplateVersion:
362
+ return TemplateVersion.model_validate(self._c.request("POST", f"/templates/{id_or_slug}/versions", version))
363
+
364
+ def publish(self, id_or_slug: str, number: int) -> TemplateVersion:
365
+ return TemplateVersion.model_validate(self._c.request("POST", f"/templates/{id_or_slug}/versions/{number}/publish"))
366
+
367
+ def schema(self, id_or_slug: str) -> dict[str, Any]:
368
+ return self._c.request("GET", f"/templates/{id_or_slug}/schema")
369
+
370
+
371
+ class _Account:
372
+ def __init__(self, client: Formfeed) -> None:
373
+ self._c = client
374
+
375
+ def get(self) -> dict[str, Any]:
376
+ return self._c.request("GET", "/account")
377
+
378
+ def usage(self, period: str = "current") -> Usage:
379
+ """Units of the current period, or of ``YYYY-MM``, with a daily series and per template."""
380
+ return Usage.model_validate(self._c.request("GET", f"/usage?period={period}"))
381
+
382
+
383
+ # --- async namespaces -------------------------------------------------------------------------
384
+
385
+
386
+ class _AsyncRenders:
387
+ def __init__(self, client: AsyncFormfeed) -> None:
388
+ self._c = client
389
+
390
+ async def create(
391
+ self,
392
+ *,
393
+ template: str | None = None,
394
+ html: str | None = None,
395
+ url: str | None = None,
396
+ data: dict[str, Any] | None = None,
397
+ idempotency_key: str | None = None,
398
+ **options: Any,
399
+ ) -> Render:
400
+ body = _render_body(template, html, url, data, options)
401
+ return Render.model_validate(await self._c.request("POST", "/renders", body, idempotency_key=idempotency_key or str(uuid.uuid4())))
402
+
403
+ async def get(self, render_id: str) -> Render:
404
+ return Render.model_validate(await self._c.request("GET", f"/renders/{render_id}"))
405
+
406
+ async def wait_for(self, render_id: str, *, timeout: float = 120.0, interval: float = 1.0) -> Render:
407
+ deadline = time.monotonic() + timeout
408
+ while True:
409
+ render = await self.get(render_id)
410
+ if render.finished:
411
+ return render
412
+ if time.monotonic() + interval > deadline:
413
+ raise FormfeedError("timeout", f"render {render_id} did not finish within the wait time")
414
+ await asyncio.sleep(interval)
415
+
416
+ async def download(self, render: Render | str) -> bytes:
417
+ target = await self.get(render) if isinstance(render, str) else render
418
+ if not target.download_url:
419
+ raise FormfeedError("not_ready", f"render {target.id} has no output ({target.status})")
420
+ return await self._c.download_url(target.download_url)
421
+
422
+ async def list(
423
+ self,
424
+ *,
425
+ template: str | None = None,
426
+ status: str | None = None,
427
+ environment: str | None = None,
428
+ since: str | None = None,
429
+ until: str | None = None,
430
+ limit: int | None = None,
431
+ cursor: str | None = None,
432
+ ) -> RenderPage:
433
+ """Page of renders, newest first."""
434
+ params = httpx.QueryParams(_query(template=template, status=status, environment=environment, since=since, until=until, limit=limit, cursor=cursor))
435
+ return RenderPage.model_validate(await self._c.request("GET", f"/renders{'?' + str(params) if params else ''}"))
436
+
437
+ async def all(self, **filters: Any) -> list[Render]:
438
+ """Every render matching the filters, following the cursor."""
439
+ out: list[Render] = []
440
+ cursor: str | None = None
441
+ while True:
442
+ page = await self.list(cursor=cursor, **filters)
443
+ out.extend(page.data)
444
+ cursor = page.next_cursor
445
+ if not cursor:
446
+ return out
447
+
448
+ async def delete_outputs(self, render_id: str) -> None:
449
+ """Removes the stored files of a render before they expire (needs the file:delete scope)."""
450
+ await self._c.request("DELETE", f"/renders/{render_id}/outputs")
451
+
452
+ async def batch(self, items: list[dict[str, Any]], *, template: str | None = None, idempotency_key: str | None = None, **options: Any) -> Job:
453
+ body: dict[str, Any] = {"items": items, **{k: v for k, v in options.items() if v is not None}}
454
+ if template is not None:
455
+ body["template"] = template
456
+ return Job.model_validate(await self._c.request("POST", "/renders/batch", body, idempotency_key=idempotency_key or str(uuid.uuid4())))
457
+
458
+
459
+ class _AsyncJobs:
460
+ def __init__(self, client: AsyncFormfeed) -> None:
461
+ self._c = client
462
+
463
+ async def get(self, job_id: str) -> Job:
464
+ return Job.model_validate(await self._c.request("GET", f"/jobs/{job_id}"))
465
+
466
+ async def wait_for(self, job_id: str, *, timeout: float = 600.0, interval: float = 2.0) -> Job:
467
+ deadline = time.monotonic() + timeout
468
+ while True:
469
+ job = await self.get(job_id)
470
+ if job.finished:
471
+ return job
472
+ if time.monotonic() + interval > deadline:
473
+ raise FormfeedError("timeout", f"job {job_id} did not finish within the wait time")
474
+ await asyncio.sleep(interval)
475
+
476
+
477
+ class _AsyncWebhooks:
478
+ def __init__(self, client: AsyncFormfeed) -> None:
479
+ self._c = client
480
+
481
+ async def list(self) -> list[WebhookEndpoint]:
482
+ return [WebhookEndpoint.model_validate(e) for e in (await self._c.request("GET", "/webhooks"))["data"]]
483
+
484
+ async def create(self, url: str, *, events: list[str] | None = None, description: str | None = None) -> WebhookEndpoint:
485
+ return WebhookEndpoint.model_validate(await self._c.request("POST", "/webhooks", _query(url=url, events=events, description=description)))
486
+
487
+ async def update(self, endpoint_id: str, **patch: Any) -> WebhookEndpoint:
488
+ return WebhookEndpoint.model_validate(await self._c.request("PUT", f"/webhooks/{endpoint_id}", patch))
489
+
490
+ async def delete(self, endpoint_id: str) -> None:
491
+ await self._c.request("DELETE", f"/webhooks/{endpoint_id}")
492
+
493
+ async def test(self, endpoint_id: str) -> dict[str, Any]:
494
+ return await self._c.request("POST", f"/webhooks/{endpoint_id}/test")
495
+
496
+
497
+ class _AsyncTemplates:
498
+ def __init__(self, client: AsyncFormfeed) -> None:
499
+ self._c = client
500
+
501
+ async def list(self, *, kind: str | None = None, engine: str | None = None, tag: str | None = None, q: str | None = None, limit: int | None = None, cursor: str | None = None) -> TemplatePage:
502
+ params = httpx.QueryParams(_query(kind=kind, engine=engine, tag=tag, q=q, limit=limit, cursor=cursor))
503
+ return TemplatePage.model_validate(await self._c.request("GET", f"/templates{'?' + str(params) if params else ''}"))
504
+
505
+ async def all(self, **filters: Any) -> list[Template]:
506
+ out: list[Template] = []
507
+ cursor: str | None = None
508
+ while True:
509
+ page = await self.list(cursor=cursor, **filters)
510
+ out.extend(page.data)
511
+ cursor = page.next_cursor
512
+ if not cursor:
513
+ return out
514
+
515
+ async def get(self, id_or_slug: str) -> Template:
516
+ return Template.model_validate(await self._c.request("GET", f"/templates/{id_or_slug}"))
517
+
518
+ async def create(self, **template: Any) -> Template:
519
+ return Template.model_validate(await self._c.request("POST", "/templates", template))
520
+
521
+ async def update(self, id_or_slug: str, **patch: Any) -> Template:
522
+ return Template.model_validate(await self._c.request("PUT", f"/templates/{id_or_slug}", patch))
523
+
524
+ async def archive(self, id_or_slug: str) -> None:
525
+ await self._c.request("DELETE", f"/templates/{id_or_slug}")
526
+
527
+ async def version(self, id_or_slug: str, which: str | int = "published") -> TemplateVersion:
528
+ return TemplateVersion.model_validate(await self._c.request("GET", f"/templates/{id_or_slug}/versions/{which}"))
529
+
530
+ async def versions(self, id_or_slug: str) -> list[TemplateVersion]:
531
+ return [TemplateVersion.model_validate(v) for v in (await self._c.request("GET", f"/templates/{id_or_slug}/versions"))["data"]]
532
+
533
+ async def create_version(self, id_or_slug: str, **version: Any) -> TemplateVersion:
534
+ return TemplateVersion.model_validate(await self._c.request("POST", f"/templates/{id_or_slug}/versions", version))
535
+
536
+ async def publish(self, id_or_slug: str, number: int) -> TemplateVersion:
537
+ return TemplateVersion.model_validate(await self._c.request("POST", f"/templates/{id_or_slug}/versions/{number}/publish"))
538
+
539
+ async def schema(self, id_or_slug: str) -> dict[str, Any]:
540
+ return await self._c.request("GET", f"/templates/{id_or_slug}/schema")
541
+
542
+
543
+ class _AsyncAccount:
544
+ def __init__(self, client: AsyncFormfeed) -> None:
545
+ self._c = client
546
+
547
+ async def get(self) -> dict[str, Any]:
548
+ return await self._c.request("GET", "/account")
549
+
550
+ async def usage(self, period: str = "current") -> Usage:
551
+ """Units of the current period, or of ``YYYY-MM``, with a daily series and per template."""
552
+ return Usage.model_validate(await self._c.request("GET", f"/usage?period={period}"))
@@ -0,0 +1,30 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ class FormfeedError(Exception):
7
+ """An API problem (RFC 9457) or a transport failure.
8
+
9
+ ``code`` is the problem code (``quota_exceeded``, ``template_not_found`` …) or ``network_error`` /
10
+ ``timeout`` / ``not_ready`` for client-side failures; ``status`` is the HTTP status, 0 when none.
11
+ """
12
+
13
+ def __init__(
14
+ self,
15
+ code: str,
16
+ message: str,
17
+ status: int = 0,
18
+ problem: dict[str, Any] | None = None,
19
+ request_id: str | None = None,
20
+ ) -> None:
21
+ super().__init__(message)
22
+ self.code = code
23
+ self.message = message
24
+ self.status = status
25
+ self.problem = problem
26
+ self.request_id = request_id
27
+
28
+ def __str__(self) -> str: # pragma: no cover - formatting
29
+ suffix = f" (request {self.request_id})" if self.request_id else ""
30
+ return f"{self.code}: {self.message}{suffix}"
@@ -0,0 +1,150 @@
1
+ """Response models. Extra fields the API adds later are kept (``extra="allow"``) so clients never break on additions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Literal
6
+
7
+ from pydantic import BaseModel, ConfigDict
8
+
9
+ Region = Literal["eu", "us"]
10
+ Engine = Literal["jinja2", "liquid", "handlebars"]
11
+ OutputFormat = Literal["pdf", "png", "jpg", "webp"]
12
+ RenderStatus = Literal["queued", "rendering", "succeeded", "failed"]
13
+
14
+
15
+ class _Model(BaseModel):
16
+ model_config = ConfigDict(extra="allow", populate_by_name=True)
17
+
18
+
19
+ class RenderTemplateRef(_Model):
20
+ id: str
21
+ slug: str
22
+ version: int
23
+
24
+
25
+ class Render(_Model):
26
+ id: str
27
+ status: RenderStatus
28
+ output: str | None = None
29
+ download_url: str | None = None
30
+ expires_at: str | None = None
31
+ bytes: int | None = None
32
+ page_count: int | None = None
33
+ units: float = 0
34
+ region: str | None = None
35
+ environment: str | None = None
36
+ template: RenderTemplateRef | None = None
37
+ engine_version: str | None = None
38
+ template_checksum: str | None = None
39
+ output_sha256: str | None = None
40
+ deduplicated: bool = False
41
+ timings: dict[str, float] | None = None
42
+ error: dict[str, Any] | None = None
43
+ meta: dict[str, Any] = {}
44
+ created_at: str | None = None
45
+ completed_at: str | None = None
46
+
47
+ @property
48
+ def finished(self) -> bool:
49
+ return self.status in ("succeeded", "failed")
50
+
51
+
52
+ class Job(_Model):
53
+ id: str
54
+ type: str = "batch"
55
+ status: str
56
+ total: int = 0
57
+ succeeded: int = 0
58
+ failed: int = 0
59
+ zip_url: str | None = None
60
+ zip_expires_at: str | None = None
61
+ items: list[Render] = []
62
+ meta: dict[str, Any] = {}
63
+ created_at: str | None = None
64
+ completed_at: str | None = None
65
+
66
+ @property
67
+ def finished(self) -> bool:
68
+ return self.status not in ("queued", "processing")
69
+
70
+
71
+ class WebhookEndpoint(_Model):
72
+ id: str
73
+ url: str
74
+ events: list[str] = []
75
+ enabled: bool = True
76
+ description: str | None = None
77
+ consecutive_failures: int = 0
78
+ secret: str | None = None
79
+ created_at: str | None = None
80
+ updated_at: str | None = None
81
+
82
+
83
+ class Template(_Model):
84
+ id: str
85
+ slug: str
86
+ name: str
87
+ description: str | None = None
88
+ kind: str
89
+ engine: str
90
+ tags: list[str] = []
91
+ published_version: int | None = None
92
+ latest_version: int = 0
93
+ created_at: str | None = None
94
+ updated_at: str | None = None
95
+
96
+
97
+ class TemplateVersion(_Model):
98
+ id: str
99
+ number: int
100
+ status: str
101
+ checksum: str
102
+ change_note: str | None = None
103
+ created_at: str | None = None
104
+ published_at: str | None = None
105
+ html: str | None = None
106
+ css: str | None = None
107
+ head: str | None = None
108
+ settings: dict[str, Any] | None = None
109
+ sample_data: dict[str, Any] | None = None
110
+ data_schema: dict[str, Any] | None = None
111
+ i18n: dict[str, Any] | None = None
112
+
113
+
114
+ class UsageDay(_Model):
115
+ date: str
116
+ units: float = 0
117
+ renders: int = 0
118
+
119
+
120
+ class UsageTemplate(_Model):
121
+ template: str
122
+ units: float = 0
123
+
124
+
125
+ class Usage(_Model):
126
+ period: str
127
+ included: float = 0
128
+ used: float = 0
129
+ overage_used: float = 0
130
+ overage_balance: float = 0
131
+ daily: list[UsageDay] = []
132
+ by_template: list[UsageTemplate] = []
133
+
134
+
135
+ class RenderPage(_Model):
136
+ data: list[Render]
137
+ next_cursor: str | None = None
138
+
139
+
140
+ class TemplatePage(_Model):
141
+ data: list[Template]
142
+ next_cursor: str | None = None
143
+
144
+
145
+ class WebhookEvent(_Model):
146
+ id: str
147
+ type: str
148
+ created_at: str
149
+ workspace_id: str | None = None
150
+ data: Any = None
File without changes
@@ -0,0 +1,59 @@
1
+ """Webhook signature verification (spec 04 §2.4): ``Webhook-Signature: t=<unix>,v1=<hex hmac-sha256(secret, t + "." + body)>``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import hmac
7
+ import json
8
+ import re
9
+ import time
10
+ from typing import Any
11
+
12
+ from .models import WebhookEvent
13
+
14
+ _HEX64 = re.compile(r"^[0-9a-f]{64}$")
15
+
16
+
17
+ def verify_webhook_signature(
18
+ secret: str,
19
+ signature_header: str | None,
20
+ raw_body: bytes | str,
21
+ *,
22
+ tolerance_seconds: int = 300,
23
+ now: int | None = None,
24
+ ) -> bool:
25
+ """True when the header matches the raw body. Pass the body exactly as received: the signature covers the bytes on the wire."""
26
+ if not signature_header or not secret:
27
+ return False
28
+ parts: dict[str, str] = {}
29
+ for kv in signature_header.split(","):
30
+ key, _, value = kv.strip().partition("=")
31
+ parts[key] = value
32
+ try:
33
+ t = int(parts.get("t", ""))
34
+ except ValueError:
35
+ return False
36
+ v1 = parts.get("v1", "").lower()
37
+ if not _HEX64.match(v1):
38
+ return False
39
+ current = now if now is not None else int(time.time())
40
+ if abs(current - t) > tolerance_seconds:
41
+ return False
42
+ body = raw_body.encode("utf-8") if isinstance(raw_body, str) else raw_body
43
+ expected = hmac.new(secret.encode("utf-8"), f"{t}.".encode("utf-8") + body, hashlib.sha256).hexdigest()
44
+ return hmac.compare_digest(expected, v1)
45
+
46
+
47
+ def parse_webhook_event(
48
+ secret: str,
49
+ signature_header: str | None,
50
+ raw_body: bytes | str,
51
+ *,
52
+ tolerance_seconds: int = 300,
53
+ now: int | None = None,
54
+ ) -> WebhookEvent:
55
+ """Parses a verified event body; raises ``ValueError`` when the signature does not match."""
56
+ if not verify_webhook_signature(secret, signature_header, raw_body, tolerance_seconds=tolerance_seconds, now=now):
57
+ raise ValueError("invalid webhook signature")
58
+ payload: dict[str, Any] = json.loads(raw_body)
59
+ return WebhookEvent.model_validate(payload)
@@ -0,0 +1,155 @@
1
+ import hashlib
2
+ import hmac
3
+ import json
4
+ import time
5
+
6
+ import httpx
7
+ import pytest
8
+
9
+ from formfeed import AsyncFormfeed, Formfeed, FormfeedError, parse_webhook_event, verify_webhook_signature
10
+
11
+ RENDER = {"id": "rnd_1", "status": "succeeded", "download_url": "https://cdn.test/o/x.pdf?exp=1&sig=2", "page_count": 1, "units": 1}
12
+
13
+
14
+ def _json(body, status=200, headers=None):
15
+ return httpx.Response(status, json=body, headers=headers or {})
16
+
17
+
18
+ class Recorder:
19
+ def __init__(self, responder):
20
+ self.calls: list[httpx.Request] = []
21
+ self._responder = responder
22
+
23
+ def handler(self, request: httpx.Request) -> httpx.Response:
24
+ self.calls.append(request)
25
+ return self._responder(request, len(self.calls))
26
+
27
+
28
+ def sync_client(responder, **options) -> tuple[Formfeed, Recorder]:
29
+ rec = Recorder(responder)
30
+ return Formfeed("ff_test_k", transport=httpx.MockTransport(rec.handler), **options), rec
31
+
32
+
33
+ def test_render_sends_auth_idempotency_and_maps_the_response():
34
+ client, rec = sync_client(lambda req, n: _json(RENDER, 201))
35
+ render = client.renders.create(template="invoice", data={"a": 1}, output="pdf")
36
+ assert render.id == "rnd_1" and render.finished
37
+ req = rec.calls[0]
38
+ assert req.url == "https://api-eu.formfeed.dev/v1/renders"
39
+ assert req.headers["authorization"] == "Bearer ff_test_k"
40
+ assert len(req.headers["idempotency-key"]) > 10
41
+ assert json.loads(req.content) == {"template": "invoice", "data": {"a": 1}, "output": "pdf"}
42
+
43
+
44
+ def test_retries_429_with_retry_after_then_succeeds(monkeypatch):
45
+ monkeypatch.setattr("formfeed.client.time.sleep", lambda s: None)
46
+ client, rec = sync_client(lambda req, n: _json({"code": "rate_limited"}, 429, {"retry-after": "1"}) if n < 3 else _json(RENDER))
47
+ assert client.renders.get("rnd_1").status == "succeeded"
48
+ assert len(rec.calls) == 3
49
+
50
+
51
+ def test_problems_become_typed_errors():
52
+ client, _ = sync_client(lambda req, n: _json({"code": "quota_exceeded", "detail": "Monthly units used up", "status": 402}, 402, {"x-request-id": "req_9"}))
53
+ with pytest.raises(FormfeedError) as e:
54
+ client.renders.create(html="<p>x</p>")
55
+ assert e.value.code == "quota_exceeded"
56
+ assert e.value.status == 402
57
+ assert e.value.request_id == "req_9"
58
+ assert "Monthly units" in str(e.value)
59
+
60
+
61
+ def test_wait_for_and_download(monkeypatch):
62
+ monkeypatch.setattr("formfeed.client.time.sleep", lambda s: None)
63
+ states = iter(["queued", "rendering", "succeeded"])
64
+
65
+ def responder(req, n):
66
+ if req.url.host == "cdn.test":
67
+ return httpx.Response(200, content=b"%PDF")
68
+ return _json({**RENDER, "status": next(states)})
69
+
70
+ client, _ = sync_client(responder)
71
+ render = client.renders.wait_for("rnd_1", interval=0)
72
+ assert render.status == "succeeded"
73
+ assert client.renders.download(render) == b"%PDF"
74
+
75
+
76
+ def test_usage_reads_a_period():
77
+ client, rec = sync_client(
78
+ lambda req, n: _json(
79
+ {"period": "2026-09", "included": 15000, "used": 120, "overage_used": 0, "overage_balance": 0,
80
+ "daily": [{"date": "2026-09-07", "units": 3, "renders": 2}], "by_template": [{"template": "invoice", "units": 3}]}
81
+ )
82
+ )
83
+ usage = client.account.usage("2026-08")
84
+ assert usage.period == "2026-09"
85
+ assert usage.daily[0].renders == 2
86
+ assert usage.by_template[0].template == "invoice"
87
+ assert rec.calls[0].url.params.get("period") == "2026-08"
88
+
89
+
90
+ def test_renders_list_follows_the_cursor_and_deletes_outputs():
91
+ def responder(req, n):
92
+ if req.method == "DELETE":
93
+ return httpx.Response(204)
94
+ cursor = req.url.params.get("cursor")
95
+ return _json({"data": [RENDER], "next_cursor": None if cursor else "c1"})
96
+
97
+ client, rec = sync_client(responder)
98
+ page = client.renders.list(status="succeeded", limit=1)
99
+ assert len(page.data) == 1 and page.next_cursor == "c1"
100
+ assert rec.calls[0].url.params.get("status") == "succeeded"
101
+
102
+ every = client.renders.all(template="invoice")
103
+ assert len(every) == 2
104
+ assert rec.calls[-1].url.params.get("cursor") == "c1"
105
+
106
+ client.renders.delete_outputs("rnd_1")
107
+ assert rec.calls[-1].method == "DELETE"
108
+ assert rec.calls[-1].url.path == "/v1/renders/rnd_1/outputs"
109
+
110
+
111
+ def test_templates_follow_the_cursor_and_address_versions():
112
+ def responder(req, n):
113
+ if req.url.path == "/v1/templates":
114
+ cursor = req.url.params.get("cursor")
115
+ return _json({"data": [{"id": "tpl_1", "slug": "a", "name": "A", "kind": "pdf", "engine": "jinja2"}], "next_cursor": None if cursor else "c1"})
116
+ if req.url.path.endswith("/versions/latest"):
117
+ return _json({"id": "v", "number": 3, "status": "draft", "checksum": "x", "html": "<p>"})
118
+ return _json({"id": "v", "number": 4, "status": "published", "checksum": "y"})
119
+
120
+ client, rec = sync_client(responder)
121
+ assert len(client.templates.all(kind="pdf")) == 2
122
+ assert rec.calls[0].url.params["kind"] == "pdf"
123
+ assert rec.calls[1].url.params["cursor"] == "c1"
124
+ assert client.templates.version("a", "latest").html == "<p>"
125
+ assert client.templates.publish("a", 4).status == "published"
126
+ assert rec.calls[-1].url.path == "/v1/templates/a/versions/4/publish"
127
+
128
+
129
+ def test_async_client_has_the_same_surface():
130
+ import asyncio
131
+
132
+ rec = Recorder(lambda req, n: _json(RENDER, 201))
133
+
134
+ async def scenario():
135
+ async with AsyncFormfeed("ff_test_k", transport=httpx.MockTransport(rec.handler), region="us") as client:
136
+ return await client.renders.create(html="<p>x</p>")
137
+
138
+ render = asyncio.run(scenario())
139
+ assert render.id == "rnd_1"
140
+ assert rec.calls[0].url.host == "api-us.formfeed.dev"
141
+
142
+
143
+ def test_webhook_signature_round_trip():
144
+ secret = "whsec_test"
145
+ body = b'{"id":"evt_1","type":"render.completed","created_at":"2026-09-09T00:00:00Z","workspace_id":null,"data":{"id":"rnd_1"}}'
146
+ t = int(time.time())
147
+ sig = hmac.new(secret.encode(), f"{t}.".encode() + body, hashlib.sha256).hexdigest()
148
+ header = f"t={t},v1={sig}"
149
+ assert verify_webhook_signature(secret, header, body)
150
+ assert not verify_webhook_signature(secret, header, body + b" ")
151
+ assert not verify_webhook_signature(secret, header, body, now=t + 1000)
152
+ event = parse_webhook_event(secret, header, body)
153
+ assert event.type == "render.completed" and event.data == {"id": "rnd_1"}
154
+ with pytest.raises(ValueError):
155
+ parse_webhook_event("other", header, body)