Next.js starter your AI actually understands. Ship internal tools in days not weeks. Pre-order $199 $499 → [Get it now]

How to handle webhooks in Node.js

Webhooks are HTTP POST requests from external services notifying your application about events — a Stripe payment succeeding, a GitHub push, or a PayPal subscription renewing. As the creator of CoreUI with 25 years of backend development experience, I’ve built webhook handlers for payment processors, version control systems, and communication platforms where reliability and security are critical. The two non-negotiable requirements are: verify the webhook signature before processing, and return 200 immediately then handle the event asynchronously. Failing to verify signatures exposes you to spoofed events; slow synchronous processing risks timeouts and missed retries.

Create a generic webhook handler with signature verification.

// src/webhooks/webhook.handler.js
import crypto from 'crypto'

export function verifyHmacSignature(payload, signature, secret, algorithm = 'sha256') {
  const hmac = crypto.createHmac(algorithm, secret)
  hmac.update(typeof payload === 'string' ? payload : JSON.stringify(payload))
  const digest = `${algorithm}=${hmac.digest('hex')}`

  // Use timingSafeEqual to prevent timing attacks
  const a = Buffer.from(digest)
  const b = Buffer.from(signature)

  if (a.length !== b.length) return false
  return crypto.timingSafeEqual(a, b)
}
// src/webhooks/github.router.js
import { Router } from 'express'
import { verifyHmacSignature } from './webhook.handler.js'
import { processGitHubEvent } from './github.service.js'

const router = Router()

// Must receive raw body for signature verification
router.post(
  '/github',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const signature = req.headers['x-hub-signature-256']
    const event = req.headers['x-github-event']

    if (!signature) {
      return res.status(400).json({ error: 'Missing signature' })
    }

    if (!verifyHmacSignature(req.body, signature, process.env.GITHUB_WEBHOOK_SECRET)) {
      return res.status(401).json({ error: 'Invalid signature' })
    }

    // Respond immediately - GitHub expects a quick response
    res.status(200).json({ received: true })

    // Process asynchronously after responding
    const payload = JSON.parse(req.body.toString())
    processGitHubEvent(event, payload).catch(console.error)
  }
)

export { router as githubWebhookRouter }

crypto.timingSafeEqual prevents timing attacks where an attacker could guess the signature character by character based on response time differences. express.raw() preserves the raw body bytes needed for signature computation.

Generic Webhook Router for Multiple Services

Route events to the appropriate handler.

// src/webhooks/webhook.router.js
import { Router } from 'express'
import Stripe from 'stripe'
import { verifyHmacSignature } from './webhook.handler.js'

const router = Router()
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY)

// Stripe webhook
router.post(
  '/stripe',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    let event
    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        req.headers['stripe-signature'],
        process.env.STRIPE_WEBHOOK_SECRET
      )
    } catch {
      return res.status(400).send('Invalid Stripe signature')
    }

    res.json({ received: true })
    await handleStripeEvent(event)
  }
)

// Generic HMAC-verified webhook (GitHub, Shopify, etc.)
router.post(
  '/:service',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const { service } = req.params
    const secret = process.env[`${service.toUpperCase()}_WEBHOOK_SECRET`]
    const sigHeader = req.headers['x-hub-signature-256'] ?? req.headers['x-signature']

    if (!secret || !verifyHmacSignature(req.body, sigHeader, secret)) {
      return res.status(401).json({ error: 'Unauthorized' })
    }

    res.json({ received: true })
    const payload = JSON.parse(req.body)
    processEvent(service, payload).catch(console.error)
  }
)

Dynamically looking up the secret from environment variables (GITHUB_WEBHOOK_SECRET, SHOPIFY_WEBHOOK_SECRET, etc.) keeps the router generic without adding a new route for every service.

Idempotent Event Processing

Prevent duplicate processing when webhooks are delivered more than once.

async function handleStripeEvent(event) {
  // Check if we've already processed this event
  const existing = await prisma.webhookEvent.findUnique({
    where: { eventId: event.id }
  })

  if (existing) {
    console.log(`Event ${event.id} already processed`)
    return
  }

  // Process the event
  if (event.type === 'payment_intent.succeeded') {
    await fulfillOrder(event.data.object.metadata.orderId)
  }

  // Record that we processed it
  await prisma.webhookEvent.create({
    data: { eventId: event.id, type: event.type }
  })
}

Most webhook providers guarantee at-least-once delivery — the same event may arrive multiple times. Storing processed event IDs and checking before processing makes your handler idempotent, preventing double-fulfillment or double-charging.

Best Practice Note

This is the webhook handling pattern used in CoreUI backend templates for payment and notification integrations. For high-volume webhooks, queue incoming events with Bull or BullMQ and process them with workers to keep the response time under 5 seconds (most providers retry if they don’t receive a response quickly). See how to integrate Stripe in Node.js for the complete Stripe webhook implementation.


Speed up your responsive apps and websites with fully-featured, ready-to-use open-source admin panel templates—free to use and built for efficiency.


About the Author