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
130 changes: 23 additions & 107 deletions skills/paddle-webhooks/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<unix>;h1=<hex>` (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
Expand Down Expand Up @@ -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
```
Expand Down
4 changes: 2 additions & 2 deletions skills/paddle-webhooks/examples/express/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
40 changes: 5 additions & 35 deletions skills/paddle-webhooks/examples/fastapi/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 4 additions & 4 deletions skills/paddle-webhooks/examples/fastapi/requirements.txt
Original file line number Diff line number Diff line change
@@ -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
6 changes: 3 additions & 3 deletions skills/paddle-webhooks/examples/nextjs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
18 changes: 3 additions & 15 deletions skills/paddle-webhooks/references/verification.md
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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:

Expand Down Expand Up @@ -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.

Expand Down
Loading