fix webhook crashes on null custom_data, migrate to SDK Verifier

Paddle sandbox sends lifecycle events (subscription.updated, etc.) with
"custom_data": null. The .get("custom_data", {}) default only applies
when the key is missing, not when the value is explicitly null, causing
AttributeError on the next .get() call. Also guarded subscription.activated
to skip when user_id is absent (was inserting user_id=0 → FK violation).

Replaced manual HMAC verification with paddle_billing.Notifications.Verifier
via a lightweight _WebhookRequest wrapper satisfying the SDK's Request Protocol.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-02-18 22:43:40 +01:00
parent df8a747463
commit 0b8350c770
3 changed files with 124 additions and 51 deletions

View File

@@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Fixed
- **Webhook crash on null `custom_data`** — Paddle sends `"custom_data": null`
on lifecycle events (e.g. `subscription.updated`); `.get("custom_data", {})`
returns `None` when the key exists with a null value, causing `AttributeError`
on the next `.get()` call; switched to `or {}` fallback; also guarded
`subscription.activated` to skip when `user_id` is missing (was inserting
`user_id=0` causing FK violation), and applied same `or {}` to
`current_billing_period`
- **Webhook signature verification uses SDK Verifier** — replaced manual HMAC
implementation with `paddle_billing.Notifications.Verifier` via a lightweight
request wrapper; same algorithm, fewer moving parts
### Added
- **Credit system test suite** (`tests/test_credits.py` — 24 tests) — covers
`get_balance`, `add_credits`, `spend_credits`, `compute_credit_cost`,

View File

@@ -3,16 +3,14 @@ Billing domain: checkout, webhooks, subscription management.
Payment provider: paddle
"""
import hashlib
import hmac
import json
import time
from datetime import datetime
from functools import wraps
from pathlib import Path
from paddle_billing import Client as PaddleClient
from paddle_billing import Environment, Options
from paddle_billing.Notifications import Secret, Verifier
from quart import Blueprint, flash, g, jsonify, redirect, render_template, request, session, url_for
from ..auth.routes import login_required
@@ -212,37 +210,30 @@ async def cancel():
return redirect(url_for("dashboard.settings"))
def _verify_paddle_signature(payload: bytes, signature_header: str, secret: str,
max_drift_seconds: int = 300) -> bool:
"""Verify Paddle webhook signature (ts=<unix>;h1=<hmac_sha256>)."""
parts = {}
for kv in signature_header.split(";"):
if "=" in kv:
k, v = kv.split("=", 1)
parts[k] = v
class _WebhookRequest:
"""Minimal wrapper satisfying paddle_billing's Request Protocol."""
def __init__(self, body: bytes, headers):
self.body = body
self.headers = headers
ts = parts.get("ts", "")
h1 = parts.get("h1", "")
if not ts or not h1:
return False
if abs(time.time() - int(ts)) > max_drift_seconds:
return False
expected = hmac.new(
secret.encode(), f"{ts}:{payload.decode()}".encode(), hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, h1)
_verifier = Verifier(maximum_variance=300)
@bp.route("/webhook/paddle", methods=["POST"])
async def webhook():
"""Handle Paddle webhooks."""
payload = await request.get_data()
sig = request.headers.get("Paddle-Signature", "")
if config.PADDLE_WEBHOOK_SECRET:
if not _verify_paddle_signature(payload, sig, config.PADDLE_WEBHOOK_SECRET):
try:
ok = _verifier.verify(
_WebhookRequest(payload, request.headers),
Secret(config.PADDLE_WEBHOOK_SECRET),
)
except (ConnectionRefusedError, ValueError):
ok = False
if not ok:
return jsonify({"error": "Invalid signature"}), 400
try:
@@ -250,29 +241,29 @@ async def webhook():
except (json.JSONDecodeError, ValueError):
return jsonify({"error": "Invalid JSON payload"}), 400
event_type = event.get("event_type")
data = event.get("data", {})
custom_data = data.get("custom_data", {})
data = event.get("data") or {}
custom_data = data.get("custom_data") or {}
user_id = custom_data.get("user_id")
plan = custom_data.get("plan", "")
if event_type == "subscription.activated":
if plan.startswith("supplier_"):
await _handle_supplier_subscription_activated(data, custom_data)
else:
elif user_id:
await upsert_subscription(
user_id=int(user_id) if user_id else 0,
user_id=int(user_id),
plan=plan or "starter",
status="active",
provider_customer_id=str(data.get("customer_id", "")),
provider_subscription_id=data.get("id", ""),
current_period_end=data.get("current_billing_period", {}).get("ends_at"),
current_period_end=(data.get("current_billing_period") or {}).get("ends_at"),
)
elif event_type == "subscription.updated":
await update_subscription_status(
data.get("id", ""),
status=data.get("status", "active"),
current_period_end=data.get("current_billing_period", {}).get("ends_at"),
current_period_end=(data.get("current_billing_period") or {}).get("ends_at"),
)
elif event_type == "subscription.canceled":
@@ -305,7 +296,7 @@ BOOST_PRICE_KEYS = {
"boost_logo": "logo",
"boost_highlight": "highlight",
"boost_verified": "verified",
"boost_newsletter": "newsletter",
"boost_card_color": "card_color",
"boost_sticky_week": "sticky_week",
"boost_sticky_month": "sticky_month",
}
@@ -400,30 +391,34 @@ async def _handle_transaction_completed(data: dict, custom_data: dict) -> None:
# Sticky boost purchases
elif key == "boost_sticky_week" and supplier_id:
from datetime import timedelta
from ..core import transaction as db_transaction
expires = (datetime.utcnow() + timedelta(weeks=1)).isoformat()
country = custom_data.get("sticky_country", "")
await execute(
async with db_transaction() as db:
await db.execute(
"""INSERT INTO supplier_boosts
(supplier_id, boost_type, status, starts_at, expires_at, created_at)
VALUES (?, 'sticky_week', 'active', ?, ?, ?)""",
(int(supplier_id), now, expires, now),
)
await execute(
await db.execute(
"UPDATE suppliers SET sticky_until = ?, sticky_country = ? WHERE id = ?",
(expires, country, int(supplier_id)),
)
elif key == "boost_sticky_month" and supplier_id:
from datetime import timedelta
from ..core import transaction as db_transaction
expires = (datetime.utcnow() + timedelta(days=30)).isoformat()
country = custom_data.get("sticky_country", "")
await execute(
async with db_transaction() as db:
await db.execute(
"""INSERT INTO supplier_boosts
(supplier_id, boost_type, status, starts_at, expires_at, created_at)
VALUES (?, 'sticky_month', 'active', ?, ?, ?)""",
(int(supplier_id), now, expires, now),
)
await execute(
await db.execute(
"UPDATE suppliers SET sticky_until = ?, sticky_country = ? WHERE id = ?",
(expires, country, int(supplier_id)),
)

View File

@@ -78,6 +78,72 @@ class TestWebhookSignature:
)
assert response.status_code == 400
async def test_null_custom_data_does_not_crash(self, client, db):
"""Paddle sends null custom_data on lifecycle events like subscription.updated."""
payload = {
"event_type": "subscription.updated",
"data": {
"id": "sub_test456",
"status": "active",
"customer_id": "ctm_test123",
"custom_data": None,
"current_billing_period": {
"starts_at": "2025-02-01T00:00:00.000000Z",
"ends_at": "2025-03-01T00:00:00.000000Z",
},
},
}
payload_bytes = json.dumps(payload).encode()
sig = sign_payload(payload_bytes)
response = await client.post(
WEBHOOK_PATH,
data=payload_bytes,
headers={SIG_HEADER: sig, "Content-Type": "application/json"},
)
assert response.status_code == 200
async def test_null_custom_data_activated_does_not_crash(self, client, db):
"""subscription.activated with null custom_data must not FK-violate on user_id=0."""
payload = {
"event_type": "subscription.activated",
"data": {
"id": "sub_test456",
"status": "active",
"customer_id": "ctm_test123",
"custom_data": None,
"current_billing_period": {
"starts_at": "2025-02-01T00:00:00.000000Z",
"ends_at": "2025-03-01T00:00:00.000000Z",
},
},
}
payload_bytes = json.dumps(payload).encode()
sig = sign_payload(payload_bytes)
response = await client.post(
WEBHOOK_PATH,
data=payload_bytes,
headers={SIG_HEADER: sig, "Content-Type": "application/json"},
)
assert response.status_code == 200
async def test_null_data_does_not_crash(self, client, db):
"""Guard against data being null in the event payload."""
payload = {
"event_type": "subscription.updated",
"data": None,
}
payload_bytes = json.dumps(payload).encode()
sig = sign_payload(payload_bytes)
response = await client.post(
WEBHOOK_PATH,
data=payload_bytes,
headers={SIG_HEADER: sig, "Content-Type": "application/json"},
)
assert response.status_code == 200
# ════════════════════════════════════════════════════════════
# Subscription Lifecycle Events