# Paddle

URL: https://supapulse.app/docs/guides/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 [#before-you-start]

- Supapulse is installed on the page that opens the checkout ([quick start](https://supapulse.app/docs/quickstart)).
- `SUPAPULSE_SECRET_TOKEN` holds 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 [#1-identify-the-customer]

After login or signup, link the visitor to your user ID:

```ts
supapulse.identify(user.id); // window.supapulse with the script tag
```

In React, use [`useIdentify()`](https://supapulse.app/docs/react/hooks#useidentify).

## 2. Open the checkout with custom data [#2-open-the-checkout-with-custom-data]

```html
<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 [#3-handle-the-webhook]

```ts title="app/api/webhooks/paddle/route.ts"
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](https://supapulse.app/docs/revenue/usage#subscriptions).

## Amounts [#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 [#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 [#testing]

1. Use the Paddle sandbox (`sandbox-api.paddle.com`, a `test_` client token) and point the sandbox notification destination at a tunnel, for example `ngrok http 3000`.
2. Complete a sandbox checkout from your site so the custom data carries your user ID.
3. 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 [#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.
