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.
| 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.
Paddle
Paddle payments go through your own webhook handler and the server path.
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
- Verify the signature first. A webhook URL is public; without verification anyone can post fake payments to it.
- Send successful payments as revenue. Skip pending and failed ones. Send refunds as
event_type: "refund"so they're subtracted. - 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. - Let failures fail. If the call to Supapulse throws, return an error so the processor retries the webhook later.
- 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.
API reference
The revenue and identify events the ingest API accepts, the fingerprint endpoint, the Stripe webhook, what each stores, and the @supapulse/revenue SDK surface.
Attribution
How Supapulse links a payment to the visit that first brought the customer, how renewals are handled, and what it doesn't cover.