Payment Webhooks
Payment gateways notify Prime of subscription events (new payments, renewals, cancellations, failures) by posting to your webhook endpoints. Prime verifies the signature and updates the account accordingly.
Webhook endpoints
| Gateway | Endpoint |
|---|---|
| Lemon Squeezy | POST /webhooks/lemonsqueezy |
| Stripe | POST /webhooks/stripe |
Setting up webhooks
Lemon Squeezy
- Go to Settings → Webhooks in your Lemon Squeezy dashboard
- Add endpoint:
https://yourdomain.com/webhooks/lemonsqueezy - Subscribe to all of:
subscription_createdsubscription_updatedsubscription_cancelledsubscription_expiredsubscription_payment_successsubscription_payment_failedsubscription_payment_recoveredorder_created
- Copy the Signing Secret → set as
LEMONSQUEEZY_WEBHOOK_SECRET
subscription_payment_success is what records a renewal — without it, invoices are
never written and renewals are not booked. subscription_payment_failed /
_recovered drive dunning. Subscribing only to the lifecycle events leaves a
working checkout with no billing history.
Stripe
- Go to Developers → Webhooks in your Stripe dashboard
- Add endpoint:
https://yourdomain.com/webhooks/stripe - Subscribe to:
checkout.session.completedcustomer.subscription.updatedcustomer.subscription.deletedinvoice.paidinvoice.payment_failed
- Copy the Signing Secret → set as
STRIPE_WEBHOOK_SECRET
invoice.paid is the Stripe renewal event — omit it and renewals go unrecorded.
Do not also subscribe to customer.subscription.created: it maps to the same
internal event as checkout.session.completed, so both firing would describe one
new subscription twice. The handler de-duplicates on the subscription ID, but
subscribing to just one of the two keeps the ledger clean.
Local development
Use ngrok to expose your local server:
ngrok http 6600
# Use the HTTPS URL as your webhook endpoint in the gateway dashboard
What happens on a successful webhook
- Signature verified against
LEMONSQUEEZY_WEBHOOK_SECRETorSTRIPE_WEBHOOK_SECRET - Raw payload stored in the
subscription_webhookstable, keyed on(gateway, identifier)— a replay of an already-processed event returns{ received: true, duplicate: true }and changes nothing - The gateway adapter translates the event into Prime's own vocabulary
- The matching handler updates the account — plan,
subscription_id,expires_at,sub_status - Slack notification sent to
SLACK_URL_PAYMENTS(if configured)
A handler that throws leaves the ledger row marked failed and returns a 500, so
the gateway retries.
Event vocabulary
Gateways disagree about what their events mean, so each adapter normalizes onto a shared set before any account is touched. The distinction that matters most:
| Prime event | Meaning | Lemon Squeezy | Stripe |
|---|---|---|---|
subscription.cancel_scheduled | Customer cancelled but keeps access until the period ends | subscription_cancelled | customer.subscription.updated with cancel_at_period_end |
subscription.cancelled / .expired | Subscription is over, access stops | subscription_expired | customer.subscription.deleted |
Lemon Squeezy fires subscription_cancelled the moment a customer clicks cancel,
which is not the same as Stripe's customer.subscription.deleted. Treating
them alike revokes access from someone who has already paid for the rest of the
period, so they map to different events.
If you add a gateway, put the translation in its adapter's mapEventType /
parseWebhookPayload. The handlers in controller/paymentGatewayController.js
are gateway-agnostic and should never branch on event.gateway.
Signature verification
Both gateways use HMAC-SHA256 to sign webhook payloads. Stripe additionally requires the raw request body (not parsed JSON) for signature verification — handled automatically by the raw body middleware in app.js.