Skip to main content

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​

GatewayEndpoint
Lemon SqueezyPOST /webhooks/lemonsqueezy
StripePOST /webhooks/stripe

Setting up webhooks​

Lemon Squeezy​

  1. Go to Settings → Webhooks in your Lemon Squeezy dashboard
  2. Add endpoint: https://yourdomain.com/webhooks/lemonsqueezy
  3. Subscribe to all of:
    • subscription_created
    • subscription_updated
    • subscription_cancelled
    • subscription_expired
    • subscription_payment_success
    • subscription_payment_failed
    • subscription_payment_recovered
    • order_created
  4. Copy the Signing Secret → set as LEMONSQUEEZY_WEBHOOK_SECRET
Do not skip the payment events

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​

  1. Go to Developers → Webhooks in your Stripe dashboard
  2. Add endpoint: https://yourdomain.com/webhooks/stripe
  3. Subscribe to:
    • checkout.session.completed
    • customer.subscription.updated
    • customer.subscription.deleted
    • invoice.paid
    • invoice.payment_failed
  4. Copy the Signing Secret → set as STRIPE_WEBHOOK_SECRET
note

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​

  1. Signature verified against LEMONSQUEEZY_WEBHOOK_SECRET or STRIPE_WEBHOOK_SECRET
  2. Raw payload stored in the subscription_webhooks table, keyed on (gateway, identifier) — a replay of an already-processed event returns { received: true, duplicate: true } and changes nothing
  3. The gateway adapter translates the event into Prime's own vocabulary
  4. The matching handler updates the account — plan, subscription_id, expires_at, sub_status
  5. 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 eventMeaningLemon SqueezyStripe
subscription.cancel_scheduledCustomer cancelled but keeps access until the period endssubscription_cancelledcustomer.subscription.updated with cancel_at_period_end
subscription.cancelled / .expiredSubscription is over, access stopssubscription_expiredcustomer.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.