Skip to content

Commit 09bdb8a

Browse files
KMKoushikclaude
andauthored
feat(python-sdk): add webhook verification and event handling (#344)
* feat(python-sdk): add webhook verification and event handling Add webhook support to the Python SDK matching the JS SDK implementation: - Add Webhooks class with verify() and construct_event() methods - Implement HMAC-SHA256 signature verification with timing-safe comparison - Add timestamp validation with configurable tolerance (default 5 minutes) - Add comprehensive webhook event types (18 events: email, contact, domain, test) - Add WebhookVerificationError with typed error codes - Export webhook constants (headers) and types * fix(python-sdk): harden webhook parsing and typing Normalize invalid UTF-8 webhook payloads to INVALID_BODY errors so verify() safely returns false, and narrow base email webhook event types to avoid discriminated-union overlap. Add regression tests for both paths. * chore(python-sdk): bump package version to 0.2.9 * feat(python-sdk): add local webhook test example project Add a runnable Flask receiver and signed webhook sender under packages/python-sdk/example, and link it from the Python SDK README for local verification. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e246d32 commit 09bdb8a

File tree

11 files changed

+1046
-2
lines changed

11 files changed

+1046
-2
lines changed

packages/python-sdk/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,12 @@ else:
8484
print("ok:", raw)
8585
```
8686

87+
## Webhook Local Example
88+
89+
For a runnable webhook verification demo project, see:
90+
91+
- `example/webhook-test-project/README.md`
92+
8793
## Development
8894

8995
This package is managed with Poetry. Models are maintained in-repo under
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Webhook Test Project (Flask)
2+
3+
This example project helps you validate Python SDK webhook signature verification locally.
4+
5+
## What it includes
6+
7+
- `receiver.py`: local webhook endpoint that verifies and parses events
8+
- `send_test_webhook.py`: sends a signed test webhook request to your local endpoint
9+
10+
## Setup
11+
12+
```bash
13+
cd packages/python-sdk/example/webhook-test-project
14+
python -m venv .venv
15+
source .venv/bin/activate
16+
pip install -r requirements.txt
17+
```
18+
19+
## Run
20+
21+
Terminal 1:
22+
23+
```bash
24+
python receiver.py
25+
```
26+
27+
Terminal 2:
28+
29+
```bash
30+
python send_test_webhook.py
31+
```
32+
33+
You should see:
34+
35+
- `200` response from the receiver
36+
- parsed webhook event output in the receiver terminal
37+
38+
## Environment variables
39+
40+
- `USESEND_WEBHOOK_SECRET` (default: `whsec_test`)
41+
- `WEBHOOK_URL` (default: `http://127.0.0.1:8000/webhook`)
42+
43+
Use the same `USESEND_WEBHOOK_SECRET` for both scripts.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
from __future__ import annotations
2+
3+
import os
4+
5+
from flask import Flask, jsonify, request
6+
from flask.typing import ResponseReturnValue
7+
8+
from usesend import UseSend, WebhookVerificationError # type: ignore[import-not-found]
9+
10+
11+
WEBHOOK_SECRET = os.getenv("USESEND_WEBHOOK_SECRET", "whsec_test")
12+
13+
app = Flask(__name__)
14+
usesend = UseSend("us_test")
15+
webhooks = usesend.webhooks(WEBHOOK_SECRET)
16+
17+
18+
@app.post("/webhook")
19+
def webhook() -> ResponseReturnValue:
20+
raw_body = request.get_data()
21+
22+
try:
23+
event = webhooks.construct_event(raw_body, headers=request.headers)
24+
except WebhookVerificationError as exc:
25+
return jsonify({"ok": False, "code": exc.code, "message": str(exc)}), 400
26+
27+
print(f"Received event: {event['type']}")
28+
29+
if event["type"] == "email.bounced":
30+
bounce = event["data"].get("bounce", {})
31+
print("Bounce details:", bounce)
32+
33+
return jsonify({"ok": True, "type": event["type"]}), 200
34+
35+
36+
if __name__ == "__main__":
37+
app.run(host="127.0.0.1", port=8000)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-e ../..
2+
flask>=3.0,<4.0
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
from __future__ import annotations
2+
3+
import hashlib
4+
import hmac
5+
import json
6+
import os
7+
import time
8+
import uuid
9+
10+
import requests
11+
12+
13+
WEBHOOK_SECRET = os.getenv("USESEND_WEBHOOK_SECRET", "whsec_test")
14+
WEBHOOK_URL = os.getenv("WEBHOOK_URL", "http://127.0.0.1:8000/webhook")
15+
16+
17+
def _signature(secret: str, timestamp_ms: str, body: str) -> str:
18+
digest = hmac.new(
19+
secret.encode("utf-8"),
20+
f"{timestamp_ms}.{body}".encode("utf-8"),
21+
hashlib.sha256,
22+
).hexdigest()
23+
return f"v1={digest}"
24+
25+
26+
def main() -> None:
27+
payload = {
28+
"id": f"evt_{uuid.uuid4().hex[:8]}",
29+
"type": "email.bounced",
30+
"createdAt": "2026-02-08T10:00:00.000Z",
31+
"data": {
32+
"id": "email_123",
33+
"status": "BOUNCED",
34+
"from": "sender@example.com",
35+
"to": ["recipient@example.com"],
36+
"occurredAt": "2026-02-08T10:00:00.000Z",
37+
"bounce": {
38+
"type": "Permanent",
39+
"subType": "General",
40+
"message": "Mailbox unavailable",
41+
},
42+
},
43+
}
44+
45+
body = json.dumps(payload)
46+
timestamp = str(int(time.time() * 1000))
47+
signature = _signature(WEBHOOK_SECRET, timestamp, body)
48+
49+
headers = {
50+
"Content-Type": "application/json",
51+
"X-UseSend-Signature": signature,
52+
"X-UseSend-Timestamp": timestamp,
53+
"X-UseSend-Event": payload["type"],
54+
"X-UseSend-Call": f"call_{uuid.uuid4().hex[:10]}",
55+
}
56+
57+
response = requests.post(WEBHOOK_URL, data=body, headers=headers, timeout=10)
58+
print("Status:", response.status_code)
59+
print("Body:", response.text)
60+
61+
62+
if __name__ == "__main__":
63+
main()

packages/python-sdk/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "usesend"
3-
version = "0.2.8"
3+
version = "0.2.9"
44
description = "Python SDK for the UseSend API"
55
authors = ["UseSend"]
66
license = "MIT"
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import hashlib
2+
import hmac
3+
import json
4+
import time
5+
from typing import get_args, get_type_hints
6+
7+
import pytest
8+
9+
from usesend import types
10+
from usesend.webhooks import (
11+
WEBHOOK_SIGNATURE_HEADER,
12+
WEBHOOK_TIMESTAMP_HEADER,
13+
WebhookVerificationError,
14+
Webhooks,
15+
)
16+
17+
18+
def _sign(secret: str, timestamp: str, body: str) -> str:
19+
digest = hmac.new(
20+
secret.encode("utf-8"),
21+
f"{timestamp}.{body}".encode("utf-8"),
22+
hashlib.sha256,
23+
).hexdigest()
24+
return f"v1={digest}"
25+
26+
27+
def test_verify_returns_false_for_non_utf8_bytes_body() -> None:
28+
webhooks = Webhooks("whsec_test")
29+
timestamp = str(int(time.time() * 1000))
30+
31+
is_valid = webhooks.verify(
32+
b"\xff",
33+
headers={
34+
WEBHOOK_SIGNATURE_HEADER: "v1=deadbeef",
35+
WEBHOOK_TIMESTAMP_HEADER: timestamp,
36+
},
37+
)
38+
39+
assert is_valid is False
40+
41+
42+
def test_construct_event_raises_invalid_body_for_non_utf8_bytes() -> None:
43+
webhooks = Webhooks("whsec_test")
44+
timestamp = str(int(time.time() * 1000))
45+
46+
with pytest.raises(WebhookVerificationError) as exc:
47+
webhooks.construct_event(
48+
b"\xff",
49+
headers={
50+
WEBHOOK_SIGNATURE_HEADER: "v1=deadbeef",
51+
WEBHOOK_TIMESTAMP_HEADER: timestamp,
52+
},
53+
)
54+
55+
assert exc.value.code == "INVALID_BODY"
56+
57+
58+
def test_email_webhook_event_type_excludes_specialized_events() -> None:
59+
email_event_type = get_type_hints(types.EmailWebhookEvent)["type"]
60+
supported = set(get_args(email_event_type))
61+
62+
assert "email.delivered" in supported
63+
assert "email.bounced" not in supported
64+
assert "email.failed" not in supported
65+
assert "email.suppressed" not in supported
66+
assert "email.opened" not in supported
67+
assert "email.clicked" not in supported
68+
69+
70+
def test_construct_event_parses_bounced_event_with_valid_signature() -> None:
71+
secret = "whsec_test"
72+
webhooks = Webhooks(secret)
73+
timestamp = str(int(time.time() * 1000))
74+
75+
payload = {
76+
"id": "evt_123",
77+
"type": "email.bounced",
78+
"createdAt": "2026-02-08T10:00:00.000Z",
79+
"data": {
80+
"id": "email_123",
81+
"status": "BOUNCED",
82+
"from": "from@example.com",
83+
"to": ["to@example.com"],
84+
"occurredAt": "2026-02-08T10:00:00.000Z",
85+
"bounce": {
86+
"type": "Permanent",
87+
"subType": "General",
88+
},
89+
},
90+
}
91+
body = json.dumps(payload)
92+
signature = _sign(secret, timestamp, body)
93+
94+
event = webhooks.construct_event(
95+
body,
96+
headers={
97+
WEBHOOK_SIGNATURE_HEADER: signature,
98+
WEBHOOK_TIMESTAMP_HEADER: timestamp,
99+
},
100+
)
101+
102+
assert event["type"] == "email.bounced"
103+
assert event["data"]["bounce"]["type"] == "Permanent"

packages/python-sdk/usesend/__init__.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,26 @@
33
from .usesend import UseSend, UseSendHTTPError
44
from .domains import Domains # type: ignore
55
from .campaigns import Campaigns # type: ignore
6+
from .webhooks import (
7+
Webhooks,
8+
WebhookVerificationError,
9+
WEBHOOK_SIGNATURE_HEADER,
10+
WEBHOOK_TIMESTAMP_HEADER,
11+
WEBHOOK_EVENT_HEADER,
12+
WEBHOOK_CALL_HEADER,
13+
)
614
from . import types
715

8-
__all__ = ["UseSend", "UseSendHTTPError", "types", "Domains", "Campaigns"]
16+
__all__ = [
17+
"UseSend",
18+
"UseSendHTTPError",
19+
"types",
20+
"Domains",
21+
"Campaigns",
22+
"Webhooks",
23+
"WebhookVerificationError",
24+
"WEBHOOK_SIGNATURE_HEADER",
25+
"WEBHOOK_TIMESTAMP_HEADER",
26+
"WEBHOOK_EVENT_HEADER",
27+
"WEBHOOK_CALL_HEADER",
28+
]

0 commit comments

Comments
 (0)