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]
|
## [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`,
|
||||||
|
|||||||
@@ -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)),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user