Payment providers notify your app of subscription events via webhooks. Stripe, LemonSqueezy, and Polar webhooks are all handled through the same normalized interface.
Endpoints
| Provider | Endpoint |
|---|---|
| Stripe | POST /api/webhooks/stripe |
| LemonSqueezy | POST /api/webhooks/lemonsqueezy |
| Polar | POST /api/webhooks/polar |
Signature Verification
Every incoming webhook is verified against its signing secret before processing. Invalid signatures are rejected with a 400 status. Set the corresponding secret:
- Stripe:
STRIPE_WEBHOOK_SECRET - LemonSqueezy:
LEMONSQUEEZY_WEBHOOK_SECRET - Polar:
POLAR_WEBHOOK_SECRET
Handled Events
| Event | Action |
|---|---|
checkout.session.completed | Activate subscription, link to user |
customer.subscription.created | Record new subscription |
customer.subscription.updated | Update plan, seats, or status |
customer.subscription.deleted | Cancel and deactivate subscription |
Webhook payloads include metadata (userId, planId) set during checkout creation.
Delivery Logging and Idempotency
All webhook deliveries are logged in the webhook_delivery table with the provider event ID, event type, status, and payload. View delivery history in Admin → Webhooks.
After signature verification, the provider event ID is claimed before subscription or payment side effects run. A repeated delivery of an already pending or successful event is acknowledged without running those side effects again. If processing failed, the same event can be claimed again and retried. The unique (provider, event_id) index makes this safe across concurrent app instances.
Failed deliveries are retried via the webhook-retry background job with exponential backoff.
This prevents duplicate processing of the same provider event. It does not reorder different valid events: deploys should still monitor provider delivery order and reconcile the final subscription state when a provider sends related events out of order.
Extending the Handler
To add custom logic when a payment event occurs, edit the webhook handler in the corresponding route file:
// src/app/api/webhooks/stripe/route.ts
case 'checkout.session.completed':
await activateSubscription(session);
// Add your custom logic here:
// await sendWelcomeEmail(session.customer_email);
// await provisionResources(session.metadata.planId);
break;
Testing Locally
Stripe CLI
stripe listen --forward-to http://localhost:3000/api/webhooks/stripe
Trigger test events:
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
LemonSqueezy
Use ngrok or a similar tunnel to expose your local server:
ngrok http 3000
Then set the ngrok URL as the webhook endpoint in the LemonSqueezy dashboard.
Polar
Use ngrok or a similar tunnel to expose your local server, then register the tunnel URL as the webhook endpoint in the Polar dashboard. See Polar Setup for the full webhook configuration steps.