Payment providers notify your app of subscription events via webhooks. Both Stripe and LemonSqueezy webhooks are handled through the same normalized interface.
Endpoints
| Provider | Endpoint |
|---|---|
| Stripe | POST /api/webhooks/stripe |
| LemonSqueezy | POST /api/webhooks/lemonsqueezy |
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
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
All webhook deliveries are logged in the webhook_delivery table with the event type, status, and payload. View delivery history in Admin → Webhooks.
Failed deliveries are retried via the webhook-retry background job with exponential backoff.
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.