Skip to content

Commit 10220e9

Browse files
committed
Add WebSocket listener support (v1.2.0)
- Add listen() and stream() APIs for real-time hook delivery via WebSocket - Add Result type (ack/accept/nack) shared across HTTP and WebSocket handlers - Add ASGI/WSGI handler wrappers with Result dispatch - Add auto-reconnection, heartbeat monitoring, bounded concurrency - Add certifi-backed SSL context for websockets library compatibility - Add websockets dependency (>=12.0) - Add keywords to pyproject.toml
1 parent 1e01008 commit 10220e9

File tree

10 files changed

+2178
-11
lines changed

10 files changed

+2178
-11
lines changed

README.md

Lines changed: 180 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ The official Python client library for the [Posthook](https://posthook.io) API -
88
pip install posthook-python
99
```
1010

11-
**Requirements:** Python 3.9+. Only dependency is [httpx](https://www.python-httpx.org/).
11+
**Requirements:** Python 3.9+. Dependencies: [httpx](https://www.python-httpx.org/) and [websockets](https://websockets.readthedocs.io/).
1212

1313
## Quick Start
1414

@@ -70,7 +70,7 @@ hook = client.hooks.schedule(
7070

7171
### Absolute UTC time (`post_at`)
7272

73-
Schedule at an exact UTC time. Accepts `datetime` objects or ISO 8601 strings:
73+
Schedule at an exact UTC time. Accepts `datetime` objects or RFC 3339 strings:
7474

7575
```python
7676
from datetime import datetime, timedelta, timezone
@@ -82,7 +82,7 @@ hook = client.hooks.schedule(
8282
data={"userId": "123"},
8383
)
8484

85-
# Using an ISO string
85+
# Using an RFC 3339 string
8686
hook = client.hooks.schedule(
8787
path="/webhooks/send-reminder",
8888
post_at="2026-06-15T10:00:00Z",
@@ -186,7 +186,7 @@ async for hook in client.hooks.list_all(status="failed"):
186186

187187
### Delete a hook
188188

189-
Idempotent -- returns `None` on both success and 404 (already delivered or gone):
189+
To cancel a pending hook, delete it before delivery. Idempotent -- returns `None` on both 200 (deleted) and 404 (already deleted):
190190

191191
```python
192192
client.hooks.delete("hook-uuid")
@@ -330,9 +330,175 @@ delivery = client.signatures.parse_delivery(
330330
)
331331
```
332332

333+
## WebSocket Listener
334+
335+
The WebSocket listener receives hooks in real time without running an HTTP server. Use `AsyncPosthook` and call `hooks.listen()` with an async handler:
336+
337+
```python
338+
import asyncio
339+
import posthook
340+
341+
async def main():
342+
async with posthook.AsyncPosthook("pk_...") as client:
343+
async def on_hook(delivery):
344+
print(f"Received: {delivery.hook_id} -> {delivery.path}")
345+
print(f"Data: {delivery.data}")
346+
return posthook.Result.ack()
347+
348+
listener = await client.hooks.listen(
349+
on_hook,
350+
on_connected=lambda info: print(f"Connected: {info.project_name}"),
351+
)
352+
await listener.wait() # Blocks until closed
353+
354+
asyncio.run(main())
355+
```
356+
357+
### Result types
358+
359+
Your handler must return a `Result`:
360+
361+
| Factory | Effect |
362+
|---------|--------|
363+
| `Result.ack()` | Processing complete — hook is marked as delivered immediately |
364+
| `Result.nack(error?)` | Reject — triggers retry according to project settings |
365+
| `Result.accept(timeout)` | Async — you have `timeout` seconds to call back via HTTP (see below) |
366+
367+
```python
368+
return posthook.Result.ack()
369+
return posthook.Result.nack("processing failed")
370+
return posthook.Result.accept(timeout=120)
371+
```
372+
373+
### Async processing with `accept`
374+
375+
Use `accept` when your handler needs more time than the 10-second ack window.
376+
After returning `accept`, POST to the callback URLs on the delivery to report
377+
the outcome:
378+
379+
```python
380+
async def on_hook(delivery):
381+
# Kick off background work, save the callback URLs
382+
await queue.enqueue("process", {
383+
"data": delivery.data,
384+
"ack_url": delivery.ack_url,
385+
"nack_url": delivery.nack_url,
386+
})
387+
return posthook.Result.accept(timeout=300) # 5 minutes to call back
388+
389+
# Later, in the background worker:
390+
await posthook.async_ack(job["ack_url"])
391+
# or on failure:
392+
await posthook.async_nack(job["nack_url"], {"error": "failed"})
393+
```
394+
395+
If neither URL is called before the deadline, the hook is retried.
396+
397+
### Concurrency
398+
399+
By default, handlers run with unlimited concurrency (matching HTTP delivery behavior). Set `max_concurrency` to limit parallel handlers — deliveries that arrive while at capacity are nacked immediately so the server can retry them:
400+
401+
```python
402+
listener = await client.hooks.listen(on_hook, max_concurrency=10)
403+
```
404+
405+
### Lifecycle callbacks
406+
407+
```python
408+
listener = await client.hooks.listen(
409+
on_hook,
410+
on_connected=lambda info: print(f"Connected: {info.connection_id}"),
411+
on_disconnected=lambda err: print(f"Disconnected: {err}"),
412+
on_reconnecting=lambda attempt: print(f"Reconnecting (attempt {attempt})"),
413+
)
414+
```
415+
416+
### Stream API
417+
418+
For manual control over ack/nack, use `hooks.stream()` which returns an async iterator:
419+
420+
```python
421+
async with posthook.AsyncPosthook("pk_...") as client:
422+
async with await client.hooks.stream() as stream:
423+
async for delivery in stream:
424+
print(delivery.hook_id, delivery.data)
425+
426+
if should_process(delivery):
427+
await stream.ack(delivery.hook_id)
428+
else:
429+
await stream.nack(delivery.hook_id, "not ready")
430+
```
431+
432+
### HTTP fallback
433+
434+
If your project has a domain configured, hooks are delivered via HTTP when no
435+
WebSocket listener is connected. You can run both an HTTP endpoint and a
436+
WebSocket listener — the server uses WebSocket when available and falls back to
437+
HTTP automatically. Since both paths use the same `Result` type, you can share
438+
your handler logic:
439+
440+
```python
441+
async def process_hook(delivery):
442+
await process_order(delivery.data)
443+
return posthook.Result.ack()
444+
445+
# HTTP delivery (ASGI endpoint)
446+
app = signatures.asgi_handler(process_hook)
447+
448+
# WebSocket delivery (runs alongside)
449+
listener = await client.hooks.listen(process_hook)
450+
```
451+
452+
### WebSocket delivery metadata
453+
454+
Deliveries received via WebSocket include a `ws` field with attempt info:
455+
456+
```python
457+
async def on_hook(delivery):
458+
if delivery.ws:
459+
print(f"Attempt {delivery.ws.attempt}/{delivery.ws.max_attempts}")
460+
if delivery.ws.forward_request:
461+
print(f"Original body: {delivery.ws.forward_request.body}")
462+
return posthook.Result.ack()
463+
```
464+
465+
## ASGI/WSGI Handlers
466+
467+
For quick integration without a full web framework, `SignaturesService` provides handler wrappers:
468+
469+
### ASGI
470+
471+
```python
472+
import posthook
473+
474+
signatures = posthook.create_signatures("ph_sk_...")
475+
476+
async def on_hook(delivery):
477+
print(delivery.data)
478+
return posthook.Result.ack()
479+
480+
# Mount as an ASGI endpoint (e.g. with uvicorn)
481+
app = signatures.asgi_handler(on_hook)
482+
```
483+
484+
### WSGI
485+
486+
```python
487+
import posthook
488+
489+
signatures = posthook.create_signatures("ph_sk_...")
490+
491+
def on_hook(delivery):
492+
print(delivery.data)
493+
return posthook.Result.ack()
494+
495+
# Mount as a WSGI endpoint (e.g. with gunicorn)
496+
app = signatures.wsgi_handler(on_hook)
497+
```
498+
333499
## Async Hooks
334500

335-
When [async hooks](https://posthook.io/docs/essentials/async-hooks) are enabled, `parse_delivery()` populates `ack_url` and `nack_url` on the delivery object. Return 202 from your handler and call back when processing completes.
501+
When [async hooks](https://docs.posthook.io/essentials/async-hooks) are enabled, `parse_delivery()` populates `ack_url` and `nack_url` on the delivery object. Return 202 from your handler and call back when processing completes.
336502

337503
### FastAPI
338504

@@ -551,7 +717,16 @@ client = posthook.Posthook("pk_...", http_client=http_client)
551717

552718
When you provide a custom client, the SDK does **not** close it on `client.close()` -- you are responsible for its lifecycle.
553719

720+
## Resources
721+
722+
- [Documentation](https://docs.posthook.io) — guides, concepts, and patterns
723+
- [API Reference](https://docs.posthook.io/api-reference/introduction) — endpoint specs and examples
724+
- [Quickstart](https://docs.posthook.io/quickstart) — get started in under 2 minutes
725+
- [Pricing](https://posthook.io/pricing) — free tier included
726+
- [Status](https://status.posthook.io) — uptime and incident history
727+
554728
## Requirements
555729

556730
- Python 3.9+
557731
- [httpx](https://www.python-httpx.org/) >= 0.25.0
732+
- [websockets](https://websockets.readthedocs.io/) >= 12.0 (for WebSocket listener/stream)

pyproject.toml

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,25 @@ classifiers = [
2222
"Programming Language :: Python :: 3.13",
2323
"Typing :: Typed",
2424
]
25-
dependencies = ["httpx>=0.25.0,<1"]
25+
keywords = [
26+
"posthook",
27+
"webhook",
28+
"webhooks",
29+
"scheduled-webhooks",
30+
"api",
31+
"sdk",
32+
"scheduling",
33+
]
34+
dependencies = [
35+
"httpx>=0.25.0,<1",
36+
"websockets>=12.0,<15",
37+
]
2638

2739
[project.urls]
2840
Homepage = "https://posthook.io"
29-
Documentation = "https://posthook.io/docs"
41+
Documentation = "https://docs.posthook.io"
3042
Repository = "https://github.com/posthook/posthook-python"
43+
Changelog = "https://docs.posthook.io/changelog"
3144

3245
[tool.hatch.build.targets.wheel]
3346
packages = ["src/posthook"]

src/posthook/__init__.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@
1414
PosthookError,
1515
RateLimitError,
1616
SignatureVerificationError,
17+
WebSocketError,
1718
)
19+
from ._listener import ConnectionInfo, Listener, Result, Stream
1820
from ._models import (
1921
SORT_BY_CREATED_AT,
2022
SORT_BY_POST_AT,
@@ -29,9 +31,11 @@
2931
BulkActionResult,
3032
CallbackResult,
3133
Delivery,
34+
ForwardRequest,
3235
Hook,
3336
HookRetryOverride,
3437
QuotaInfo,
38+
WebSocketMeta,
3539
)
3640
from ._resources._signatures import SignaturesService, create_signatures
3741
from ._version import VERSION as __version__
@@ -47,6 +51,13 @@
4751
"BulkActionResult",
4852
"CallbackResult",
4953
"Delivery",
54+
"ForwardRequest",
55+
"WebSocketMeta",
56+
# WebSocket
57+
"Result",
58+
"Listener",
59+
"Stream",
60+
"ConnectionInfo",
5061
# Callbacks
5162
"ack",
5263
"nack",
@@ -78,6 +89,7 @@
7889
"InternalServerError",
7990
"PosthookConnectionError",
8091
"SignatureVerificationError",
92+
"WebSocketError",
8193
# Version
8294
"__version__",
8395
]

src/posthook/_errors.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,13 @@ def __init__(self, message: str, status_code: int | None = None) -> None:
110110
super().__init__(message, status_code=status_code, code="callback_error")
111111

112112

113+
class WebSocketError(PosthookError):
114+
"""Raised when a WebSocket connection error occurs."""
115+
116+
def __init__(self, message: str) -> None:
117+
super().__init__(message, code="websocket_error")
118+
119+
113120
def _create_error(
114121
status_code: int,
115122
message: str,

0 commit comments

Comments
 (0)