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] ## [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 ### Added
- **Credit system test suite** (`tests/test_credits.py` — 24 tests) — covers - **Credit system test suite** (`tests/test_credits.py` — 24 tests) — covers
`get_balance`, `add_credits`, `spend_credits`, `compute_credit_cost`, `get_balance`, `add_credits`, `spend_credits`, `compute_credit_cost`,

View File

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

View File

@@ -78,6 +78,72 @@ class TestWebhookSignature:
) )
assert response.status_code == 400 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 # Subscription Lifecycle Events