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/core in your frontend
  • @pulsora/revenue in 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#

  1. Log into Paddle Dashboard
  2. Go to Developer Tools → Notifications
  3. Add webhook endpoint: https://yourdomain.com/api/webhooks/paddle
  4. Select events to receive:
    • transaction.completed
    • transaction.updated
    • subscription.updated
    • subscription.cancelled
    • subscription.expired
    • transaction.payment_failed
  5. 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_data is null or missing fingerprint/session

Solutions:

  1. 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,
  },
});
  1. 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:

  1. Verify webhook secret:
console.log('Webhook secret configured:', !!process.env.PADDLE_WEBHOOK_SECRET);
  1. 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...
  },
);
  1. 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#

  1. Enable debug mode:
const revenue = new Revenue({
  apiToken: process.env.PULSORA_SECRET_TOKEN!,
  debug: true, // See detailed logs
});
  1. Log webhook payloads:
app.post('/webhooks/paddle', async (req, res) => {
  console.log(
    '[Paddle Webhook] Full payload:',
    JSON.stringify(req.body, null, 2),
  );
  // Process webhook...
});
  1. Test with Paddle Sandbox:

    • Use test credit cards: 4242 4242 4242 4242
    • Create test subscriptions and check webhooks
    • Verify custom_data propagates correctly
  2. Monitor webhook deliveries:

    • Paddle Dashboard → Developer Tools → Notifications
    • Check webhook delivery history and retry attempts
    • View payload and response for each delivery

Next Steps#