supapulsedocs
Revenue packagePayment webhooks

Payment webhooks

Forward payments from Paddle or any processor's webhooks to Supapulse, safely and without double counting. Stripe has a built-in webhook.

Using Stripe?

Skip this page. Supapulse has a Stripe webhook built in, so you don't write a handler. See the Stripe guide.

For any other processor the flow is the same: identify the customer in the browser, get a webhook from the processor, and post a revenue event with the customer's ID. The examples use the trackRevenue() helper from Installation.

Customer ID

Use your own user ID as customer_id, the same value you pass to supapulse.identify(). Put it in the processor's free-form metadata when you create the checkout, and read it back in the webhook.

ProcessorSet it onRead it from
Paddle BillingcustomData when opening the checkoutdata.custom_data
Lemon Squeezycheckout_data.custommeta.custom_data
Anything elseWhatever free-form metadata field it has, or a column in your databaseThe same field in the webhook payload

If a customer can pay before they have an account, also pass getVisitorFingerprint() and getSessionId() through the same field and send them as visitor_fingerprint and session_id. See Attribution.

Paddle

Paddle payments go through your own webhook handler and the server path.

app/api/webhooks/paddle/route.ts
import { Environment, Paddle } from '@paddle/paddle-node-sdk';
import { trackRevenue } from '@/lib/supapulse';

const paddle = new Paddle(process.env.PADDLE_API_KEY!, {
  environment: process.env.PADDLE_ENV === 'production' ? Environment.production : Environment.sandbox,
});

type CustomData = { supapulse_customer_id?: string } | null;

export async function POST(request: Request) {
  const event = await paddle.webhooks.unmarshal(
    await request.text(),
    process.env.PADDLE_WEBHOOK_SECRET!,
    request.headers.get('paddle-signature')!,
  );

  if (event.eventType === 'transaction.completed') {
    const transaction = event.data;
    const customerId = (transaction.customData as CustomData)?.supapulse_customer_id ?? transaction.customerId;

    await trackRevenue({
      customerId: customerId!,
      amount: Number(transaction.details?.totals?.total ?? 0) / 100,
      currency: transaction.currencyCode,
      transactionId: transaction.id,
      eventType: transaction.origin === 'subscription_recurring' ? 'renewal' : 'purchase',
      paymentProcessor: 'paddle',
    });
  }

  return new Response('ok');
}

More in the Paddle guide.

Any other processor

export async function handleWebhook(request: Request) {
  const payload = await verifySignature(request); // your processor's verification

  if (payload.status !== 'paid') {
    return new Response('ignored');
  }

  await trackRevenue({
    customerId: payload.metadata.supapulse_customer_id,
    amount: payload.amount, // convert to major units if needed
    currency: payload.currency.toUpperCase(),
    transactionId: payload.id,
    paymentProcessor: 'your-processor',
  });

  return new Response('ok');
}

Rules

  1. Verify the signature first. A webhook URL is public; without verification anyone can post fake payments to it.
  2. Send successful payments as revenue. Skip pending and failed ones. Send refunds as event_type: "refund" so they're subtracted.
  3. Use the processor's payment ID as transaction_id. Supapulse drops repeats of the same ID and event type, so a retried webhook is counted once.
  4. Let failures fail. If the call to Supapulse throws, return an error so the processor retries the webhook later.
  5. Always send a customer_id. It's required. If the payment has no account behind it, use the processor's customer ID or an order reference.

Testing

  • Paddle: use the sandbox and the webhook simulator in the Paddle dashboard, pointed at a tunnel such as ngrok http 3000.
  • Supapulse: send test payments to a separate Supapulse site so they stay out of your real numbers.