Paddle
Attribute Paddle Billing payments to the visits that led to them. Identify the customer, pass your user ID as custom data, verify the webhook, and post revenue to Supapulse.
Paddle has no built-in Supapulse webhook, so payments go through your own webhook handler and the server path. You'll identify the customer in the browser, pass your user ID to Paddle Checkout as custom data, get it back in the transaction.completed webhook, and post a revenue event to Supapulse with your site's secret token. This guide uses Paddle Billing and Paddle.js v2.
Before you start
- Supapulse is installed on the page that opens the checkout (quick start).
SUPAPULSE_SECRET_TOKENholds the site's secret token (sec_…) on your server.- A Paddle notification destination points at your webhook URL, subscribed to
transaction.completed, and you have its secret key.
1. Identify the customer
After login or signup, link the visitor to your user ID:
supapulse.identify(user.id); // window.supapulse with the script tagIn React, use useIdentify().
2. Open the checkout with custom data
<script src="https://cdn.paddle.com/paddle/v2/paddle.js"></script>
<script>
Paddle.Initialize({ token: 'live_your_client_side_token' });
document.getElementById('buy').addEventListener('click', async function () {
Paddle.Checkout.open({
items: [{ priceId: 'pri_01h…', quantity: 1 }],
customData: {
supapulse_customer_id: USER_ID, // the ID you passed to identify()
supapulse_visitor_fingerprint: await window.supapulse.getVisitorFingerprint(), // string | null
supapulse_session_id: window.supapulse.getSessionId(),
},
});
});
</script>Paddle stores customData on the transaction and on the subscription it creates. The fingerprint and session ID are optional: if the customer was identified, customer_id alone is enough.
3. Handle the webhook
import { Environment, EventName, Paddle } from '@paddle/paddle-node-sdk';
import { Revenue } from '@supapulse/revenue';
const paddle = new Paddle(process.env.PADDLE_API_KEY!, {
environment: process.env.PADDLE_ENV === 'production' ? Environment.production : Environment.sandbox,
});
const revenue = new Revenue({ apiToken: process.env.SUPAPULSE_SECRET_TOKEN! });
type CustomData = {
supapulse_customer_id?: string;
supapulse_visitor_fingerprint?: string | null;
supapulse_session_id?: string | null;
} | null;
async function customDataFor(transaction: { customData: unknown; subscriptionId: string | null }) {
const fromTransaction = transaction.customData as CustomData;
if (fromTransaction?.supapulse_customer_id) {
return fromTransaction;
}
// Renewals: fall back to the subscription's custom data.
if (transaction.subscriptionId) {
const subscription = await paddle.subscriptions.get(transaction.subscriptionId);
return subscription.customData as CustomData;
}
return null;
}
export async function POST(request: Request) {
const signature = request.headers.get('paddle-signature') ?? '';
const body = await request.text();
let event;
try {
event = await paddle.webhooks.unmarshal(body, process.env.PADDLE_WEBHOOK_SECRET!, signature);
} catch {
return new Response('Invalid signature', { status: 400 });
}
if (event.eventType === EventName.TransactionCompleted) {
const transaction = event.data;
const custom = await customDataFor(transaction);
try {
await revenue.track({
customer_id: custom?.supapulse_customer_id ?? transaction.customerId!,
visitor_fingerprint: custom?.supapulse_visitor_fingerprint ?? undefined,
session_id: custom?.supapulse_session_id ?? undefined,
amount: Number(transaction.details?.totals?.total ?? 0) / 100,
currency: transaction.currencyCode,
transaction_id: transaction.id,
event_type: transaction.origin === 'subscription_recurring' ? 'renewal' : 'purchase',
payment_processor: 'paddle',
});
} catch {
return new Response('Supapulse ingest failed', { status: 502 });
}
}
return new Response('ok');
}transaction.completed fires for the first payment and for every renewal, so this one handler covers one-time purchases and subscriptions. The transaction ID (txn_…) is unique per payment, which keeps Paddle's retries from counting twice. Renewals reuse the first touch stored for the customer.
For MRR, send event_type: 'subscription' with mrr_impact on the first payment of a plan, and upgrade, downgrade or churn when it changes. See Basic usage.
Amounts
Paddle sends totals as strings in the lowest currency unit. details.totals.total includes tax; use details.totals.subtotal if you want revenue before tax. Divide by 100 for two-decimal currencies, and not for zero-decimal ones like JPY.
Supapulse doesn't convert currencies, so keep a site's revenue in one currency.
Refunds and cancellations
Paddle adjustments aren't picked up automatically. When you refund, post a refund event with the refunded amount as a positive number, and its own transaction_id such as the adjustment ID. Supapulse stores it as negative. For a cancellation, post a churn event with the monthly amount as mrr_impact.
Testing
- Use the Paddle sandbox (
sandbox-api.paddle.com, atest_client token) and point the sandbox notification destination at a tunnel, for examplengrok http 3000. - Complete a sandbox checkout from your site so the custom data carries your user ID.
- Use a separate Supapulse site for sandbox payments.
The webhook simulator in the Paddle dashboard sends sample payloads without your custom data; those show up in totals without attribution, under the Paddle customer ID.
Common issues
custom_data is empty in the webhook. customData must be passed to Paddle.Checkout.open(), and window.supapulse must exist when the button is clicked. Without a custom supapulse_customer_id, the revenue is recorded under the Paddle customer ID.
Revenue shows up as direct. Supapulse found no visit for that customer. Call identify() with the same ID you pass in customData.
402 from Supapulse. The site's team has no active plan.
Signature verification fails. Verify the raw body exactly as received. Parsing and re-serializing the JSON changes it.
Revenue is a hundred times too high. You didn't divide the lowest-unit total by 100.
Stripe
Connect Stripe to Supapulse with a per-site webhook. Purchases, subscriptions, renewals, refunds and cancellations are credited to the visits that brought each customer.
Custom integrations
Connect Supapulse to any payment processor, CMS or backend. The general pattern, server-side events, manual invoices and what isn't supported.