posthook-python 1.0.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,9 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .ruff_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Posthook
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,513 @@
1
+ Metadata-Version: 2.4
2
+ Name: posthook-python
3
+ Version: 1.0.0
4
+ Summary: The official Python client library for the Posthook API
5
+ Project-URL: Homepage, https://posthook.io
6
+ Project-URL: Documentation, https://posthook.io/docs
7
+ Project-URL: Repository, https://github.com/posthook/posthook-python
8
+ Author-email: Posthook <support@posthook.io>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Classifier: Development Status :: 5 - Production/Stable
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.9
22
+ Requires-Dist: httpx<1,>=0.25.0
23
+ Description-Content-Type: text/markdown
24
+
25
+ # posthook
26
+
27
+ The official Python client library for the [Posthook](https://posthook.io) API -- schedule webhooks and deliver them reliably.
28
+
29
+ ## Installation
30
+
31
+ ```bash
32
+ pip install posthook-python
33
+ ```
34
+
35
+ **Requirements:** Python 3.9+. Only dependency is [httpx](https://www.python-httpx.org/).
36
+
37
+ ## Quick Start
38
+
39
+ ```python
40
+ import posthook
41
+
42
+ client = posthook.Posthook("pk_...")
43
+
44
+ # Schedule a webhook 5 minutes from now
45
+ hook = client.hooks.schedule(
46
+ path="/webhooks/user-created",
47
+ post_in="5m",
48
+ data={"userId": "123", "event": "user.created"},
49
+ )
50
+
51
+ print(hook.id) # UUID
52
+ print(hook.status) # "pending"
53
+ ```
54
+
55
+ ## How It Works
56
+
57
+ Your Posthook project has a **domain** configured in the [dashboard](https://posthook.io) (e.g., `webhook.example.com`). When you schedule a hook, you specify a **path** (e.g., `/webhooks/user-created`). At the scheduled time, Posthook delivers the hook by POSTing to the full URL (`https://webhook.example.com/webhooks/user-created`) with your data payload and signature headers.
58
+
59
+ ## Authentication
60
+
61
+ You can find your API key under **Project Settings** in the [Posthook dashboard](https://posthook.io). Pass it directly to the constructor, or set the `POSTHOOK_API_KEY` environment variable:
62
+
63
+ ```python
64
+ # Explicit API key
65
+ client = posthook.Posthook("pk_...")
66
+
67
+ # From environment variable
68
+ client = posthook.Posthook() # reads POSTHOOK_API_KEY
69
+ ```
70
+
71
+ For webhook signature verification, also provide a signing key:
72
+
73
+ ```python
74
+ client = posthook.Posthook("pk_...", signing_key="ph_sk_...")
75
+ ```
76
+
77
+ The signing key can also be set via the `POSTHOOK_SIGNING_KEY` environment variable.
78
+
79
+ ## Scheduling Hooks
80
+
81
+ Three mutually exclusive scheduling modes are available. You must provide exactly one of `post_in`, `post_at`, or `post_at_local`.
82
+
83
+ ### Relative delay (`post_in`)
84
+
85
+ Schedule after a relative delay. Accepts `s` (seconds), `m` (minutes), `h` (hours), or `d` (days):
86
+
87
+ ```python
88
+ hook = client.hooks.schedule(
89
+ path="/webhooks/send-reminder",
90
+ post_in="30m",
91
+ data={"userId": "123"},
92
+ )
93
+ ```
94
+
95
+ ### Absolute UTC time (`post_at`)
96
+
97
+ Schedule at an exact UTC time. Accepts `datetime` objects or ISO 8601 strings:
98
+
99
+ ```python
100
+ from datetime import datetime, timedelta, timezone
101
+
102
+ # Using a datetime object (automatically converted to UTC)
103
+ hook = client.hooks.schedule(
104
+ path="/webhooks/send-reminder",
105
+ post_at=datetime.now(timezone.utc) + timedelta(hours=1),
106
+ data={"userId": "123"},
107
+ )
108
+
109
+ # Using an ISO string
110
+ hook = client.hooks.schedule(
111
+ path="/webhooks/send-reminder",
112
+ post_at="2026-06-15T10:00:00Z",
113
+ data={"userId": "123"},
114
+ )
115
+ ```
116
+
117
+ ### Local time with timezone (`post_at_local`)
118
+
119
+ Schedule at a local time. Posthook handles DST transitions automatically:
120
+
121
+ ```python
122
+ hook = client.hooks.schedule(
123
+ path="/webhooks/daily-digest",
124
+ post_at_local="2026-03-01T09:00:00",
125
+ timezone="America/New_York",
126
+ data={"userId": "123"},
127
+ )
128
+ ```
129
+
130
+ ### Custom retry configuration
131
+
132
+ Override your project's default retry behavior for a specific hook:
133
+
134
+ ```python
135
+ hook = client.hooks.schedule(
136
+ path="/webhooks/critical",
137
+ post_in="1m",
138
+ data={"orderId": "456"},
139
+ retry_override=posthook.HookRetryOverride(
140
+ min_retries=10,
141
+ delay_secs=15,
142
+ strategy="exponential",
143
+ backoff_factor=2.0,
144
+ max_delay_secs=3600,
145
+ jitter=True,
146
+ ),
147
+ )
148
+ ```
149
+
150
+ ## Managing Hooks
151
+
152
+ ### Get a hook
153
+
154
+ ```python
155
+ hook = client.hooks.get("hook-uuid")
156
+ ```
157
+
158
+ ### List hooks
159
+
160
+ ```python
161
+ hooks = client.hooks.list(status=posthook.STATUS_FAILED, limit=50)
162
+ print(f"Found {len(hooks)} hooks")
163
+ ```
164
+
165
+ All list parameters are optional:
166
+
167
+ | Parameter | Description |
168
+ |-----------|-------------|
169
+ | `status` | Filter by status: `"pending"`, `"retry"`, `"completed"`, `"failed"` |
170
+ | `limit` | Max results per page |
171
+ | `sort_by` | Sort field (e.g., `"createdAt"`, `"postAt"`) |
172
+ | `sort_order` | `"ASC"` or `"DESC"` |
173
+ | `post_at_before` | Filter hooks scheduled before this time (ISO string) |
174
+ | `post_at_after` | Cursor: hooks scheduled after this time (ISO string) |
175
+ | `created_at_before` | Filter hooks created before this time (ISO string) |
176
+ | `created_at_after` | Filter hooks created after this time (ISO string) |
177
+
178
+ ### Cursor-based pagination
179
+
180
+ Use `post_at_after` as a cursor. After each page, advance it to the last hook's `post_at`:
181
+
182
+ ```python
183
+ limit = 100
184
+ cursor = None
185
+ while True:
186
+ hooks = client.hooks.list(status="failed", limit=limit, post_at_after=cursor)
187
+ for hook in hooks:
188
+ print(hook.id, hook.failure_error)
189
+
190
+ if len(hooks) < limit:
191
+ break # last page
192
+ cursor = hooks[-1].post_at.isoformat()
193
+ ```
194
+
195
+ ### Auto-paginating iterator (`list_all`)
196
+
197
+ For convenience, `list_all` yields every matching hook across all pages automatically:
198
+
199
+ ```python
200
+ for hook in client.hooks.list_all(status="failed"):
201
+ process(hook)
202
+ ```
203
+
204
+ The async client returns an async iterator:
205
+
206
+ ```python
207
+ async for hook in client.hooks.list_all(status="failed"):
208
+ await process(hook)
209
+ ```
210
+
211
+ ### Delete a hook
212
+
213
+ Idempotent -- returns `None` on both success and 404 (already delivered or gone):
214
+
215
+ ```python
216
+ client.hooks.delete("hook-uuid")
217
+ ```
218
+
219
+ ## Bulk Operations
220
+
221
+ Three bulk operations are available, each supporting by-IDs or by-filter:
222
+
223
+ - **Retry** -- Re-attempts delivery for failed hooks
224
+ - **Replay** -- Re-delivers completed hooks (useful for reprocessing)
225
+ - **Cancel** -- Cancels pending hooks before delivery
226
+
227
+ ### By IDs
228
+
229
+ ```python
230
+ result = client.hooks.bulk.retry(["id-1", "id-2", "id-3"])
231
+ print(f"Retried {result.affected} hooks")
232
+ ```
233
+
234
+ ### By filter
235
+
236
+ ```python
237
+ result = client.hooks.bulk.cancel_by_filter(
238
+ start_time="2026-02-01T00:00:00Z",
239
+ end_time="2026-02-22T00:00:00Z",
240
+ limit=500,
241
+ endpoint_key="/webhooks/deprecated",
242
+ )
243
+ print(f"Cancelled {result.affected} hooks")
244
+ ```
245
+
246
+ All six methods:
247
+
248
+ ```python
249
+ # By IDs
250
+ client.hooks.bulk.retry(hook_ids)
251
+ client.hooks.bulk.replay(hook_ids)
252
+ client.hooks.bulk.cancel(hook_ids)
253
+
254
+ # By filter
255
+ client.hooks.bulk.retry_by_filter(start_time, end_time, limit, ...)
256
+ client.hooks.bulk.replay_by_filter(start_time, end_time, limit, ...)
257
+ client.hooks.bulk.cancel_by_filter(start_time, end_time, limit, ...)
258
+ ```
259
+
260
+ Filter methods also accept optional `endpoint_key` and `sequence_id` keyword arguments.
261
+
262
+ ## Verifying Webhook Signatures
263
+
264
+ When Posthook delivers a hook to your endpoint, it includes signature headers for verification. Use `parse_delivery` to verify and parse the delivery.
265
+
266
+ **Important:** You must pass the **raw request body** (bytes or string), not a parsed JSON object.
267
+
268
+ ### Flask
269
+
270
+ ```python
271
+ from flask import Flask, request
272
+ import posthook
273
+
274
+ app = Flask(__name__)
275
+ client = posthook.Posthook("pk_...", signing_key="ph_sk_...")
276
+
277
+ @app.route("/webhooks/user-created", methods=["POST"])
278
+ def handle_webhook():
279
+ try:
280
+ delivery = client.signatures.parse_delivery(
281
+ body=request.get_data(),
282
+ headers=dict(request.headers),
283
+ )
284
+ except posthook.SignatureVerificationError:
285
+ return "invalid signature", 401
286
+
287
+ print(delivery.hook_id) # from Posthook-Id header
288
+ print(delivery.path) # "/webhooks/user-created"
289
+ print(delivery.data) # your custom data payload
290
+ print(delivery.post_at) # when it was scheduled
291
+ print(delivery.posted_at) # when it was delivered
292
+
293
+ return "", 200
294
+ ```
295
+
296
+ ### Django
297
+
298
+ ```python
299
+ from django.http import HttpResponse
300
+ import posthook
301
+
302
+ client = posthook.Posthook("pk_...", signing_key="ph_sk_...")
303
+
304
+ def handle_webhook(request):
305
+ try:
306
+ delivery = client.signatures.parse_delivery(
307
+ body=request.body,
308
+ headers=dict(request.headers),
309
+ )
310
+ except posthook.SignatureVerificationError:
311
+ return HttpResponse(status=401)
312
+
313
+ print(delivery.hook_id)
314
+ print(delivery.data)
315
+
316
+ return HttpResponse(status=200)
317
+ ```
318
+
319
+ ### FastAPI
320
+
321
+ ```python
322
+ from fastapi import FastAPI, Request, Response
323
+ import posthook
324
+
325
+ app = FastAPI()
326
+ client = posthook.Posthook("pk_...", signing_key="ph_sk_...")
327
+
328
+ @app.post("/webhooks/user-created")
329
+ async def handle_webhook(request: Request):
330
+ body = await request.body()
331
+ try:
332
+ delivery = client.signatures.parse_delivery(
333
+ body=body,
334
+ headers=dict(request.headers),
335
+ )
336
+ except posthook.SignatureVerificationError:
337
+ return Response(status_code=401)
338
+
339
+ print(delivery.hook_id)
340
+ print(delivery.data)
341
+
342
+ return Response(status_code=200)
343
+ ```
344
+
345
+ ### Custom tolerance
346
+
347
+ By default, signatures older than 5 minutes are rejected. You can override this:
348
+
349
+ ```python
350
+ delivery = client.signatures.parse_delivery(
351
+ body=raw_body,
352
+ headers=headers,
353
+ tolerance=600, # 10 minutes, in seconds
354
+ )
355
+ ```
356
+
357
+ ## Error Handling
358
+
359
+ All API errors extend `PosthookError` and can be caught with `isinstance` or `except`:
360
+
361
+ ```python
362
+ import posthook
363
+
364
+ try:
365
+ hook = client.hooks.get("hook-id")
366
+ except posthook.RateLimitError:
367
+ print("Rate limited, retry later")
368
+ except posthook.AuthenticationError:
369
+ print("Invalid API key")
370
+ except posthook.NotFoundError:
371
+ print("Hook not found")
372
+ except posthook.PosthookError as err:
373
+ print(f"API error: {err.message} (status={err.status_code})")
374
+ ```
375
+
376
+ | Error class | HTTP Status | Code |
377
+ |---|---|---|
378
+ | `BadRequestError` | 400 | `bad_request` |
379
+ | `AuthenticationError` | 401 | `authentication_error` |
380
+ | `ForbiddenError` | 403 | `forbidden` |
381
+ | `NotFoundError` | 404 | `not_found` |
382
+ | `PayloadTooLargeError` | 413 | `payload_too_large` |
383
+ | `RateLimitError` | 429 | `rate_limit_exceeded` |
384
+ | `InternalServerError` | 5xx | `internal_error` |
385
+ | `PosthookConnectionError` | -- | `connection_error` |
386
+ | `SignatureVerificationError` | -- | `signature_verification_error` |
387
+
388
+ ## Configuration
389
+
390
+ ```python
391
+ client = posthook.Posthook(
392
+ "pk_...",
393
+ base_url="https://api.staging.posthook.io",
394
+ timeout=60,
395
+ signing_key="ph_sk_...",
396
+ )
397
+ ```
398
+
399
+ | Option | Description | Default |
400
+ |--------|-------------|---------|
401
+ | `api_key` | Your Posthook API key | `POSTHOOK_API_KEY` env var |
402
+ | `base_url` | Custom API base URL | `https://api.posthook.io` |
403
+ | `timeout` | Request timeout in seconds | `30` |
404
+ | `signing_key` | Signing key for webhook verification | `POSTHOOK_SIGNING_KEY` env var |
405
+ | `http_client` | Custom `httpx.Client` instance | -- |
406
+
407
+ ## Quota Info
408
+
409
+ After scheduling a hook, quota information is available on the returned `Hook` object:
410
+
411
+ ```python
412
+ hook = client.hooks.schedule(path="/test", post_in="5m")
413
+
414
+ if hook.quota:
415
+ print(f"Limit: {hook.quota.limit}")
416
+ print(f"Usage: {hook.quota.usage}")
417
+ print(f"Remaining: {hook.quota.remaining}")
418
+ print(f"Resets at: {hook.quota.resets_at}")
419
+ ```
420
+
421
+ ## Async Client
422
+
423
+ The `AsyncPosthook` client provides an identical API -- just `await` each call:
424
+
425
+ ```python
426
+ import posthook
427
+
428
+ async with posthook.AsyncPosthook("pk_...") as client:
429
+ hook = await client.hooks.schedule(path="/test", post_in="5m")
430
+ print(hook.id)
431
+
432
+ hooks = await client.hooks.list(status="pending")
433
+ ```
434
+
435
+ Both the sync and async clients support context managers for automatic cleanup:
436
+
437
+ ```python
438
+ # Sync
439
+ with posthook.Posthook("pk_...") as client:
440
+ hook = client.hooks.schedule(path="/test", post_in="5m")
441
+
442
+ # Async
443
+ async with posthook.AsyncPosthook("pk_...") as client:
444
+ hook = await client.hooks.schedule(path="/test", post_in="5m")
445
+ ```
446
+
447
+ You can also call `close()` / `await close()` manually if you prefer.
448
+
449
+ ## Debug Logging
450
+
451
+ The SDK logs all requests via Python's `logging` module under the `"posthook"` logger. Enable it to see request details:
452
+
453
+ ```python
454
+ import logging
455
+
456
+ logging.basicConfig(level=logging.DEBUG)
457
+ ```
458
+
459
+ Example output:
460
+
461
+ ```
462
+ DEBUG:posthook:POST /v1/hooks -> 200 (0.153s)
463
+ DEBUG:posthook:GET /v1/hooks -> 200 (0.089s)
464
+ ```
465
+
466
+ ## Advanced
467
+
468
+ ### Proxy support
469
+
470
+ Pass a custom `httpx.Client` configured with a proxy:
471
+
472
+ ```python
473
+ import httpx
474
+ import posthook
475
+
476
+ http_client = httpx.Client(proxy="http://proxy.example.com:8080")
477
+ client = posthook.Posthook("pk_...", http_client=http_client)
478
+ ```
479
+
480
+ ### Custom CA certificates
481
+
482
+ ```python
483
+ import httpx
484
+ import posthook
485
+
486
+ http_client = httpx.Client(verify="/path/to/custom-ca-bundle.crt")
487
+ client = posthook.Posthook("pk_...", http_client=http_client)
488
+ ```
489
+
490
+ ### Custom httpx client
491
+
492
+ For full control over HTTP behavior, provide your own `httpx.Client` (sync) or `httpx.AsyncClient` (async). The SDK will add its authentication headers automatically:
493
+
494
+ ```python
495
+ import httpx
496
+ import posthook
497
+
498
+ http_client = httpx.Client(
499
+ timeout=60,
500
+ verify=True,
501
+ proxy="http://proxy.example.com:8080",
502
+ limits=httpx.Limits(max_connections=20),
503
+ )
504
+
505
+ client = posthook.Posthook("pk_...", http_client=http_client)
506
+ ```
507
+
508
+ When you provide a custom client, the SDK does **not** close it on `client.close()` -- you are responsible for its lifecycle.
509
+
510
+ ## Requirements
511
+
512
+ - Python 3.9+
513
+ - [httpx](https://www.python-httpx.org/) >= 0.25.0