posthaste-email 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,70 @@
1
+ # Dependencies
2
+ node_modules/
3
+ .pnpm-store/
4
+
5
+ # Build output
6
+ dist/
7
+ build/
8
+ .next/
9
+ .turbo/
10
+ *.tsbuildinfo
11
+
12
+ # Environment — NOTE the suffixed variants.
13
+ # A bare `.env` rule matches ONLY a file named exactly `.env`. Real credentials
14
+ # have been committed in other repos here precisely because `.env.app` and
15
+ # `terraform.tfvars` fell outside a bare rule. Be explicit; keep *.example tracked.
16
+ .env
17
+ .env.*
18
+ !.env.example
19
+ !.env.*.example
20
+ *.tfvars
21
+ !*.tfvars.example
22
+
23
+ # Secrets, keys, certs — DKIM private keys never enter the tree.
24
+ secrets/
25
+ *.pem
26
+ *.key
27
+ *.p12
28
+ *.keystore
29
+ dkim-private*
30
+ !*.pub.pem
31
+
32
+ # Mail spool / queue state — durable buffers, never committed
33
+ spool/
34
+ queue/
35
+ *.eml
36
+ *.ndjson
37
+
38
+ # Python build and cache output (packages/sdk-python)
39
+ __pycache__/
40
+ *.py[cod]
41
+ .pytest_cache/
42
+ .mypy_cache/
43
+ *.egg-info/
44
+ .venv/
45
+
46
+ # PHP (packages/laravel). `composer.lock` is deliberately NOT committed: this
47
+ # is a library, so the lock file describes one machine's idea of the dependency
48
+ # graph rather than an application's. CI resolves fresh against the constraints
49
+ # in composer.json, which is what actually catches an incompatible Laravel
50
+ # release before a customer does.
51
+ vendor/
52
+ composer.lock
53
+ .phpunit.cache/
54
+ .phpunit.result.cache
55
+
56
+ # Logs, coverage, test output
57
+ *.log
58
+ coverage/
59
+ playwright-report/
60
+ test-results/
61
+
62
+ # OS / editor
63
+ .DS_Store
64
+ Thumbs.db
65
+ .idea/
66
+ .vscode/
67
+ *.swp
68
+
69
+ # Agent worktrees — scratch checkouts, never part of the repo.
70
+ .claude/worktrees/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Posthaste
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,454 @@
1
+ Metadata-Version: 2.5
2
+ Name: posthaste-email
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for the Posthaste transactional email API.
5
+ Project-URL: Homepage, https://posthastemail.dev
6
+ Project-URL: Documentation, https://posthastemail.dev/docs/python
7
+ Project-URL: Source, https://github.com/posthastemail/posthaste-python
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: email,posthaste,smtp,transactional-email,webhooks
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Topic :: Communications :: Email
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.9
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest>=8; extra == 'dev'
21
+ Description-Content-Type: text/markdown
22
+
23
+ # posthaste
24
+
25
+ The official Python SDK for the [Posthaste](https://posthastemail.dev) transactional email API.
26
+
27
+ **No third-party dependencies.** `urllib.request` and `hmac`, nothing else — so it installs
28
+ instantly, adds nothing to your lockfile, and brings no transitive supply chain for your security
29
+ team to audit.
30
+
31
+ ```bash
32
+ pip install posthaste
33
+ ```
34
+
35
+ Requires Python 3.9 or newer. Fully typed, with a `py.typed` marker, so mypy and pyright see every
36
+ annotation.
37
+
38
+ ---
39
+
40
+ ## Quickstart
41
+
42
+ ```python
43
+ import os
44
+ from posthaste import Posthaste
45
+
46
+ posthaste = Posthaste(api_key=os.environ["POSTHASTE_API_KEY"])
47
+
48
+ sent = posthaste.emails.send(
49
+ from_="Acme <billing@acme.example>",
50
+ to="customer@example.com",
51
+ subject="Your receipt",
52
+ html="<p>Thanks for your order.</p>",
53
+ text="Thanks for your order.",
54
+ idempotency_key=f"receipt-{order_id}",
55
+ )
56
+
57
+ sent["id"] # 'msg_AZLm3kQ8T2Sf9pXbNc7HrQ'
58
+ sent["status"] # 'queued' | 'duplicate'
59
+ sent["duplicate"] # False — a new send, not an idempotency replay
60
+ ```
61
+
62
+ `from_` has the trailing underscore because `from` is a Python keyword. It is the only field whose
63
+ Python name differs for that reason, and the SDK maps it back to `from` on the wire.
64
+
65
+ ## Snake in, camel out
66
+
67
+ Requests are Python: snake_case keyword arguments, mapped to the API's camelCase on the way out.
68
+ Responses come back as the API's own JSON, **unchanged** — camelCase keys and all.
69
+
70
+ That asymmetry is deliberate. Rewriting a response means deciding what to do with a field this SDK
71
+ version has never heard of, and every answer is bad: drop it and you lose data the API sent you;
72
+ keep it under its original name and the object is half-translated. The response is the API's
73
+ document, and a document is not ours to rewrite. What the SDK adds instead is a `TypedDict` for
74
+ every shape, so your editor and type checker know the keys without anything being reshaped at
75
+ runtime.
76
+
77
+ ## Authentication
78
+
79
+ Posthaste authenticates with an API key sent as a bearer token. Create one in the dashboard under
80
+ **Settings → API keys**; it is shown exactly once.
81
+
82
+ ```python
83
+ posthaste = Posthaste(
84
+ api_key=os.environ["POSTHASTE_API_KEY"], # ph_live_… or ph_test_…
85
+ base_url="https://api.posthastemail.dev", # the default
86
+ timeout=30.0, # per ATTEMPT, not per call
87
+ max_retries=2, # retries after the first attempt
88
+ max_retry_delay=60.0, # longest Retry-After it will sit through
89
+ headers={"x-trace-id": trace_id}, # added to every request
90
+ )
91
+ ```
92
+
93
+ A key is server-side only: it carries no user identity and must never reach a browser. It carries
94
+ **scopes**, and a call that needs one the key does not hold raises `PermissionDeniedError` naming
95
+ the missing scope.
96
+
97
+ **The key is never printed.** It is not in `repr(client)`, not in any exception message, and not in
98
+ a formatted traceback — including when a server echoes it back in an error. That matters more than
99
+ it sounds: a credential leaks through a `repr` pasted into a ticket and through a traceback an error
100
+ reporter uploaded, far more often than through a deliberate log line.
101
+
102
+ ### Self-hosting
103
+
104
+ Point `base_url` at your own deployment. Trailing slashes are stripped.
105
+
106
+ ```python
107
+ posthaste = Posthaste(api_key=key, base_url="https://mail.internal.example/api")
108
+ ```
109
+
110
+ ### Bringing your own HTTP client
111
+
112
+ `Transport` is a `Protocol`, so anything with a matching `request` method works — no inheritance
113
+ required. Useful behind a corporate proxy, for tracing, and in tests.
114
+
115
+ ```python
116
+ import httpx
117
+ from posthaste import Posthaste
118
+ from posthaste.http import HttpResponse
119
+
120
+ class HttpxTransport:
121
+ def __init__(self, **kwargs):
122
+ # follow_redirects stays OFF: a redirect would re-send the bearer token
123
+ # to whatever host the Location names.
124
+ self._client = httpx.Client(follow_redirects=False, **kwargs)
125
+
126
+ def request(self, method, url, headers, body, timeout):
127
+ response = self._client.request(
128
+ method, url, headers=headers, content=body, timeout=timeout
129
+ )
130
+ return HttpResponse(response.status_code, response.headers, response.content)
131
+
132
+ posthaste = Posthaste(api_key=key, transport=HttpxTransport(proxy="http://proxy.internal:8080"))
133
+ ```
134
+
135
+ ## Errors
136
+
137
+ Every failure raises a `PosthasteError`, including the ones that never reached a server. Under that
138
+ base there is a real hierarchy, so `except RateLimited` reads the way a Python caller expects.
139
+
140
+ ```python
141
+ from posthaste import (
142
+ ContentBlockedError,
143
+ DomainNotVerifiedError,
144
+ QuotaExhausted,
145
+ RateLimited,
146
+ SuppressedError,
147
+ PosthasteError,
148
+ )
149
+
150
+ try:
151
+ posthaste.emails.send(from_=sender, to=recipient, text=body)
152
+
153
+ except SuppressedError as error:
154
+ # Permanent. error.suppression.reason is 'hard_bounce' | 'complaint' |
155
+ # 'spam_trap' | 'manual' | 'unsubscribe'.
156
+ drop(error.suppression)
157
+
158
+ except DomainNotVerifiedError:
159
+ ask_them_to_publish_the_dkim_record()
160
+
161
+ except ContentBlockedError as error:
162
+ # error.check names the check; error.findings is the whole lint report,
163
+ # warnings included, so one fix pass can address everything.
164
+ show(error.findings)
165
+
166
+ except RateLimited as error:
167
+ # Transient, measured in seconds. Covers `rate_limited` (your request rate)
168
+ # and `platform_paused` (our own daily send ceiling, which is not yours and
169
+ # which no plan change touches).
170
+ retry_after(error.retry_after_seconds or 60)
171
+
172
+ except QuotaExhausted as error:
173
+ # Hours or days away. Not a retry — an alert.
174
+ alert_ops(error)
175
+
176
+ except PosthasteError as error:
177
+ log.warning("posthaste refused: %s %s", error.status, error.type)
178
+ ```
179
+
180
+ **Branch on the class or the type, never on the status.** Four different refusals arrive as `429`.
181
+ `RateLimited` and `QuotaExhausted` are deliberately siblings rather than parent and child, because
182
+ an `except` clause written for one must never catch the other — that confusion is exactly what makes
183
+ a naive retry loop hammer a wall for the rest of the month.
184
+
185
+ Every exception carries `status`, `type`, `message`, `fields` when the server sent them,
186
+ `retry_after_seconds` when the server said, and `body` for the extra keys a refusal carries. Two
187
+ types are synthesised by the SDK and never sent by the API: `APIConnectionError` (the request never
188
+ opened) and `APITimeoutError` (it opened and never answered). Both have `status == 0`.
189
+
190
+ | Exception | Covers |
191
+ | ---------------------------------------- | ----------------------------------------------------- |
192
+ | `AuthenticationError` | `unauthorized`, `unauthenticated`, `email_unverified` |
193
+ | `PermissionDeniedError` | `forbidden` — a real key missing a scope |
194
+ | `InvalidRequestError` | `invalid_request`; read `.fields` |
195
+ | `NotFoundError` | `not_found` |
196
+ | `ConflictError` | `conflict`, `address_taken` |
197
+ | `UnprocessableError` | every other 422 — permanent, never retried |
198
+ | `SuppressedError` | `suppressed`; read `.suppression` |
199
+ | `DomainNotVerifiedError` | `domain_not_verified` |
200
+ | `ContentBlockedError` | `content_blocked`; read `.check` and `.findings` |
201
+ | `AttachmentError` | the four attachment refusals |
202
+ | `ScheduleError` | `schedule_too_far`, `not_scheduled` |
203
+ | `TemplateError` / `StreamError` | unknown or unusable template / stream |
204
+ | `RateLimited` | `rate_limited`, `platform_paused` |
205
+ | `QuotaExhausted` | `daily_limit_reached`, `monthly_limit_reached` |
206
+ | `ServerError` | 5xx |
207
+ | `APIConnectionError` / `APITimeoutError` | never reached the server |
208
+
209
+ A refusal reason added to the API after your SDK version still arrives as a typed exception — the
210
+ class is chosen from the HTTP status when the type is unrecognised, never from a `KeyError`.
211
+
212
+ ## Retries
213
+
214
+ The SDK retries `408`, `429` and `5xx`, and connection failures, with exponential backoff and full
215
+ jitter, honouring `Retry-After`. Two rules make that safe rather than merely automatic.
216
+
217
+ - **Quota is never retried in process.** `daily_limit_reached` and `monthly_limit_reached` raise
218
+ immediately with the wait attached, so you can queue, delay or alert instead of hammering a wall
219
+ the calendar has to move before it opens.
220
+ - **Nothing is repeated that repeating could duplicate.** A send is retried _only_ when you supplied
221
+ an `idempotency_key`; `webhooks.create` and `streams.create` are never retried. Reads, deletes,
222
+ `domains.create` (a duplicate is a `409`) and `suppressions.create` (an upsert) are all safe, and
223
+ are retried.
224
+
225
+ A `Retry-After` longer than `max_retry_delay` (60s by default) is honoured by **not** retrying: a
226
+ quota that frees at midnight is not something to block a request handler on, and sleeping through it
227
+ would look like a hang.
228
+
229
+ ## Pagination
230
+
231
+ Every list on the API pages. `list()` returns one page; `auto_paginate()` is a generator over every
232
+ page; `list_all()` drains it into a list up to a ceiling you choose.
233
+
234
+ ```python
235
+ # One page.
236
+ page = posthaste.messages.list(limit=50, status="bounced")
237
+ page["data"] # list[MessageSummary]
238
+ page["hasMore"]
239
+ page["nextCursor"] # pass as `before` on the next request
240
+
241
+ # Every page, as an ordinary for loop. Breaking out stops fetching.
242
+ for message in posthaste.messages.auto_paginate(status="bounced"):
243
+ suppress(message["to"])
244
+
245
+ # Or into a list, with a ceiling you choose. 1,000 by default.
246
+ recent = posthaste.messages.list_all(max_items=500, status="bounced")
247
+
248
+ # Every list has the same three, including the ones that send no `hasMore` at
249
+ # all — domains, webhooks, API keys and invoices. This is all sixty invoices,
250
+ # not the first fifty.
251
+ invoices = posthaste.billing.list_all_invoices()
252
+
253
+ # Billing history is the odd one out: it pages FORWARD from a sequence number
254
+ # in `after`, oldest first. Hence the different names — the helper knows.
255
+ for event in posthaste.billing.auto_paginate_history():
256
+ record(event["seq"], event["type"])
257
+ ```
258
+
259
+ **Neither stopping rule is correct on its own, which is why the helper exists.** `while
260
+ next_cursor:` never terminates on the endpoints that return a non-null cursor on their last page.
261
+ And `if not page.get("hasMore"): break` stops after ONE page on `/v1/domains`, `/v1/webhooks`,
262
+ `/v1/api-keys`, `/v1/billing/invoices` and `/v1/billing/history`, because those send no `hasMore` at
263
+ all — the read is `None`, which is falsy, which looks exactly like "that was everything".
264
+ `auto_paginate` consults both signals and treats each as authoritative only where the server
265
+ actually sends it.
266
+
267
+ The `max_items` ceiling on `list_all` is not garnish. Draining an unbounded list into memory is how
268
+ a convenience helper becomes an incident on the one account with four million messages, so the
269
+ caller chooses the bound.
270
+
271
+ ## Verifying webhooks
272
+
273
+ `verify_webhook` parses the signature header **by key** rather than positionally (`v1=` exists so a
274
+ `v2=` can be added beside it), compares in constant time, and rejects a timestamp more than 300
275
+ seconds old _or_ more than 300 seconds in the future — a future timestamp is not clock skew to be
276
+ generous about, it is an attacker buying an unlimited replay window.
277
+
278
+ ```python
279
+ # Flask
280
+ from flask import Flask, request
281
+ from posthaste import DELIVERY_ID_HEADER, SIGNATURE_HEADER, verify_webhook
282
+
283
+ app = Flask(__name__)
284
+
285
+ @app.post("/hooks/posthaste")
286
+ def posthaste_webhook():
287
+ # THE RAW BYTES. request.get_json() would hand you an object, and the
288
+ # signature covers the bytes we sent — not the object they decode to.
289
+ result = verify_webhook(
290
+ request.get_data(),
291
+ request.headers.get(SIGNATURE_HEADER),
292
+ os.environ["POSTHASTE_WEBHOOK_SECRET"],
293
+ )
294
+
295
+ if not result:
296
+ # 'malformed_header' | 'unsupported_version' | 'timestamp_too_old'
297
+ # | 'timestamp_in_future' | 'signature_mismatch'
298
+ app.logger.warning("rejected webhook: %s", result.reason)
299
+ return "", 400
300
+
301
+ event = json.loads(request.get_data())
302
+
303
+ # Deduplicate on the delivery id — a retry is not a second event.
304
+ enqueue(request.headers.get(DELIVERY_ID_HEADER), event)
305
+
306
+ # Acknowledge fast; do the work elsewhere. We time out after 10 seconds.
307
+ return "", 204
308
+ ```
309
+
310
+ ```python
311
+ # FastAPI
312
+ from fastapi import FastAPI, Request, Response
313
+ from posthaste import parse_webhook_event
314
+
315
+ app = FastAPI()
316
+
317
+ @app.post("/hooks/posthaste")
318
+ async def posthaste_webhook(request: Request):
319
+ raw = await request.body() # never await request.json()
320
+ event = parse_webhook_event(
321
+ raw,
322
+ request.headers.get("posthaste-signature"),
323
+ os.environ["POSTHASTE_WEBHOOK_SECRET"],
324
+ )
325
+ if event is None:
326
+ return Response(status_code=400)
327
+ await enqueue(request.headers.get("posthaste-delivery-id"), event)
328
+ return Response(status_code=204)
329
+ ```
330
+
331
+ ```python
332
+ # Django
333
+ from django.http import HttpResponse
334
+ from django.views.decorators.csrf import csrf_exempt
335
+ from posthaste import verify_webhook
336
+
337
+ @csrf_exempt
338
+ def posthaste_webhook(request):
339
+ if not verify_webhook(
340
+ request.body, # bytes, exactly as received
341
+ request.headers.get("Posthaste-Signature"),
342
+ settings.POSTHASTE_WEBHOOK_SECRET,
343
+ ):
344
+ return HttpResponse(status=400)
345
+ enqueue(request.headers.get("Posthaste-Delivery-Id"), json.loads(request.body))
346
+ return HttpResponse(status=204)
347
+ ```
348
+
349
+ **Verify over the raw bytes.** A body that has been parsed and re-serialised will never verify —
350
+ `json.loads` followed by `json.dumps` is not a byte-level round trip, and the signature covers the
351
+ bytes we sent, not the object they decode to. This is the single most common reason verification
352
+ "mysteriously" fails, and no amount of correct key handling rescues it.
353
+
354
+ `parse_webhook_event` verifies and JSON-parses in one step, returning `None` on any failure, for the
355
+ common case where a bad delivery just gets a `400`. Use `verify_webhook` directly when the reason
356
+ matters. The signing secret is the one returned once when the endpoint was created.
357
+
358
+ ## Every method
359
+
360
+ ```text
361
+ account.me() GET /v1/me
362
+ account.verify() GET /v1/account/verify
363
+ account.usage() GET /v1/usage
364
+
365
+ domains.create(name) POST /v1/domains
366
+ domains.list(...) GET /v1/domains
367
+ domains.auto_paginate(...) GET /v1/domains (all pages)
368
+ domains.list_all(...) GET /v1/domains (all pages)
369
+ domains.verify(domain_id) POST /v1/domains/:id/verify
370
+ domains.delete(domain_id) DELETE /v1/domains/:id
371
+ domains.setup(domain_id) GET /v1/domains/:id/setup
372
+ domains.connect_cloudflare(domain_id, ...) POST /v1/domains/:id/cloudflare
373
+ domains.disconnect_cloudflare() DELETE /v1/account/cloudflare
374
+
375
+ emails.send(...) POST /v1/emails
376
+ emails.send_batch(messages) POST /v1/emails/batch
377
+ emails.cancel_schedule(message_id) DELETE /v1/emails/:id/schedule
378
+
379
+ messages.list(...) GET /v1/messages
380
+ messages.auto_paginate(...) GET /v1/messages (all pages)
381
+ messages.list_all(...) GET /v1/messages (all pages)
382
+ messages.get(message_id) GET /v1/messages/:id
383
+ messages.download_attachment(msg, att) GET /v1/messages/:id/attachments/:attachmentId
384
+ messages.stats(...) GET /v1/stats/messages
385
+
386
+ suppressions.list(...) GET /v1/suppressions
387
+ suppressions.auto_paginate(...) GET /v1/suppressions (all pages)
388
+ suppressions.list_all(...) GET /v1/suppressions (all pages)
389
+ suppressions.create(address, reason=...) POST /v1/suppressions
390
+ suppressions.delete(address) DELETE /v1/suppressions/:address
391
+
392
+ webhooks.create(url, event_types=...) POST /v1/webhooks
393
+ webhooks.list(...) GET /v1/webhooks
394
+ webhooks.auto_paginate(...) GET /v1/webhooks (all pages)
395
+ webhooks.list_all(...) GET /v1/webhooks (all pages)
396
+ webhooks.delete(webhook_id) DELETE /v1/webhooks/:id
397
+
398
+ templates.list() GET /v1/templates
399
+ templates.get(template_id) GET /v1/templates/:id
400
+ templates.versions(template_id) GET /v1/templates/:id/versions
401
+ templates.preview(template_id, ...) POST /v1/templates/:id/preview
402
+ templates.create(...) POST /v1/templates
403
+ templates.update(template_id, ...) PATCH /v1/templates/:id
404
+ templates.delete(template_id) DELETE /v1/templates/:id
405
+
406
+ streams.list() GET /v1/streams
407
+ streams.create(slug=..., name=...) POST /v1/streams
408
+
409
+ api_keys.list(...) GET /v1/api-keys
410
+ api_keys.auto_paginate(...) GET /v1/api-keys (all pages)
411
+ api_keys.list_all(...) GET /v1/api-keys (all pages)
412
+
413
+ billing.get() GET /v1/billing
414
+ billing.history(...) GET /v1/billing/history
415
+ billing.auto_paginate_history(...) GET /v1/billing/history (all pages)
416
+ billing.list_all_history(...) GET /v1/billing/history (all pages)
417
+ billing.invoices(...) GET /v1/billing/invoices
418
+ billing.auto_paginate_invoices(...) GET /v1/billing/invoices (all pages)
419
+ billing.list_all_invoices(...) GET /v1/billing/invoices (all pages)
420
+ billing.invoice(invoice_id) GET /v1/billing/invoices/:id
421
+ ```
422
+
423
+ **Deliberately absent:** creating and revoking API keys, and everything else that requires a
424
+ signed-in person rather than a key — checkout, plan changes, profile edits, team management. Those
425
+ endpoints refuse a bearer token outright, and a method that can only ever raise
426
+ `PermissionDeniedError` is worse than no method. `/admin/v1/*` is not part of the public surface at
427
+ all, and `/v1/inbound/*` is absent because a customer cannot use it: the DNS records handed out on
428
+ domain verification contain no MX, so nothing would arrive.
429
+
430
+ Also absent, and for a different reason: `/v1/analytics/delivery`, `/v1/audit/*` and
431
+ `/v1/sub-accounts`. Those are real API-key endpoints that the TypeScript SDK does not cover either;
432
+ this package is at parity with it rather than ahead of it, and adding them here first would put the
433
+ two clients out of step.
434
+
435
+ ## Development
436
+
437
+ ```bash
438
+ python3 -m pytest packages/sdk-python
439
+ ```
440
+
441
+ The suite makes no network calls — a socket attempt raises before DNS is consulted, and a test
442
+ asserts that the guard is load-bearing rather than decorative. It also refuses to let any file name
443
+ an address at a domain we do not own.
444
+
445
+ The webhook fixtures are generated by the **TypeScript** signer that signs live deliveries, so the
446
+ Python verifier is checked against real signatures rather than against itself:
447
+
448
+ ```bash
449
+ pnpm exec tsx packages/sdk-python/scripts/generate-webhook-fixtures.ts
450
+ ```
451
+
452
+ ## Licence
453
+
454
+ MIT.