Webhooks
Why Webhooks Are Critical
Users close browser tabs. They lose internet. They refresh the page. The frontend never sees the final confirmation.
The clientSecret flow (Stripe) is optimistic — your app sends a secret to the frontend, the frontend handles the payment, but your server doesn't know if it succeeded unless a webhook arrives.
Never fulfill an order based on a frontend callback alone. Webhooks are the only reliable confirmation.
The Raw Body Middleware Reminder
express.raw() on /payments/webhookbeforeapp.use(express.json()).This is non-negotiable. Signature verification will fail silently if you skip it.Payment Events Reference
Your payment provider fires these events to /payments/webhook:
| Event | Provider | Meaning |
|---|---|---|
payment_intent.succeeded | Stripe | Payment confirmed — fulfill order |
payment_intent.payment_failed | Stripe | Payment failed or was cancelled |
charge.refunded | Stripe | Refund was processed |
order_created | LemonSqueezy | Payment confirmed — fulfill order |
order_refunded | LemonSqueezy | Refund was processed |
subscription_cancelled | LemonSqueezy | Subscription ended |
Add Your Fulfillment Logic
The generated payments.controller.js includes a switch statement with placeholders. This is where you add your fulfillment logic:
import { PAYMENT_EVENTS } from '@charcoles/payments'
// In your webhook handler
switch (result.event) {
case PAYMENT_EVENTS.STRIPE_PAYMENT_SUCCEEDED:
case PAYMENT_EVENTS.LS_ORDER_CREATED:
// Add your logic here
await sendConfirmationEmail(result.data)
await updateOrderStatus(result.data.id, 'paid')
await grantProductAccess(result.data)
break
case PAYMENT_EVENTS.STRIPE_PAYMENT_FAILED:
// Handle payment failures
await notifyCustomerOfFailure(result.data)
break
case PAYMENT_EVENTS.LS_ORDER_REFUNDED:
case PAYMENT_EVENTS.STRIPE_REFUND_CREATED:
// Handle refunds
await updateOrderStatus(result.data.id, 'refunded')
await revokeProductAccess(result.data)
break
}
Your logic runs after the webhook is verified and deduplicated. If fulfillment succeeds, return 200 OK. If it fails, log the error and still return 200 OK — the module will not retry you.
Webhook Deduplication
Payment providers retry webhooks when your server doesn't respond quickly or returns an error. The same event can arrive multiple times.
The module includes in-memory deduplication: if the same event ID arrives within the deduplication window, it's marked as a duplicate and processing is skipped.
// Example with Redis
const isProcessed = await redis.get(`event:${eventId}`)
if (isProcessed) return { received: true, duplicate: true }
// Process the event...
await redis.setex(`event:${eventId}`, 86400, 'processed') // 24 hour TTL
Test Webhooks Locally
Stripe Webhook Testing
Use the Stripe CLI to forward webhooks to your local server:
stripe listen --forward-to localhost:3000/payments/webhook
This creates a tunnel and prints your webhook signing secret. Add it to .env.local:
STRIPE_WEBHOOK_SECRET=whsec_...
Now trigger test events in the Stripe dashboard, and they'll be forwarded to your local server.
LemonSqueezy Webhook Testing
LemonSqueezy doesn't have a built-in CLI tunnel. Use ngrok to expose your local server:
npx ngrok http 3000
This creates a public URL like https://abc123.ngrok.io. Then:
- Go to your LemonSqueezy store settings
- Add a webhook endpoint:
https://abc123.ngrok.io/payments/webhook - Copy the signing secret
- Add to
.env.local:
LEMONSQUEEZY_WEBHOOK_SECRET=...
Now trigger test events from the LemonSqueezy dashboard.
Webhook Security
All webhooks are verified using cryptographic signatures:
- Stripe: Uses the
Stripe-Signatureheader andSTRIPE_WEBHOOK_SECRET - LemonSqueezy: Uses the
X-Signatureheader andLEMONSQUEEZY_WEBHOOK_SECRET
If a webhook arrives without a valid signature, the module rejects it and returns 401 Unauthorized.
Never trust the webhook payload without signature verification. Always use the module's built-in verification.
What Comes Next
- Examples — Full webhook handling examples
- Environment Variables — Reference all webhook secrets