Docus Logo
Charcoles Payments

Webhooks

Handle payment events reliably with webhook processing, deduplication, and testing.

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

Webhook signature verification requires the raw request body before Express parses it. If you haven't done this yet, see Setup for details.Register 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:

EventProviderMeaning
payment_intent.succeededStripePayment confirmed — fulfill order
payment_intent.payment_failedStripePayment failed or was cancelled
charge.refundedStripeRefund was processed
order_createdLemonSqueezyPayment confirmed — fulfill order
order_refundedLemonSqueezyRefund was processed
subscription_cancelledLemonSqueezySubscription 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.

In-memory deduplication resets when your server restarts. For production, use a persistent store (Redis, database) to track processed event IDs:
// 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
This ensures idempotency even across server restarts.

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:

  1. Go to your LemonSqueezy store settings
  2. Add a webhook endpoint: https://abc123.ngrok.io/payments/webhook
  3. Copy the signing secret
  4. 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-Signature header and STRIPE_WEBHOOK_SECRET
  • LemonSqueezy: Uses the X-Signature header and LEMONSQUEEZY_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