# Payment webhooks

URL: https://supapulse.app/docs/revenue/webhooks

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

<Callout title="Using Stripe?">
  Skip this page. Supapulse has a Stripe webhook built in, so you don't write a handler. See the [Stripe guide](https://supapulse.app/docs/guides/stripe).
</Callout>

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](https://supapulse.app/docs/revenue/installation#http-api).

## Customer ID [#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.

| Processor | Set it on | Read it from |
| --- | --- | --- |
| Paddle Billing | `customData` when opening the checkout | `data.custom_data` |
| Lemon Squeezy | `checkout_data.custom` | `meta.custom_data` |
| Anything else | Whatever free-form metadata field it has, or a column in your database | The 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](https://supapulse.app/docs/revenue/attribution#checkout-metadata).

## Paddle [#paddle]

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

```ts title="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](https://supapulse.app/docs/guides/paddle).

## Any other processor [#any-other-processor]

```ts
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 [#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 [#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.
