Skip to content

Commit f41148b

Browse files
authored
Merge pull request #37 from totte-dev/release/v0.4.1
Release v0.4.1: Outbound webhooks
2 parents bafea65 + e70827c commit f41148b

File tree

6 files changed

+444
-1
lines changed

6 files changed

+444
-1
lines changed

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
66

77
## [Unreleased]
88

9+
## [0.4.1] - 2026-03-11
10+
11+
### Added
12+
- **Outbound webhooks**: Send webhooks to your customers with per-endpoint HMAC-SHA256 signatures. New `type: outbound` source with dynamic endpoint management via REST API.
13+
- `POST/GET /api/outbound/endpoints` — create and list customer endpoints
14+
- `GET/PUT/DELETE /api/outbound/endpoints/:id` — manage individual endpoints
15+
- `POST /api/outbound/endpoints/:id/rotate-secret` — rotate signing secret
16+
- `POST/GET/DELETE /api/outbound/endpoints/:id/subscriptions` — event type subscriptions
17+
- Per-endpoint signing secret (`whsec_` prefix) with `X-Qhook-Signature: v1=<hmac>` and `X-Qhook-Timestamp` headers
18+
- Wildcard (`*`) subscriptions, fan-out to multiple endpoints, disable/enable toggle
19+
- Reuses existing retry, DLQ, and circuit breaker infrastructure
20+
- **Example**: `examples/outbound-webhook/` — customer webhook receiver with signature verification
21+
- 10 outbound E2E tests + 1 full lifecycle scenario test (endpoint → subscribe → deliver → verify signature → disable → rotate secret)
22+
- TypeScript and Python SDK generation from OpenAPI spec (`sdks/generate.sh`)
23+
924
## [0.4.0] - 2026-03-11
1025

1126
### Added

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "qhook"
3-
version = "0.4.0"
3+
version = "0.4.1"
44
edition = "2024"
55
rust-version = "1.85"
66
description = "Lightweight webhook gateway and workflow engine with queue, retry, and signature verification."
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# Outbound Webhooks
2+
3+
Send webhooks to your customers with HMAC-SHA256 signatures. Like Svix, but built into qhook.
4+
5+
## Setup
6+
7+
1. Start qhook:
8+
9+
```bash
10+
qhook start -c examples/outbound-webhook/qhook.yaml
11+
```
12+
13+
2. Register a customer endpoint:
14+
15+
```bash
16+
curl -s -X POST http://localhost:8888/api/outbound/endpoints \
17+
-H "Authorization: Bearer my-secret-token" \
18+
-H "Content-Type: application/json" \
19+
-d '{"source": "my-saas", "url": "http://localhost:9000/webhook", "description": "Customer A"}'
20+
```
21+
22+
Save the `signing_secret` from the response (starts with `whsec_`).
23+
24+
3. Subscribe the endpoint to events:
25+
26+
```bash
27+
curl -s -X POST http://localhost:8888/api/outbound/endpoints/{ENDPOINT_ID}/subscriptions \
28+
-H "Authorization: Bearer my-secret-token" \
29+
-H "Content-Type: application/json" \
30+
-d '{"event_types": ["order.created", "payment.completed"]}'
31+
```
32+
33+
4. Start the customer receiver (pass the signing secret):
34+
35+
```bash
36+
python3 examples/outbound-webhook/receiver.py whsec_...
37+
```
38+
39+
5. Send an event from your app:
40+
41+
```bash
42+
curl -X POST http://localhost:8888/events/my-saas/order.created \
43+
-H "Authorization: Bearer my-secret-token" \
44+
-H "Content-Type: application/json" \
45+
-d '{"order_id": "ord_001", "customer": "alice", "amount": 4999}'
46+
```
47+
48+
## Expected Output
49+
50+
The customer receiver logs:
51+
52+
```
53+
[VERIFIED] event_type=order.created event_id=01JXXXX delivery_id=01JYYYY payload={"order_id": "ord_001", "customer": "alice", "amount": 4999}
54+
```
55+
56+
## What This Shows
57+
58+
- **Dynamic endpoint registration** via Management API
59+
- **Per-endpoint signing secrets** with `whsec_` prefix
60+
- **HMAC-SHA256 signed delivery** with `X-Qhook-Signature: v1=...`
61+
- **Subscription-based routing** -- only subscribed event types are delivered
62+
- **Automatic retry** with exponential backoff on failure
63+
64+
## Management API
65+
66+
| Operation | Command |
67+
|-----------|---------|
68+
| List endpoints | `curl -H "Authorization: Bearer $TOKEN" http://localhost:8888/api/outbound/endpoints` |
69+
| Disable endpoint | `curl -X PUT -H "Authorization: Bearer $TOKEN" -d '{"status":"disabled"}' http://localhost:8888/api/outbound/endpoints/{id}` |
70+
| Rotate secret | `curl -X POST -H "Authorization: Bearer $TOKEN" http://localhost:8888/api/outbound/endpoints/{id}/rotate-secret` |
71+
| Delete endpoint | `curl -X DELETE -H "Authorization: Bearer $TOKEN" http://localhost:8888/api/outbound/endpoints/{id}` |
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
# Outbound webhook example -- send webhooks to your customers
2+
database:
3+
driver: sqlite
4+
5+
server:
6+
port: 8888
7+
allow_private_urls: true
8+
9+
api:
10+
auth_token: ${QHOOK_API_TOKEN:-my-secret-token}
11+
12+
sources:
13+
my-saas:
14+
type: outbound
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""
2+
Customer webhook receiver that verifies qhook signatures.
3+
Demonstrates how your customers verify outbound webhook deliveries.
4+
No dependencies beyond Python stdlib.
5+
"""
6+
from http.server import HTTPServer, BaseHTTPRequestHandler
7+
import hashlib
8+
import hmac
9+
import json
10+
import sys
11+
12+
13+
# Set this to the signing_secret returned when creating the endpoint
14+
SIGNING_SECRET = sys.argv[1] if len(sys.argv) > 1 else ""
15+
16+
17+
class Handler(BaseHTTPRequestHandler):
18+
def do_POST(self):
19+
length = int(self.headers.get("Content-Length", 0))
20+
body = self.rfile.read(length)
21+
22+
# Extract signature headers
23+
signature = self.headers.get("X-Qhook-Signature", "")
24+
timestamp = self.headers.get("X-Qhook-Timestamp", "")
25+
event_type = self.headers.get("X-Qhook-Event-Type", "")
26+
event_id = self.headers.get("X-Qhook-Event-ID", "")
27+
delivery_id = self.headers.get("X-Qhook-Delivery-ID", "")
28+
29+
# Verify signature
30+
verified = False
31+
if SIGNING_SECRET and signature.startswith("v1="):
32+
expected = hmac.new(
33+
SIGNING_SECRET.encode(),
34+
f"{timestamp}.".encode() + body,
35+
hashlib.sha256,
36+
).hexdigest()
37+
verified = hmac.compare_digest(signature[3:], expected)
38+
39+
payload = json.loads(body) if body else {}
40+
status = "VERIFIED" if verified else ("UNVERIFIED" if SIGNING_SECRET else "NO_SECRET")
41+
42+
print(f"[{status}] event_type={event_type} event_id={event_id} "
43+
f"delivery_id={delivery_id} payload={json.dumps(payload)}")
44+
45+
self.send_response(200)
46+
self.send_header("Content-Type", "application/json")
47+
self.end_headers()
48+
self.wfile.write(b'{"status":"ok"}')
49+
50+
def log_message(self, format, *args):
51+
pass # suppress default access logs
52+
53+
54+
if __name__ == "__main__":
55+
port = 9000
56+
server = HTTPServer(("0.0.0.0", port), Handler)
57+
print(f"Customer webhook receiver listening on :{port}")
58+
if SIGNING_SECRET:
59+
print(f"Verifying signatures with secret: {SIGNING_SECRET[:12]}...")
60+
else:
61+
print("No signing secret provided -- pass it as: python3 receiver.py whsec_...")
62+
server.serve_forever()

0 commit comments

Comments
 (0)