ZyVOP Logo
Content That Connects
SeriesAI NewsWhy ZyVOPJoin Discord
LoginGet Started
ZyVOP Logo
Content That Connects

The Developer Publishing Hub. Write once, cross-post to Dev.to, Medium, Hashnode, WordPress & Bluesky with automated canonical source tags and zero paywalls.

Content

  • Categories
  • Tags
  • Badges
  • Leaderboard
  • Write Article
  • Newsletter

Company

  • About Us
  • Why ZyVOP
  • Developer API & CLI
  • Write for Us
  • Contact

Connect

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

© 2026 ZyVOP. Developer Publishing Hub.

Zero paywalls · Full content ownership
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

ZyVOP
ZyVOP
Senior Developer
May 25, 2026
5 min read
Receiving Webhooks Safely in Node.js (Stripe, GitHub, and Everything Else)
#Node.js webhooks#GitHub webhook Node.js#webhook signature verification#Stripe webhook verification#duplicate webhook handling#BullMQ webhooks 2026#idempotent webhooks

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.

Comments (0)

Login to post a comment.

ZyVOP
ZyVOP

Founder of Zyvop 🚀 | Building AI-driven tools & premium insights for software engineers, CTOs, and tech leaders. Obsessed with automating workflows and exploring the frontier of AI.

Subscribe to ZyVOP's Newsletter

More from ZyVOP

View profile

Debian Adopts "Responsible Use of Generative AI" After Nine-Way Condorcet Vote

Debian's General Resolution 2026-002 closed on August 28 with "Responsible Use of Generative AI" beating eight rival proposals, including a Social Contract ban, by a clear Condorcet margin, per the project secretary's published beat matrix.

3 minAug 30

How I Built a Real-Time Developer Trend Radar Into My SEO Growth Engine

An AI-powered content intelligence system that streams live developer conversations from Hacker News, Dev.to, Google Search, and GitHub — and turns them into ready-to-write blog opportunities with one click.

12 minAug 29

Qwen3.8-Flash-Next Cost Efficiency, OpenExecutive Satire, and Multi-Vector Retrieval Advances

This week's digest covers Qwen3.8-Flash-Next's push for ultimate cost-efficiency, the viral OpenExecutive project, and the technical release of MultiVectorEncoder in Sentence-Transformers v6.0.

4 minAug 28

Anthropic’s Pricing Shock, Granite 4.2 Open‑Source Leap, and AI‑Powered Security & Policy Shifts

From Anthropic’s flagship model losing steam to IBM’s 512 K‑token Granite 4.2, plus a new wave of AI‑driven security exploits and policy alarms, this week’s digest maps the technical and market forces you need to act on now.

3 minAug 26

Introducing Questions and Discussions: A New Way to Connect!

We are thrilled to announce a major update to how you can interact and share content on our platform! Up until now, sharing your thoughts meant writing a standa...

2 minAug 1