diff --git a/skills/paddle-webhooks/SKILL.md b/skills/paddle-webhooks/SKILL.md index fdf7c4d..0d5acb6 100644 --- a/skills/paddle-webhooks/SKILL.md +++ b/skills/paddle-webhooks/SKILL.md @@ -76,8 +76,14 @@ app.post('/webhooks/paddle', function verifyPaddleSignature(payload, signature, secret) { const parts = signature.split(';'); - const ts = parts.find(p => p.startsWith('ts=')).slice(3); - const h1 = parts.find(p => p.startsWith('h1=')).slice(3); + 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 signedPayload = `${ts}:${payload}`; const expectedSignature = crypto @@ -85,9 +91,12 @@ function verifyPaddleSignature(payload, signature, secret) { .update(signedPayload) .digest('hex'); - return crypto.timingSafeEqual( - Buffer.from(h1), - Buffer.from(expectedSignature) + // Check if any signature matches (handles secret rotation) + return signatures.some(sig => + crypto.timingSafeEqual( + Buffer.from(sig), + Buffer.from(expectedSignature) + ) ); } ``` @@ -118,12 +127,26 @@ async def paddle_webhook(request: Request): return {"received": True} def verify_paddle_signature(payload, signature, secret): - parts = dict(p.split('=') for p in signature.split(';')) - signed_payload = f"{parts['ts']}:{payload}" + 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: + return False + + signed_payload = f"{timestamp}:{payload}" expected = hmac.new( secret.encode(), signed_payload.encode(), hashlib.sha256 ).hexdigest() - return hmac.compare_digest(parts['h1'], expected) + + # 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: diff --git a/skills/paddle-webhooks/TODO.md b/skills/paddle-webhooks/TODO.md new file mode 100644 index 0000000..9de9fd2 --- /dev/null +++ b/skills/paddle-webhooks/TODO.md @@ -0,0 +1,20 @@ +# TODO - Known Issues and Improvements + +*Last updated: 2026-02-04* + +These items were identified during review. Most SDK-related issues have been fixed. + +## Resolved Issues (2026-02-04) + +- [x] **SDK verification method**: Fixed to use correct `paddle.webhooks.unmarshal(body, secret, signature)` for Node.js +- [x] **Python SDK pattern**: Updated to use `Verifier().verify(request, Secret(...))` pattern (note: FastAPI uses manual verification) +- [x] **Parameter order**: Fixed Node.js examples to use correct order `(body, secretKey, signature)` +- [x] **Documentation**: Updated verification.md with correct SDK examples + +## Remaining Items + +### Minor + +- [ ] **FastAPI SDK support**: The Python SDK's `Verifier` class is designed for Flask/Django. Consider adding native FastAPI support in future. +- [ ] **Version constraints**: Consider tightening FastAPI version constraint from `>=0.100.0` to `>=0.128.0` + diff --git a/skills/paddle-webhooks/examples/express/package.json b/skills/paddle-webhooks/examples/express/package.json index bb53fab..f8b0a6f 100644 --- a/skills/paddle-webhooks/examples/express/package.json +++ b/skills/paddle-webhooks/examples/express/package.json @@ -8,11 +8,12 @@ "test": "jest" }, "dependencies": { + "@paddle/paddle-node-sdk": "^1.4.0", "dotenv": "^16.3.0", - "express": "^4.18.0" + "express": "^5.2.1" }, "devDependencies": { - "jest": "^29.7.0", + "jest": "^30.2.0", "supertest": "^6.3.0" } } diff --git a/skills/paddle-webhooks/examples/express/src/index.js b/skills/paddle-webhooks/examples/express/src/index.js index b0fc3fb..9c60fc6 100644 --- a/skills/paddle-webhooks/examples/express/src/index.js +++ b/skills/paddle-webhooks/examples/express/src/index.js @@ -1,9 +1,13 @@ require('dotenv').config(); const express = require('express'); const crypto = require('crypto'); +const { Paddle } = require('@paddle/paddle-node-sdk'); const app = express(); +// Initialize Paddle SDK if API key is available +const paddle = process.env.PADDLE_API_KEY ? new Paddle(process.env.PADDLE_API_KEY) : null; + /** * Verify Paddle webhook signature * @param {string} payload - Raw request body as string @@ -60,26 +64,37 @@ app.post('/webhooks/paddle', async (req, res) => { const signatureHeader = req.headers['paddle-signature']; const payload = req.body.toString(); + const secret = process.env.PADDLE_WEBHOOK_SECRET; - // Verify the webhook signature - const isValid = verifyPaddleSignature( - payload, - signatureHeader, - process.env.PADDLE_WEBHOOK_SECRET - ); + // Option 1: Verify using Paddle SDK (recommended if you have SDK initialized) + let event; + if (paddle) { + try { + // The SDK handles verification and parsing in one step + // Method signature: paddle.webhooks.unmarshal(requestBody, secretKey, signature) + event = await paddle.webhooks.unmarshal(payload, secret, signatureHeader); + console.log('Webhook verified using Paddle SDK'); + } catch (err) { + console.error('SDK webhook verification failed:', err.message); + return res.status(400).send('Invalid signature'); + } + } else { + // Option 2: Manual verification (when SDK is not available) + const isValid = verifyPaddleSignature(payload, signatureHeader, secret); - if (!isValid) { - console.error('Webhook signature verification failed'); - return res.status(400).send('Invalid signature'); - } + if (!isValid) { + console.error('Manual webhook signature verification failed'); + return res.status(400).send('Invalid signature'); + } - // Parse the event - let event; - try { - event = JSON.parse(payload); - } catch (err) { - console.error('Failed to parse webhook payload:', err); - return res.status(400).send('Invalid JSON'); + // Parse the event + try { + event = JSON.parse(payload); + console.log('Webhook verified using manual verification'); + } catch (err) { + console.error('Failed to parse webhook payload:', err); + return res.status(400).send('Invalid JSON'); + } } // Handle the event based on type @@ -126,6 +141,18 @@ app.post('/webhooks/paddle', // TODO: Notify customer, update status, etc. break; + case 'customer.created': + const newCustomer = event.data; + console.log('Customer created:', newCustomer.id); + // TODO: Create customer record, send welcome email, etc. + break; + + case 'customer.updated': + const updatedCustomer = event.data; + console.log('Customer updated:', updatedCustomer.id); + // TODO: Update customer record, sync changes, etc. + break; + default: console.log(`Unhandled event type: ${event.event_type}`); } diff --git a/skills/paddle-webhooks/examples/fastapi/main.py b/skills/paddle-webhooks/examples/fastapi/main.py index 020fae1..dce9f21 100644 --- a/skills/paddle-webhooks/examples/fastapi/main.py +++ b/skills/paddle-webhooks/examples/fastapi/main.py @@ -10,6 +10,15 @@ 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 + def verify_paddle_signature(payload: str, signature_header: str, secret: str) -> bool: """ @@ -66,10 +75,33 @@ async def paddle_webhook(request: Request): if not signature_header: raise HTTPException(status_code=400, detail="Missing Paddle-Signature header") - # Verify the webhook signature - if not verify_paddle_signature(payload_str, signature_header, webhook_secret): - print("Webhook signature verification failed") - raise HTTPException(status_code=400, detail="Invalid signature") + # 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") # Parse the event try: @@ -110,6 +142,14 @@ async def paddle_webhook(request: Request): print(f"Transaction payment failed: {data.get('id')}") # TODO: Notify customer, update status, etc. + elif event_type == "customer.created": + print(f"Customer created: {data.get('id')}") + # TODO: Create customer record, send welcome email, etc. + + elif event_type == "customer.updated": + print(f"Customer updated: {data.get('id')}") + # TODO: Update customer record, sync changes, etc. + else: print(f"Unhandled event type: {event_type}") diff --git a/skills/paddle-webhooks/examples/fastapi/requirements.txt b/skills/paddle-webhooks/examples/fastapi/requirements.txt index 0fe80df..92153f4 100644 --- a/skills/paddle-webhooks/examples/fastapi/requirements.txt +++ b/skills/paddle-webhooks/examples/fastapi/requirements.txt @@ -1,5 +1,6 @@ -fastapi>=0.100.0 +fastapi>=0.128.0 uvicorn>=0.23.0 python-dotenv>=1.0.0 +paddle-python-sdk>=1.0.0 pytest>=7.4.0 httpx>=0.25.0 diff --git a/skills/paddle-webhooks/examples/nextjs/app/webhooks/paddle/route.ts b/skills/paddle-webhooks/examples/nextjs/app/webhooks/paddle/route.ts index bb66dbd..d0f1973 100644 --- a/skills/paddle-webhooks/examples/nextjs/app/webhooks/paddle/route.ts +++ b/skills/paddle-webhooks/examples/nextjs/app/webhooks/paddle/route.ts @@ -1,5 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; import crypto from 'crypto'; +import { Paddle } from '@paddle/paddle-node-sdk'; + +// Initialize Paddle SDK if API key is available +const paddle = process.env.PADDLE_API_KEY ? new Paddle(process.env.PADDLE_API_KEY) : null; /** * Verify Paddle webhook signature @@ -55,21 +59,7 @@ export async function POST(request: NextRequest) { // Get the raw body for signature verification const body = await request.text(); const signatureHeader = request.headers.get('paddle-signature'); - - // Verify the webhook signature - const isValid = verifyPaddleSignature( - body, - signatureHeader, - process.env.PADDLE_WEBHOOK_SECRET! - ); - - if (!isValid) { - console.error('Webhook signature verification failed'); - return NextResponse.json( - { error: 'Invalid signature' }, - { status: 400 } - ); - } + const secret = process.env.PADDLE_WEBHOOK_SECRET!; // Parse the event let event: { @@ -79,14 +69,49 @@ export async function POST(request: NextRequest) { data: Record; }; - try { - event = JSON.parse(body); - } catch (err) { - console.error('Failed to parse webhook payload:', err); - return NextResponse.json( - { error: 'Invalid JSON' }, - { status: 400 } - ); + // Option 1: Verify using Paddle SDK (recommended if you have SDK initialized) + if (paddle && signatureHeader) { + try { + // The SDK handles verification and parsing in one step + // Method signature: paddle.webhooks.unmarshal(requestBody, secretKey, signature) + const sdkEvent = await paddle.webhooks.unmarshal(body, secret, signatureHeader); + event = { + event_id: sdkEvent.eventId, + event_type: sdkEvent.eventType, + occurred_at: sdkEvent.occurredAt, + data: sdkEvent.data as Record, + }; + console.log('Webhook verified using Paddle SDK'); + } catch (err) { + console.error('SDK webhook verification failed:', err); + return NextResponse.json( + { error: 'Invalid signature' }, + { status: 400 } + ); + } + } else { + // Option 2: Manual verification (when SDK is not available) + const isValid = verifyPaddleSignature(body, signatureHeader, secret); + + if (!isValid) { + console.error('Manual webhook signature verification failed'); + return NextResponse.json( + { error: 'Invalid signature' }, + { status: 400 } + ); + } + + // Parse the event + try { + event = JSON.parse(body); + console.log('Webhook verified using manual verification'); + } catch (err) { + console.error('Failed to parse webhook payload:', err); + return NextResponse.json( + { error: 'Invalid JSON' }, + { status: 400 } + ); + } } // Handle the event based on type @@ -126,6 +151,16 @@ export async function POST(request: NextRequest) { // TODO: Notify customer, update status, etc. break; + case 'customer.created': + console.log('Customer created:', event.data.id); + // TODO: Create customer record, send welcome email, etc. + break; + + case 'customer.updated': + console.log('Customer updated:', event.data.id); + // TODO: Update customer record, sync changes, etc. + break; + default: console.log(`Unhandled event type: ${event.event_type}`); } diff --git a/skills/paddle-webhooks/examples/nextjs/package.json b/skills/paddle-webhooks/examples/nextjs/package.json index 443f8ff..a2a7156 100644 --- a/skills/paddle-webhooks/examples/nextjs/package.json +++ b/skills/paddle-webhooks/examples/nextjs/package.json @@ -9,14 +9,15 @@ "test": "vitest run" }, "dependencies": { - "next": "^14.0.0", + "@paddle/paddle-node-sdk": "^1.4.0", + "next": "^16.1.6", "react": "^18.2.0", "react-dom": "^18.2.0" }, "devDependencies": { "@types/node": "^20.0.0", "@types/react": "^18.2.0", - "typescript": "^5.0.0", - "vitest": "^1.0.0" + "typescript": "^5.9.3", + "vitest": "^4.0.18" } } diff --git a/skills/paddle-webhooks/references/verification.md b/skills/paddle-webhooks/references/verification.md index c0dfaa4..9cc71f9 100644 --- a/skills/paddle-webhooks/references/verification.md +++ b/skills/paddle-webhooks/references/verification.md @@ -20,53 +20,60 @@ The `h1` signature is the current version. There may be multiple `h1` signatures The official Paddle SDKs handle signature verification automatically: -**Node.js:** +**Node.js (`@paddle/paddle-node-sdk` v3.5.0+):** ```javascript -import { Environment, Paddle, EventName } from "@paddle/paddle-node-sdk"; +import { Paddle, EventName } from "@paddle/paddle-node-sdk"; const paddle = new Paddle(process.env.PADDLE_API_KEY); // Express middleware example -app.post('/webhooks/paddle', express.raw({ type: 'application/json' }), (req, res) => { +app.post('/webhooks/paddle', express.raw({ type: 'application/json' }), async (req, res) => { const signature = req.headers['paddle-signature']; const rawBody = req.body.toString(); - + const secretKey = process.env.PADDLE_WEBHOOK_SECRET; + try { - if (!paddle.webhooks.verify(rawBody, signature, process.env.PADDLE_WEBHOOK_SECRET)) { - return res.status(400).send('Invalid signature'); - } - - const event = paddle.webhooks.unmarshal(rawBody, signature, process.env.PADDLE_WEBHOOK_SECRET); - // Handle event... + // The SDK handles verification and parsing in one step + // Method signature: paddle.webhooks.unmarshal(requestBody, secretKey, signature) + const event = await paddle.webhooks.unmarshal(rawBody, secretKey, signature); + + // Handle event - note: SDK returns camelCase properties + console.log(`Received event: ${event.eventType}`); res.json({ received: true }); } catch (err) { - res.status(400).send(`Webhook Error: ${err.message}`); + console.error('Webhook verification failed:', err.message); + res.status(400).send('Invalid signature'); } }); ``` -**Python:** -```python -from paddle_billing import Client, Environment +**Python (`paddle-billing` v1.13.0+):** -paddle = Client(os.environ['PADDLE_API_KEY']) +The Python SDK uses a `Verifier` class for webhook signature verification. It supports Flask and Django natively: -@app.post("/webhooks/paddle") -async def paddle_webhook(request: Request): - payload = await request.body() - signature = request.headers.get("paddle-signature") +```python +from paddle_billing.Notifications import Secret, Verifier + +# Flask example +@app.route("/webhooks/paddle", methods=["POST"]) +def paddle_webhook(): + webhook_secret = os.environ['PADDLE_WEBHOOK_SECRET'] + + # The Verifier handles signature verification + # Method signature: Verifier().verify(request, Secret(secret)) + is_valid = Verifier().verify(request, Secret(webhook_secret)) + + if not is_valid: + return "Invalid signature", 400 - try: - if not paddle.notifications.verify(payload.decode(), signature, webhook_secret): - raise HTTPException(status_code=400, detail="Invalid signature") - - event = paddle.notifications.unmarshal(payload.decode(), signature, webhook_secret) - # Handle event... - return {"received": True} - except Exception as e: - raise HTTPException(status_code=400, detail=str(e)) + # Parse and handle event + event = request.get_json() + print(f"Received event: {event['event_type']}") + return {"received": True} ``` +> **Note for FastAPI users:** The Python SDK's `Verifier` is designed for Flask/Django request objects. For FastAPI, use manual verification (shown below) which is equally secure and more reliable across frameworks. + ### Manual Verification If you need to verify manually: @@ -106,9 +113,18 @@ import hashlib def verify_paddle_signature(payload: str, signature_header: str, secret: str) -> bool: # Parse the signature header - parts = dict(p.split('=', 1) for p in signature_header.split(';')) - timestamp = parts.get('ts') - signature = parts.get('h1') + parts = signature_header.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: + return False # Build the signed payload (timestamp:rawBody) signed_payload = f"{timestamp}:{payload}" @@ -120,8 +136,8 @@ def verify_paddle_signature(payload: str, signature_header: str, secret: str) -> hashlib.sha256 ).hexdigest() - # Use constant-time comparison - return hmac.compare_digest(signature, expected) + # Check if any signature matches (handles secret rotation) + return any(hmac.compare_digest(sig, expected) for sig in signatures) ``` ## Common Gotchas @@ -171,7 +187,7 @@ To prevent replay attacks, check the timestamp (`ts`) against the current time a function isTimestampValid(timestamp, toleranceSeconds = 5) { const now = Math.floor(Date.now() / 1000); const ts = parseInt(timestamp, 10); - return Math.abs(now - ts) <= toleranceSeconds; + return Math.abs(now - ts) <= toleranceSeconds; // Compare the difference in seconds to the tolerance } ```