diff --git a/skills/paddle-webhooks/SKILL.md b/skills/paddle-webhooks/SKILL.md index cf03491..ac04b77 100644 --- a/skills/paddle-webhooks/SKILL.md +++ b/skills/paddle-webhooks/SKILL.md @@ -20,136 +20,54 @@ metadata: - Understanding Paddle event types and payloads - Handling subscription, transaction, or customer events -## Essential Code (USE THIS) +## Verification (core) -### Express Webhook Handler +Paddle signs every webhook with HMAC-SHA256 over `timestamp:rawBody`. The `Paddle-Signature` header is `ts=;h1=` (multiple `h1=` values appear during secret rotation). Pass the **raw** request body — don't `JSON.parse` first. + +The official `@paddle/paddle-node-sdk` exposes `paddle.webhooks.unmarshal(rawBody, secretKey, signature)` which verifies and parses in one call. For Python (or when not using the SDK), verify manually: + +Node: ```javascript -const express = require('express'); const crypto = require('crypto'); -const app = express(); - -// CRITICAL: Use express.raw() for webhook endpoint - Paddle needs raw body -app.post('/webhooks/paddle', - express.raw({ type: 'application/json' }), - async (req, res) => { - const signature = req.headers['paddle-signature']; - - if (!signature) { - return res.status(400).send('Missing Paddle-Signature header'); - } - - // Verify signature - const isValid = verifyPaddleSignature( - req.body.toString(), - signature, - process.env.PADDLE_WEBHOOK_SECRET // From Paddle dashboard - ); - - if (!isValid) { - console.error('Paddle signature verification failed'); - return res.status(400).send('Invalid signature'); - } - - const event = JSON.parse(req.body.toString()); - - // Handle the event - switch (event.event_type) { - case 'subscription.created': - console.log('Subscription created:', event.data.id); - break; - case 'subscription.canceled': - console.log('Subscription canceled:', event.data.id); - break; - case 'transaction.completed': - console.log('Transaction completed:', event.data.id); - break; - default: - console.log('Unhandled event:', event.event_type); - } - - // IMPORTANT: Respond within 5 seconds - res.json({ received: true }); - } -); - -function verifyPaddleSignature(payload, signature, secret) { - const parts = signature.split(';'); +function verifyPaddleSignature(rawBody, signatureHeader, secret) { + const parts = signatureHeader.split(';'); const ts = parts.find(p => p.startsWith('ts='))?.slice(3); - const signatures = parts - .filter(p => p.startsWith('h1=')) - .map(p => p.slice(3)); - - if (!ts || signatures.length === 0) { - return false; - } + const signatures = parts.filter(p => p.startsWith('h1=')).map(p => p.slice(3)); + if (!ts || signatures.length === 0) return false; - const signedPayload = `${ts}:${payload}`; - const expectedSignature = crypto + const expected = crypto .createHmac('sha256', secret) - .update(signedPayload) + .update(`${ts}:${rawBody}`) .digest('hex'); - // Check if any signature matches (handles secret rotation) return signatures.some(sig => - crypto.timingSafeEqual( - Buffer.from(sig), - Buffer.from(expectedSignature) - ) + crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected)) ); } ``` -### Python (FastAPI) Webhook Handler +Python: ```python -import hmac -import hashlib -from fastapi import FastAPI, Request, HTTPException - -app = FastAPI() -webhook_secret = os.environ.get("PADDLE_WEBHOOK_SECRET") - -@app.post("/webhooks/paddle") -async def paddle_webhook(request: Request): - payload = await request.body() - signature = request.headers.get("paddle-signature") - - if not signature: - raise HTTPException(status_code=400, detail="Missing signature") - - if not verify_paddle_signature(payload.decode(), signature, webhook_secret): - raise HTTPException(status_code=400, detail="Invalid signature") - - event = await request.json() - # Handle event... - return {"received": True} - -def verify_paddle_signature(payload, signature, secret): - parts = signature.split(';') - timestamp = None - signatures = [] - - for part in parts: - if part.startswith('ts='): - timestamp = part[3:] - elif part.startswith('h1='): - signatures.append(part[3:]) - - if not timestamp or not signatures: +import hmac, hashlib + +def verify_paddle_signature(raw_body: str, signature_header: str, secret: str) -> bool: + parts = signature_header.split(';') + ts = next((p[3:] for p in parts if p.startswith('ts=')), None) + signatures = [p[3:] for p in parts if p.startswith('h1=')] + if not ts or not signatures: return False - signed_payload = f"{timestamp}:{payload}" expected = hmac.new( - secret.encode(), signed_payload.encode(), hashlib.sha256 + secret.encode(), f"{ts}:{raw_body}".encode(), hashlib.sha256 ).hexdigest() - # Check if any signature matches (handles secret rotation) return any(hmac.compare_digest(sig, expected) for sig in signatures) ``` -> **For complete working examples with tests**, see: +> **For complete handlers with route wiring, event dispatch, and tests**, see: > - [examples/express/](examples/express/) - Full Express implementation > - [examples/nextjs/](examples/nextjs/) - Next.js App Router implementation > - [examples/fastapi/](examples/fastapi/) - Python FastAPI implementation @@ -179,8 +97,6 @@ PADDLE_WEBHOOK_SECRET=pdl_ntfset_xxxxx_xxxxx # From notification destination s ## Local Development ```bash -# Or via NPM - # Start tunnel (no account needed) npx hookdeck-cli listen 3000 paddle --path /webhooks/paddle ``` diff --git a/skills/paddle-webhooks/examples/express/package.json b/skills/paddle-webhooks/examples/express/package.json index f8b0a6f..db6bfc3 100644 --- a/skills/paddle-webhooks/examples/express/package.json +++ b/skills/paddle-webhooks/examples/express/package.json @@ -8,12 +8,12 @@ "test": "jest" }, "dependencies": { - "@paddle/paddle-node-sdk": "^1.4.0", + "@paddle/paddle-node-sdk": "^3.8.0", "dotenv": "^16.3.0", "express": "^5.2.1" }, "devDependencies": { "jest": "^30.2.0", - "supertest": "^6.3.0" + "supertest": "^7.0.0" } } diff --git a/skills/paddle-webhooks/examples/fastapi/main.py b/skills/paddle-webhooks/examples/fastapi/main.py index dce9f21..3b48ab7 100644 --- a/skills/paddle-webhooks/examples/fastapi/main.py +++ b/skills/paddle-webhooks/examples/fastapi/main.py @@ -10,14 +10,8 @@ webhook_secret = os.environ.get("PADDLE_WEBHOOK_SECRET") -# Initialize Paddle SDK Verifier if available -# The Python SDK uses a Verifier class for webhook signature verification -verifier = None -try: - from paddle_billing.Notifications import Secret, Verifier - verifier = Verifier() -except ImportError: - pass # SDK not installed, use manual verification +# Note: paddle-python-sdk's Verifier targets Flask/Django request objects, +# so FastAPI users should verify manually. def verify_paddle_signature(payload: str, signature_header: str, secret: str) -> bool: @@ -75,33 +69,9 @@ async def paddle_webhook(request: Request): if not signature_header: raise HTTPException(status_code=400, detail="Missing Paddle-Signature header") - # Option 1: Verify using Paddle SDK (recommended if SDK is available) - # The Python SDK uses Verifier().verify(request, Secret(secret)) pattern - if verifier and webhook_secret: - try: - # Import Secret for this verification - from paddle_billing.Notifications import Secret - # The SDK's verify() method accepts a request-like object and returns bool - # Note: For FastAPI, we need to create a compatible request object - # Since Verifier expects specific request attributes, we use manual verification - # as the more reliable option for FastAPI - is_valid = verify_paddle_signature(payload_str, signature_header, webhook_secret) - if not is_valid: - print("Webhook signature verification failed") - raise HTTPException(status_code=400, detail="Invalid signature") - print("Webhook verified using manual verification (SDK available but FastAPI requires manual)") - except ImportError: - # Fallback to manual if import fails - if not verify_paddle_signature(payload_str, signature_header, webhook_secret): - print("Manual webhook signature verification failed") - raise HTTPException(status_code=400, detail="Invalid signature") - print("Webhook verified using manual verification") - else: - # Option 2: Manual verification (when SDK is not available) - if not verify_paddle_signature(payload_str, signature_header, webhook_secret): - print("Manual webhook signature verification failed") - raise HTTPException(status_code=400, detail="Invalid signature") - print("Webhook verified using manual verification") + if not verify_paddle_signature(payload_str, signature_header, webhook_secret): + print("Webhook signature verification failed") + raise HTTPException(status_code=400, detail="Invalid signature") # Parse the event try: diff --git a/skills/paddle-webhooks/examples/fastapi/requirements.txt b/skills/paddle-webhooks/examples/fastapi/requirements.txt index 92153f4..a9e0ef5 100644 --- a/skills/paddle-webhooks/examples/fastapi/requirements.txt +++ b/skills/paddle-webhooks/examples/fastapi/requirements.txt @@ -1,6 +1,6 @@ -fastapi>=0.128.0 +fastapi>=0.136.1 uvicorn>=0.23.0 python-dotenv>=1.0.0 -paddle-python-sdk>=1.0.0 -pytest>=7.4.0 -httpx>=0.25.0 +paddle-python-sdk>=1.14.1 +pytest>=9.0.3 +httpx>=0.28.1 diff --git a/skills/paddle-webhooks/examples/nextjs/package.json b/skills/paddle-webhooks/examples/nextjs/package.json index a2a7156..f57d5e0 100644 --- a/skills/paddle-webhooks/examples/nextjs/package.json +++ b/skills/paddle-webhooks/examples/nextjs/package.json @@ -9,15 +9,15 @@ "test": "vitest run" }, "dependencies": { - "@paddle/paddle-node-sdk": "^1.4.0", - "next": "^16.1.6", + "@paddle/paddle-node-sdk": "^3.8.0", + "next": "^16.2.6", "react": "^18.2.0", "react-dom": "^18.2.0" }, "devDependencies": { "@types/node": "^20.0.0", "@types/react": "^18.2.0", - "typescript": "^5.9.3", + "typescript": "^6.0.3", "vitest": "^4.0.18" } } diff --git a/skills/paddle-webhooks/references/verification.md b/skills/paddle-webhooks/references/verification.md index 9cc71f9..3fac54c 100644 --- a/skills/paddle-webhooks/references/verification.md +++ b/skills/paddle-webhooks/references/verification.md @@ -20,7 +20,7 @@ The `h1` signature is the current version. There may be multiple `h1` signatures The official Paddle SDKs handle signature verification automatically: -**Node.js (`@paddle/paddle-node-sdk` v3.5.0+):** +**Node.js (`@paddle/paddle-node-sdk` v1.4.0+):** ```javascript import { Paddle, EventName } from "@paddle/paddle-node-sdk"; @@ -47,7 +47,7 @@ app.post('/webhooks/paddle', express.raw({ type: 'application/json' }), async (r }); ``` -**Python (`paddle-billing` v1.13.0+):** +**Python (`paddle-python-sdk` v1.14.0+, imported as `paddle_billing`):** The Python SDK uses a `Verifier` class for webhook signature verification. It supports Flask and Django natively: @@ -179,19 +179,7 @@ const signedPayload = `${timestamp}:${payload}`; const signedPayload = `${timestamp}.${payload}`; ``` -### 4. Replay Protection - -To prevent replay attacks, check the timestamp (`ts`) against the current time and reject events that are too old. The recommended tolerance is 5 seconds. - -```javascript -function isTimestampValid(timestamp, toleranceSeconds = 5) { - const now = Math.floor(Date.now() / 1000); - const ts = parseInt(timestamp, 10); - return Math.abs(now - ts) <= toleranceSeconds; // Compare the difference in seconds to the tolerance -} -``` - -### 5. Response Time +### 4. Response Time Paddle requires a response within **5 seconds**. Respond before doing any processing, then handle the event asynchronously.