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>
This commit is contained in:
KM Koushik
2026-02-08 08:18:14 +11:00
committed by GitHub
parent e246d32ef9
commit 09bdb8aaad
11 changed files with 1046 additions and 2 deletions
@@ -0,0 +1,43 @@
# Webhook Test Project (Flask)
This example project helps you validate Python SDK webhook signature verification locally.
## What it includes
- `receiver.py`: local webhook endpoint that verifies and parses events
- `send_test_webhook.py`: sends a signed test webhook request to your local endpoint
## Setup
```bash
cd packages/python-sdk/example/webhook-test-project
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```
## Run
Terminal 1:
```bash
python receiver.py
```
Terminal 2:
```bash
python send_test_webhook.py
```
You should see:
- `200` response from the receiver
- parsed webhook event output in the receiver terminal
## Environment variables
- `USESEND_WEBHOOK_SECRET` (default: `whsec_test`)
- `WEBHOOK_URL` (default: `http://127.0.0.1:8000/webhook`)
Use the same `USESEND_WEBHOOK_SECRET` for both scripts.
@@ -0,0 +1,37 @@
from __future__ import annotations
import os
from flask import Flask, jsonify, request
from flask.typing import ResponseReturnValue
from usesend import UseSend, WebhookVerificationError # type: ignore[import-not-found]
WEBHOOK_SECRET = os.getenv("USESEND_WEBHOOK_SECRET", "whsec_test")
app = Flask(__name__)
usesend = UseSend("us_test")
webhooks = usesend.webhooks(WEBHOOK_SECRET)
@app.post("/webhook")
def webhook() -> ResponseReturnValue:
raw_body = request.get_data()
try:
event = webhooks.construct_event(raw_body, headers=request.headers)
except WebhookVerificationError as exc:
return jsonify({"ok": False, "code": exc.code, "message": str(exc)}), 400
print(f"Received event: {event['type']}")
if event["type"] == "email.bounced":
bounce = event["data"].get("bounce", {})
print("Bounce details:", bounce)
return jsonify({"ok": True, "type": event["type"]}), 200
if __name__ == "__main__":
app.run(host="127.0.0.1", port=8000)
@@ -0,0 +1,2 @@
-e ../..
flask>=3.0,<4.0
@@ -0,0 +1,63 @@
from __future__ import annotations
import hashlib
import hmac
import json
import os
import time
import uuid
import requests
WEBHOOK_SECRET = os.getenv("USESEND_WEBHOOK_SECRET", "whsec_test")
WEBHOOK_URL = os.getenv("WEBHOOK_URL", "http://127.0.0.1:8000/webhook")
def _signature(secret: str, timestamp_ms: str, body: str) -> str:
digest = hmac.new(
secret.encode("utf-8"),
f"{timestamp_ms}.{body}".encode("utf-8"),
hashlib.sha256,
).hexdigest()
return f"v1={digest}"
def main() -> None:
payload = {
"id": f"evt_{uuid.uuid4().hex[:8]}",
"type": "email.bounced",
"createdAt": "2026-02-08T10:00:00.000Z",
"data": {
"id": "email_123",
"status": "BOUNCED",
"from": "sender@example.com",
"to": ["recipient@example.com"],
"occurredAt": "2026-02-08T10:00:00.000Z",
"bounce": {
"type": "Permanent",
"subType": "General",
"message": "Mailbox unavailable",
},
},
}
body = json.dumps(payload)
timestamp = str(int(time.time() * 1000))
signature = _signature(WEBHOOK_SECRET, timestamp, body)
headers = {
"Content-Type": "application/json",
"X-UseSend-Signature": signature,
"X-UseSend-Timestamp": timestamp,
"X-UseSend-Event": payload["type"],
"X-UseSend-Call": f"call_{uuid.uuid4().hex[:10]}",
}
response = requests.post(WEBHOOK_URL, data=body, headers=headers, timeout=10)
print("Status:", response.status_code)
print("Body:", response.text)
if __name__ == "__main__":
main()