ZyVOP Logo
Content That Connects
SeriesCategoriesTags
ZyVOP Logo
Content That Connects

Empowering developers and creators with cutting-edge insights, comprehensive tutorials, and innovative solutions for the digital future.

Content

  • Tags
  • Write Article

Company

  • About Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

© 2026 ZyVOP. Crafted with care for the developer community.

Made with ❤️ by the ZyVOP team
All systems operational
HomeReceiving Webhooks Safely in Node.js (Stripe, GitHub, and Everything Else)

Receiving Webhooks Safely in Node.js (Stripe, GitHub, and Everything Else)

Signature verification, idempotent handlers, and async processing — the pattern that makes duplicate deliveries a non-issue

#Node.js webhooks#Stripe webhook verification#GitHub webhook Node.js#idempotent webhooks#webhook signature verification#duplicate webhook handling#BullMQ webhooks 2026
Z
ZyVOP

Senior Developer

May 25, 2026
6 min read
6 views
Receiving Webhooks Safely in Node.js (Stripe, GitHub, and Everything Else)

Webhooks look simple until production happens. You wire up an endpoint, it works in staging, and then real traffic arrives: retries, duplicate deliveries, out-of-order events, and occasionally the same payment processed twice because Stripe retried after a 30-second timeout and your handler was slow.

This guide covers the right way to receive webhooks — verified, idempotent, and processed asynchronously — so your endpoint is bulletproof regardless of which provider is sending.


The Four Rules Before Any Code

1. Verify every signature. Never process a webhook payload without verifying it came from who it claims. Any unsigned webhook should return 400 and be dropped.

2. Return 200 immediately. Providers time out fast — Stripe times out at 30 seconds and retries up to 72 hours. If your handler is slow, the provider retries, you get duplicate deliveries, and chaos follows. Acknowledge first, process in the background.

3. Make every handler idempotent. Webhooks are delivered at-least-once, not exactly-once. The same event will arrive more than once. Your handler must produce the same result whether it runs once or five times.

4. Store raw body before parsing. Signature verification requires the exact raw bytes as received. Any middleware that parses the body first will break verification. Always access req.rawBody for signature checks.


Setup

npm install stripe @octokit/webhooks bullmq ioredis
// src/app.js
// CRITICAL: rawBody must be captured before any JSON parsing
app.use('/webhooks', express.raw({ type: 'application/json' }));

// All other routes get normal JSON parsing
app.use(express.json());

Pattern 1 — Stripe Webhooks

// src/routes/webhooks/stripe.js
import Stripe from 'stripe';
import { webhookQueue } from '../../queues/index.js';
import db from '../../lib/db.js';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;

router.post('/webhooks/stripe', async (req, res) => {
  const signature = req.headers['stripe-signature'];

  // Step 1 — Verify the signature
  let event;
  try {
    event = stripe.webhooks.constructEvent(
      req.body,        // Raw bytes — not parsed JSON
      signature,
      webhookSecret
    );
  } catch (err) {
    req.log?.warn({ error: err.message }, 'Stripe webhook signature invalid');
    return res.status(400).json({ error: 'Invalid signature' });
  }

  // Step 2 — Deduplicate using the event ID
  // Stripe guarantees event IDs are unique — store them to detect replays
  try {
    await db.query(`
      INSERT INTO processed_webhooks (event_id, source, received_at)
      VALUES ($1, 'stripe', NOW())
      ON CONFLICT (event_id) DO NOTHING
    `, [event.id]);
  } catch (err) {
    // If insert fails due to conflict, this is a duplicate — acknowledge and skip
    req.log?.info({ eventId: event.id }, 'Duplicate Stripe webhook, skipping');
    return res.status(200).json({ received: true, duplicate: true });
  }

  // Step 3 — Acknowledge immediately, process in background
  res.status(200).json({ received: true });

  // Step 4 — Queue the actual processing
  await webhookQueue.add('stripe-event', {
    eventId: event.id,
    type: event.type,
    data: event.data,
  }, {
    jobId: `stripe-${event.id}`,   // Prevents duplicate jobs in the queue too
    attempts: 3,
    backoff: { type: 'exponential', delay: 2000 },
  });
});
// src/workers/webhook.worker.js
const stripeHandlers = {
  'payment_intent.succeeded': async (data) => {
    const { object: paymentIntent } = data;
    await db.query(`
      UPDATE orders
      SET status = 'paid', paid_at = NOW(), stripe_payment_id = $1
      WHERE stripe_payment_intent_id = $2
        AND status != 'paid'   -- Idempotent: skip if already paid
    `, [paymentIntent.id, paymentIntent.id]);

    const order = await db.query(
      'SELECT * FROM orders WHERE stripe_payment_intent_id = $1',
      [paymentIntent.id]
    );

    if (order.rows[0]) {
      await emailQueue.add('order-confirmation', {
        orderId: order.rows[0].id,
        userId: order.rows[0].user_id,
      }, {
        jobId: `order-confirmation-${order.rows[0].id}`,  // No duplicate emails
      });
    }
  },

  'payment_intent.payment_failed': async (data) => {
    const { object: paymentIntent } = data;
    await db.query(`
      UPDATE orders SET status = 'payment_failed'
      WHERE stripe_payment_intent_id = $1 AND status = 'pending'
    `, [paymentIntent.id]);
  },

  'customer.subscription.deleted': async (data) => {
    const { object: subscription } = data;
    await db.query(`
      UPDATE users SET plan = 'free', subscription_ends_at = NOW()
      WHERE stripe_customer_id = $1
    `, [subscription.customer]);
  },
};

const webhookWorker = new Worker('webhooks', async (job) => {
  if (job.name === 'stripe-event') {
    const { type, data } = job.data;
    const handler = stripeHandlers[type];

    if (handler) {
      await handler(data);
    } else {
      // Log unhandled event types for future implementation
      logger.info({ eventType: type }, 'Unhandled Stripe event type');
    }
  }
}, { connection: bullMQConnection });

Pattern 2 — GitHub Webhooks

// src/routes/webhooks/github.js
import { createHmac, timingSafeEqual } from 'crypto';

const GITHUB_WEBHOOK_SECRET = process.env.GITHUB_WEBHOOK_SECRET;

function verifyGitHubSignature(payload, signature) {
  if (!signature) return false;

  const hmac = createHmac('sha256', GITHUB_WEBHOOK_SECRET);
  hmac.update(payload);
  const expectedSignature = `sha256=${hmac.digest('hex')}`;

  // timingSafeEqual prevents timing attacks
  try {
    return timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expectedSignature)
    );
  } catch {
    return false;  // Buffers different lengths — invalid signature
  }
}

router.post('/webhooks/github', async (req, res) => {
  const signature = req.headers['x-hub-signature-256'];
  const event = req.headers['x-github-event'];
  const deliveryId = req.headers['x-github-delivery'];

  if (!verifyGitHubSignature(req.body, signature)) {
    return res.status(401).json({ error: 'Invalid signature' });
  }

  // Acknowledge immediately
  res.status(200).json({ received: true });

  const payload = JSON.parse(req.body.toString());

  // Queue by delivery ID to prevent duplicate processing
  await webhookQueue.add('github-event', {
    event,
    deliveryId,
    payload,
  }, {
    jobId: `github-${deliveryId}`,
  });
});

Pattern 3 — Generic Webhook Receiver (Custom HMAC)

If you are building a service that sends webhooks to your customers, or receiving from a provider that uses standard HMAC-SHA256:

// src/lib/webhookVerifier.js
import { createHmac, timingSafeEqual } from 'crypto';

export function verifyHmacSignature({
  payload,        // Raw request body (Buffer)
  signature,      // Signature from header
  secret,         // Shared secret
  algorithm = 'sha256',
  prefix = '',    // Some providers prefix with "sha256="
}) {
  const hmac = createHmac(algorithm, secret);
  hmac.update(payload);
  const expected = `${prefix}${hmac.digest('hex')}`;

  try {
    return timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expected)
    );
  } catch {
    return false;
  }
}

The Idempotency Database Table

CREATE TABLE processed_webhooks (
  event_id    TEXT NOT NULL,
  source      TEXT NOT NULL,           -- 'stripe', 'github', etc.
  received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
  PRIMARY KEY (event_id, source)       -- Composite PK handles same ID from different sources
);

-- Automatically clean up old records after 30 days
-- Add to a cron job:
-- DELETE FROM processed_webhooks WHERE received_at < NOW() - INTERVAL '30 days';

CREATE INDEX idx_processed_webhooks_received ON processed_webhooks(received_at);

Testing Webhooks Locally

Use the Stripe CLI to forward real webhook events to your local server:

# Install Stripe CLI, then:
stripe listen --forward-to localhost:3000/webhooks/stripe

# Trigger a test event
stripe trigger payment_intent.succeeded

For GitHub, use smee.io or ngrok to expose your local server:

# ngrok
ngrok http 3000

# Then set your GitHub webhook URL to:
# https://your-ngrok-url.ngrok.io/webhooks/github

The Checklist

Every webhook endpoint should pass all of these:

✅ Signature verified before any processing
✅ Returns 200 within 2 seconds
✅ Event ID stored in DB to detect duplicates
✅ Actual processing happens in a background worker
✅ All DB updates use ON CONFLICT DO NOTHING or equivalent
✅ Emails and notifications use jobId deduplication
✅ Raw body captured before JSON parsing
✅ timingSafeEqual used for signature comparison (not ===)
✅ Unhandled event types logged, not errored

Webhooks that follow this pattern are indistinguishable from exactly-once delivery to the rest of your system — even when the provider retries 10 times.

Z

ZyVOP

Passionate developer sharing knowledge about modern web technologies and best practices.

Comments (0)

Login to post a comment.

Stay Updated

Get the latest articles delivered to your inbox.

We respect your privacy. Unsubscribe anytime.

Popular Tags

#.env.example Node.js#0x profiling#12-factor#AI agents#AI code security#AI coding tools 2026#AI-assisted development#AI-generated vulnerabilities#ALTER TABLE no lock#API Design