Webhook Setup
Configure payment provider webhooks for real-time events. Each provider needs a URL, specific events, and a signing secret.
Webhook URLs
Replace <YOUR_DOMAIN> with your production domain. For local dev, use ngrok or Stripe CLI.
| Provider | URL |
|---|---|
| Stripe | https://<YOUR_DOMAIN>/api/webhooks/stripe |
| Polar | https://<YOUR_DOMAIN>/api/webhooks/polar |
| Lemon Squeezy | https://<YOUR_DOMAIN>/api/webhooks/lemon-squeezy |
| Dodo Payments | https://<YOUR_DOMAIN>/api/webhooks/dodo |
| Creem | https://<YOUR_DOMAIN>/api/webhooks/creem |
Billing Portal Return Sync
When a user opens the customer portal from /dashboard/billing, the application now sets
the provider return URL to:
https://<YOUR_DOMAIN>/dashboard/billing?portal_return=1On return, /dashboard/billing performs an authenticated provider reconciliation by
calling PaymentProvider.getSubscription() for the user's current
profiles.subscription_id, then routes the normalized result through
syncSubscriptionToDb(). This closes the common race where the customer returns
before webhook delivery finishes.
Webhooks remain the source of truth. The portal-return reconciliation is a
fast, user-scoped read repair. Successful webhook or reconciliation syncs
invalidate the billing, dashboard, and user cache tags.
Provider dashboard change: make sure your portal/customer-dashboard settings
allow <YOUR_DOMAIN>/dashboard/billing as a return URL or allowed redirect domain.
1. Stripe
Events:
checkout.session.completedcustomer.subscription.createdcustomer.subscription.updatedcustomer.subscription.pausedcustomer.subscription.resumedcustomer.subscription.deletedinvoice.paidinvoice.payment_failed
After setup: Copy Webhook Signing Secret (whsec_...) → STRIPE_WEBHOOK_SECRET
Local: stripe listen --forward-to localhost:3000/api/webhooks/stripe
2. Polar
Events:
checkout.createdcheckout.updatedcheckout.expiredorder.createdorder.paidsubscription.createdsubscription.activesubscription.updatedsubscription.canceledsubscription.past_duesubscription.revoked
After setup: Copy Webhook Secret → POLAR_WEBHOOK_SECRET
3. Lemon Squeezy
Events:
order_createdsubscription_createdsubscription_updatedsubscription_cancelledsubscription_resumedsubscription_expiredsubscription_pausedsubscription_unpausedsubscription_payment_successsubscription_payment_failedsubscription_payment_recovered
After setup: Copy Webhook Secret → LEMONSQUEEZY_WEBHOOK_SECRET
4. Dodo Payments
Events:
payment.succeededpayment.failedsubscription.activesubscription.renewedsubscription.plan_changedsubscription.updatedsubscription.cancelledsubscription.failedsubscription.on_holdsubscription.expired
After setup: Copy Webhook Secret → DODO_WEBHOOK_SECRET
DODO_WEBHOOK_SECRET is the canonical Dodo webhook variable.
5. Creem
Events:
checkout.completedsubscription.activesubscription.trialingsubscription.paidsubscription.canceledsubscription.scheduled_cancelsubscription.expiredsubscription.pausedsubscription.updatesubscription.past_due
After setup: Copy Webhook Secret → CREEM_WEBHOOK_SECRET
Signature Verification
| Provider | Signature Header(s) | Algorithm |
|---|---|---|
| Stripe | stripe-signature | HMAC-SHA256 |
| Polar | webhook-id, webhook-timestamp, webhook-signature | HMAC-SHA256 |
| Lemon Squeezy | x-signature | HMAC-SHA256 |
| Dodo | webhook-id, webhook-timestamp, webhook-signature | HMAC-SHA256 |
| Creem | creem-signature | HMAC-SHA256 |
[!TIP] Always use
crypto.timingSafeEqual()when comparing signatures to prevent timing attacks.
Webhook Idempotency
Every provider handler records delivery state in webhook_logs using the (provider, event_id) partial unique index where event_id IS NOT NULL.
| Existing row state | Behavior |
|---|---|
success | Skip; event is already processed |
processing under 5 minutes old | Skip; another request owns the work |
stale processing | Reuse row and increment attempts |
failed | Reuse row and increment attempts |
This keeps provider retries safe without losing retryability after transient failures.
Provider resource IDs are also unique at the billing-state layer:
subscriptions(provider, provider_subscription_id)prevents duplicate active subscription rows and letssyncSubscriptionToDb()upsert.purchases(provider, provider_payment_id)prevents duplicate one-time purchase rows and letssavePurchase()return the existing purchase as idempotent success.
Creem events must include a durable provider event ID (event.id). The handler rejects payloads without that ID instead of generating a timestamp-based fallback, because generated IDs would turn every retry into a new event.
Payment Readiness
Internal health uses lib/payments/readiness.ts to validate the selected
provider's API key/access token, webhook secret, and active paid-tier
price/product IDs. A webhook secret alone is not enough for launch readiness:
checkout still fails if the selected provider's tier product identifiers are
missing.
Local Testing
- Stripe: Use the Stripe CLI:
stripe listen --forward-to localhost:3000/api/webhooks/stripe - Others: Use a tunnel like ngrok:
ngrok http 3000. Register the HTTPS URL (e.g.,https://xyz.ngrok.io/api/webhooks/[provider]) in the provider's dashboard.
Provider Docs References
- Stripe subscription webhooks: https://docs.stripe.com/billing/subscriptions/webhooks
- Polar
subscription.updated: https://polar.sh/docs/api-reference/webhooks/subscription.updated - Lemon Squeezy event types: https://docs.lemonsqueezy.com/help/webhooks/event-types
- Dodo subscription webhooks: https://docs.dodopayments.com/developer-resources/webhooks/intents/subscription
- Creem webhooks: https://docs.creem.io/code/webhooks
Universal Checklist
- URL - Must end with correct provider name:
stripe,polar,lemon-squeezy,dodo, orcreem - Events - Include the listed subscription update/cancel events. They are the real-time source for
/dashboard/billingstate. - Secret - Signing secret must be in
.env.localwith the correct var name - Access - Server must be internet-accessible (ngrok for local)
- Headers - Polar and Dodo require
webhook-id,webhook-timestamp,webhook-signaturefor HMAC. Handler passes full headers automatically - Return URL - Allow
https://<YOUR_DOMAIN>/dashboard/billingin the provider portal settings if the provider restricts portal redirects. - Idempotency - Confirm duplicate success events skip, active processing rows skip, failed/stale processing events retry, and duplicate provider subscription/payment IDs do not create duplicate billing rows.
- Creem Event IDs - Confirm Creem sandbox webhooks include durable
event.idvalues before enabling production webhooks.
Copyright © 2026 Vibe44. Built with ❤️ by Scale with Alap.