Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 31 additions & 8 deletions skills/paddle-webhooks/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,18 +76,27 @@ 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
.createHmac('sha256', 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)
)
);
}
```
Expand Down Expand Up @@ -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:
Expand Down
20 changes: 20 additions & 0 deletions skills/paddle-webhooks/TODO.md
Original file line number Diff line number Diff line change
@@ -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`

5 changes: 3 additions & 2 deletions skills/paddle-webhooks/examples/express/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
61 changes: 44 additions & 17 deletions skills/paddle-webhooks/examples/express/src/index.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}`);
}
Expand Down
48 changes: 44 additions & 4 deletions skills/paddle-webhooks/examples/fastapi/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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}")

Expand Down
3 changes: 2 additions & 1 deletion skills/paddle-webhooks/examples/fastapi/requirements.txt
Original file line number Diff line number Diff line change
@@ -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
81 changes: 58 additions & 23 deletions skills/paddle-webhooks/examples/nextjs/app/webhooks/paddle/route.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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: {
Expand All @@ -79,14 +69,49 @@ export async function POST(request: NextRequest) {
data: Record<string, unknown>;
};

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<string, unknown>,
};
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
Expand Down Expand Up @@ -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}`);
}
Expand Down
7 changes: 4 additions & 3 deletions skills/paddle-webhooks/examples/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Loading