Paddle Integration
Complete guide to integrate Paddle payment processing with Pulsora revenue tracking and attribution. Track subscriptions, one-time purchases, upgrades, downgrades, and churn with full visitor attribution.
Prerequisites
- Paddle Billing API credentials
- Pulsora public (
pub_) and secret (sec_) tokens @pulsora/corein your frontend@pulsora/revenuein your backend
Capture Metadata on the Frontend
import { Pulsora } from '@pulsora/core';
const pulsora = new Pulsora();
pulsora.init({ apiToken: 'pub_123' });
async function openCheckout(priceId: string) {
const fingerprint = await pulsora.getVisitorFingerprint();
const sessionId = pulsora.getSessionId();
Paddle.Checkout.open({
items: [{ priceId }],
customData: {
visitor_fingerprint: fingerprint,
session_id: sessionId,
},
customer: {
email: currentUser.email,
},
});
}
Attach openCheckout to your purchase buttons.
Handle Paddle Webhooks
Install the revenue SDK:
npm install @pulsora/revenue
Create a webhook endpoint:
import express from 'express';
import { Revenue } from '@pulsora/revenue';
const app = express();
app.use(express.json());
const revenue = new Revenue({
apiToken: process.env.PULSORA_SECRET!,
});
app.post('/webhooks/paddle', async (req, res) => {
const event = req.body;
if (event.event_type === 'transaction.completed') {
await revenue.track({
visitor_fingerprint: event.data.custom_data?.visitor_fingerprint,
session_id: event.data.custom_data?.session_id,
customer_id: event.data.customer_id,
amount: parseFloat(event.data.details.totals.total),
currency: event.data.currency_code,
transaction_id: event.data.id,
payment_processor: 'paddle',
event_type: event.data.status === 'refunded' ? 'refund' : 'purchase',
customer_subscription_id: event.data.subscription_id,
is_recurring: event.data.is_subscription,
metadata: {
product: event.data.product_id,
plan: event.data.items?.[0]?.price?.product?.name,
},
});
}
res.json({ success: true });
});
Paddle Signature Verification
Paddle signs webhook payloads. Verify signatures before processing:
import crypto from 'crypto';
function verifySignature(body: string, signature: string) {
const hmac = crypto.createHmac('sha256', process.env.PADDLE_WEBHOOK_SECRET!);
hmac.update(body, 'utf8');
return hmac.digest('hex') === signature;
}
app.post(
'/webhooks/paddle',
express.text({ type: '*/*' }),
async (req, res) => {
const signature = req.headers['paddle-signature'] as string;
if (!verifySignature(req.body, signature)) {
return res.status(400).send('Invalid signature');
}
const event = JSON.parse(req.body);
// ...same logic as above...
},
);
Subscription Lifecycle Events
Paddle emits multiple events—map them to Pulsora event types:
| Paddle Event | Pulsora Event | Notes |
|---|---|---|
transaction.completed (initial) |
subscription |
Set is_recurring: true and mrr_impact. |
transaction.completed (renewal) |
renewal |
Include the same customer_subscription_id. |
transaction.updated (upgrade) |
upgrade |
Populate mrr_impact with the delta. |
transaction.updated (downgrade) |
downgrade |
Use negative mrr_impact. |
transaction.refunded |
refund |
Pass the refunded amount. |
subscription.cancelled |
churn |
Send final mrr_impact as negative. |
The event.data.is_subscription flag helps differentiate one-off transactions.
Complete Webhook Handler (Next.js)
Here's a production-ready webhook handler for Next.js App Router:
// app/api/webhooks/paddle/route.ts
import { Revenue } from '@pulsora/revenue';
import crypto from 'crypto';
import { headers } from 'next/headers';
const revenue = new Revenue({
apiToken: process.env.PULSORA_SECRET_TOKEN!,
});
// Verify Paddle webhook signature
function verifyPaddleSignature(body: string, signature: string): boolean {
const hmac = crypto.createHmac('sha256', process.env.PADDLE_WEBHOOK_SECRET!);
hmac.update(body, 'utf8');
const computed = hmac.digest('hex');
return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(signature));
}
export async function POST(req: Request) {
const body = await req.text();
const headersList = headers();
const signature = headersList.get('paddle-signature');
// Verify signature
if (!signature || !verifyPaddleSignature(body, signature)) {
console.error('[Paddle Webhook] Invalid signature');
return Response.json({ error: 'Invalid signature' }, { status: 401 });
}
const event = JSON.parse(body);
console.log('[Paddle Webhook] Received:', event.event_type);
try {
switch (event.event_type) {
case 'transaction.completed': {
const data = event.data;
const customData = data.custom_data || {};
// Determine if this is a subscription or one-time purchase
const isSubscription = data.items?.some(
(item: any) => item.price?.billing_cycle !== null,
);
// Check if this is initial payment or renewal
const isRenewal =
data.billing_period !== null && !data.billing_period.is_first;
await revenue.track({
visitor_fingerprint: customData.visitor_fingerprint,
session_id: customData.session_id,
customer_id: data.customer_id,
customer_subscription_id: data.subscription_id,
amount: parseFloat(data.details.totals.total) / 100, // Paddle uses cents
currency: data.currency_code,
transaction_id: data.id,
payment_processor: 'paddle',
event_type: isRenewal
? 'renewal'
: isSubscription
? 'subscription'
: 'purchase',
mrr_impact: isRenewal
? 0
: isSubscription
? parseFloat(data.details.totals.total) / 100
: undefined,
is_recurring: isSubscription,
metadata: {
product_id: data.items?.[0]?.price?.product_id,
product_name: data.items?.[0]?.price?.name,
billing_cycle: data.items?.[0]?.price?.billing_cycle?.interval,
quantity: data.items?.[0]?.quantity,
},
});
console.log(
`[Paddle Webhook] Tracked ${isRenewal ? 'renewal' : isSubscription ? 'subscription' : 'purchase'}:`,
data.id,
);
break;
}
case 'subscription.updated': {
const data = event.data;
const customData = data.custom_data || {};
// Check if plan changed
const previousPrice =
parseFloat(
data.previous_attributes?.price?.unit_price?.amount || '0',
) / 100;
const currentPrice =
parseFloat(data.items?.[0]?.price?.unit_price?.amount || '0') / 100;
const priceDiff = currentPrice - previousPrice;
if (priceDiff !== 0) {
await revenue.track({
visitor_fingerprint: customData.visitor_fingerprint,
session_id: customData.session_id,
customer_id: data.customer_id,
customer_subscription_id: data.id,
amount: currentPrice,
currency: data.currency_code,
transaction_id: data.id,
payment_processor: 'paddle',
event_type: priceDiff > 0 ? 'upgrade' : 'downgrade',
mrr_impact: priceDiff,
is_recurring: true,
metadata: {
old_price: previousPrice,
new_price: currentPrice,
old_product:
data.previous_attributes?.items?.[0]?.price?.product_id,
new_product: data.items?.[0]?.price?.product_id,
},
});
console.log(
`[Paddle Webhook] Tracked ${priceDiff > 0 ? 'upgrade' : 'downgrade'}:`,
data.id,
);
}
break;
}
case 'subscription.cancelled':
case 'subscription.expired': {
const data = event.data;
const customData = data.custom_data || {};
const mrrLoss =
parseFloat(data.items?.[0]?.price?.unit_price?.amount || '0') / 100;
await revenue.track({
visitor_fingerprint: customData.visitor_fingerprint,
session_id: customData.session_id,
customer_id: data.customer_id,
customer_subscription_id: data.id,
amount: 0,
currency: data.currency_code,
transaction_id: data.id,
payment_processor: 'paddle',
event_type: 'churn',
mrr_impact: -mrrLoss,
metadata: {
cancellation_reason: data.cancellation_reason,
cancelled_at: data.cancelled_at,
scheduled_change: data.scheduled_change,
},
});
console.log('[Paddle Webhook] Tracked churn:', data.id);
break;
}
case 'transaction.payment_failed': {
const data = event.data;
// Track failed payments for churn prediction
console.warn('[Paddle Webhook] Payment failed:', {
customer_id: data.customer_id,
subscription_id: data.subscription_id,
amount: data.details.totals.total,
attempt: data.payment_attempt_number,
});
break;
}
}
return Response.json({ received: true });
} catch (error) {
console.error('[Paddle Webhook] Error processing event:', error);
// Return 500 so Paddle retries
return Response.json({ error: 'Processing failed' }, { status: 500 });
}
}
Frontend Integration Examples
React + Paddle.js
// components/CheckoutButton.tsx
'use client';
import { usePulsora } from '@pulsora/react';
import { useEffect, useState } from 'react';
declare global {
interface Window {
Paddle: any;
}
}
export function CheckoutButton({
priceId,
productName,
}: {
priceId: string;
productName: string;
}) {
const pulsora = usePulsora();
const [paddleLoaded, setPaddleLoaded] = useState(false);
useEffect(() => {
// Load Paddle.js
if (!window.Paddle) {
const script = document.createElement('script');
script.src = 'https://cdn.paddle.com/paddle/v2/paddle.js';
script.onload = () => {
window.Paddle.Initialize({
token: process.env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN!,
environment:
process.env.NODE_ENV === 'production' ? 'production' : 'sandbox',
});
setPaddleLoaded(true);
};
document.body.appendChild(script);
} else {
setPaddleLoaded(true);
}
}, []);
const handleCheckout = async () => {
if (!paddleLoaded) {
console.error('Paddle not loaded yet');
return;
}
// Get Pulsora tracking data
const fingerprint = await pulsora.getVisitorFingerprint();
const sessionId = pulsora.getSessionId();
// Track checkout started
await pulsora.event('checkout_started', {
product: productName,
price_id: priceId,
});
// Open Paddle checkout with metadata
window.Paddle.Checkout.open({
items: [{ priceId, quantity: 1 }],
customData: {
visitor_fingerprint: fingerprint,
session_id: sessionId,
},
settings: {
successUrl: `${window.location.origin}/success?session_id=${sessionId}`,
closeUrl: window.location.href,
},
});
};
return (
<button
onClick={handleCheckout}
disabled={!paddleLoaded}
className="rounded-lg bg-blue-600 px-6 py-3 text-white hover:bg-blue-700 disabled:opacity-50"
>
{paddleLoaded ? 'Subscribe Now' : 'Loading...'}
</button>
);
}
Vanilla JavaScript
<!DOCTYPE html>
<html>
<head>
<title>Paddle Checkout</title>
<script src="https://cdn.paddle.com/paddle/v2/paddle.js"></script>
<script
src="https://cdn.pulsora.co/v1/pulsora.min.js"
data-token="pub_xxx"
></script>
</head>
<body>
<button id="checkout-btn">Subscribe - $29/month</button>
<script>
// Initialize Paddle
Paddle.Initialize({
token: 'YOUR_PADDLE_CLIENT_TOKEN',
environment: 'sandbox', // or 'production'
});
document
.getElementById('checkout-btn')
.addEventListener('click', async () => {
// Get Pulsora tracking data
const fingerprint = await window.pulsora.getVisitorFingerprint();
const sessionId = window.pulsora.getSessionId();
// Track checkout started
window.pulsora.event('checkout_started', {
plan: 'pro-monthly',
});
// Open checkout with metadata
Paddle.Checkout.open({
items: [{ priceId: 'pri_01h1234567890abcdef' }],
customData: {
visitor_fingerprint: fingerprint,
session_id: sessionId,
},
});
});
</script>
</body>
</html>
Configuration & Setup
1. Paddle Webhook Setup
- Log into Paddle Dashboard
- Go to Developer Tools → Notifications
- Add webhook endpoint:
https://yourdomain.com/api/webhooks/paddle - Select events to receive:
transaction.completedtransaction.updatedsubscription.updatedsubscription.cancelledsubscription.expiredtransaction.payment_failed
- Copy webhook secret for signature verification
2. Environment Variables
# .env.local
NEXT_PUBLIC_PADDLE_CLIENT_TOKEN=your_paddle_client_token
PADDLE_WEBHOOK_SECRET=your_paddle_webhook_secret
PULSORA_SECRET_TOKEN=sec_your_secret_token
3. Test Mode
Test your integration using Paddle Sandbox:
// Use sandbox for development
Paddle.Initialize({
token: process.env.NEXT_PUBLIC_PADDLE_CLIENT_TOKEN!,
environment: process.env.NODE_ENV === 'production' ? 'production' : 'sandbox',
});
Troubleshooting
Custom Data Not Received in Webhook
Symptoms:
- Webhook fires successfully
custom_dataisnullor missing fingerprint/session
Solutions:
- Verify custom data is set:
console.log('Opening checkout with custom data:', {
visitor_fingerprint: fingerprint,
session_id: sessionId,
});
Paddle.Checkout.open({
items: [{ priceId: 'pri_xxx' }],
customData: {
// Must be 'customData', not 'custom_data'
visitor_fingerprint: fingerprint,
session_id: sessionId,
},
});
- Check Paddle dashboard:
- Go to Customers → Transactions
- Click on transaction → View custom data
- Verify fingerprint and session_id are present
Signature Verification Fails
Symptoms:
- Webhook returns 401 "Invalid signature"
- Paddle keeps retrying webhook
Solutions:
- Verify webhook secret:
console.log('Webhook secret configured:', !!process.env.PADDLE_WEBHOOK_SECRET);
- Use raw body for verification:
// ❌ Wrong - JSON parsed body
app.use(express.json());
// ✅ Correct - raw text body for signature verification
app.post(
'/webhooks/paddle',
express.text({ type: '*/*' }),
async (req, res) => {
const signature = req.headers['paddle-signature'];
const isValid = verifyPaddleSignature(req.body, signature); // req.body is string
if (!isValid) {
return res.status(401).json({ error: 'Invalid signature' });
}
const event = JSON.parse(req.body); // Parse after verification
// Handle event...
},
);
- Test webhook signature:
# Use Paddle CLI to test
paddle webhook test --endpoint https://yourdomain.com/api/webhooks/paddle
MRR Calculations Incorrect
Issue: MRR dashboard doesn't match expectations
Common mistakes:
// ❌ Wrong - setting mrr_impact for renewals
if (event.event_type === 'transaction.completed' && !isFirst) {
mrr_impact: amount; // Don't set for renewals!
}
// ✅ Correct - only set mrr_impact for new subs, upgrades, downgrades, churn
if (event.event_type === 'transaction.completed' && isFirst) {
mrr_impact: amount; // Only for initial subscription
}
// For upgrades/downgrades
if (event.event_type === 'subscription.updated') {
mrr_impact: priceDiff; // Can be positive or negative
}
// For churn
if (event.event_type === 'subscription.cancelled') {
mrr_impact: -previousAmount; // Negative to reduce MRR
}
Duplicate Events
Issue: Same transaction tracked multiple times
Prevention:
Pulsora automatically deduplicates based on transaction_id, but ensure you're not processing the same webhook multiple times:
// Store processed webhook IDs
const processedWebhooks = new Set();
export async function POST(req: Request) {
const event = JSON.parse(body);
// Check if already processed
if (processedWebhooks.has(event.id)) {
console.log('[Paddle] Webhook already processed:', event.id);
return Response.json({ received: true });
}
// Process webhook
await revenue.track({
transaction_id: event.data.id, // Unique transaction ID
});
// Mark as processed
processedWebhooks.add(event.id);
return Response.json({ received: true });
}
Debugging Tips
- Enable debug mode:
const revenue = new Revenue({
apiToken: process.env.PULSORA_SECRET_TOKEN!,
debug: true, // See detailed logs
});
- Log webhook payloads:
app.post('/webhooks/paddle', async (req, res) => {
console.log(
'[Paddle Webhook] Full payload:',
JSON.stringify(req.body, null, 2),
);
// Process webhook...
});
-
Test with Paddle Sandbox:
- Use test credit cards:
4242 4242 4242 4242 - Create test subscriptions and check webhooks
- Verify custom_data propagates correctly
- Use test credit cards:
-
Monitor webhook deliveries:
- Paddle Dashboard → Developer Tools → Notifications
- Check webhook delivery history and retry attempts
- View payload and response for each delivery
Next Steps
- Revenue Package - Complete revenue tracking API
- Custom Integration - Build custom payment integrations
- Stripe Integration - Alternative payment processor
- Revenue Attribution - Configure revenue tracking and attribution