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:
12
CHANGELOG.md
12
CHANGELOG.md
@@ -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`,
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user