by InlinexDev

Handling Webhooks Reliably in Node.js: Patterns That Won't Lose Data

Battle-tested patterns for processing webhooks from Shopify, Stripe, WhatsApp, and other services without losing events or processing duplicates.

webhooksNode.jsreliabilityShopifyStripe

Why Webhooks Fail

Webhooks are the backbone of modern integrations. Shopify sends order events, Stripe sends payment updates, WhatsApp sends message notifications. But webhooks are inherently unreliable — your server might be down, your handler might crash, or the sender might retry causing duplicates.

After integrating webhooks from five different services across production applications, here are the patterns that prevent data loss.

Pattern 1: Respond First, Process Later

The most common mistake is processing the webhook synchronously before responding. If processing takes more than a few seconds, the sender times out and retries.

// BAD - Processing before responding
app.post('/webhooks/shopify', async (req, res) => {
  await syncOrder(req.body);      // Might take 5+ seconds
  await updateInventory(req.body); // Might take 3+ seconds
  res.sendStatus(200);             // Too late, Shopify already retried
});

// GOOD - Respond immediately, process asynchronously
app.post('/webhooks/shopify', async (req, res) => {
  res.sendStatus(200); // Respond within milliseconds
  
  try {
    await processWebhook(req.body);
  } catch (err) {
    console.error('Webhook processing failed:', err);
    await queueForRetry(req.body);
  }
});

Pattern 2: Idempotent Processing

Every webhook service retries on failure. Your handler must process the same event multiple times without side effects:

async function processWebhook(event) {
  const eventId = event.id || generateEventId(event);
  
  // Check if already processed
  const existing = await db.query(
    'SELECT id FROM webhook_events WHERE event_id = $1',
    [eventId]
  );
  
  if (existing.rowCount > 0) {
    console.log(`Skipping duplicate event: ${eventId}`);
    return;
  }
  
  // Process and record atomically
  await db.query('BEGIN');
  try {
    await db.query(
      'INSERT INTO webhook_events (event_id, type, payload, processed_at) VALUES ($1, $2, $3, NOW())',
      [eventId, event.type, JSON.stringify(event)]
    );
    await handleEvent(event);
    await db.query('COMMIT');
  } catch (err) {
    await db.query('ROLLBACK');
    throw err;
  }
}

Pattern 3: Signature Verification

Always verify that webhooks actually come from the claimed sender:

Shopify HMAC Verification

const crypto = require('crypto');

function verifyShopifyWebhook(req) {
  const hmac = req.headers['x-shopify-hmac-sha256'];
  const hash = crypto
    .createHmac('sha256', process.env.SHOPIFY_WEBHOOK_SECRET)
    .update(req.rawBody)
    .digest('base64');
  
  return crypto.timingSafeEqual(
    Buffer.from(hash),
    Buffer.from(hmac)
  );
}

Stripe Signature Verification

function verifyStripeWebhook(req) {
  try {
    return stripe.webhooks.constructEvent(
      req.rawBody,
      req.headers['stripe-signature'],
      process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (err) {
    throw new Error(`Webhook signature verification failed: ${err.message}`);
  }
}

Getting the Raw Body

Signature verification needs the raw request body, but Express middleware (like express.json()) parses it. Capture both:

app.use('/webhooks', express.raw({ 
  type: 'application/json',
  verify: (req, res, buf) => {
    req.rawBody = buf;
  }
}));

Pattern 4: Dead Letter Queue

When webhook processing fails after retries, store it for manual review:

async function queueForRetry(event, error) {
  await db.query(
    `INSERT INTO webhook_dead_letters 
     (event_id, payload, error, attempts, created_at) 
     VALUES ($1, $2, $3, 1, NOW())`,
    [event.id, JSON.stringify(event), error.message]
  );
}

// Retry dead letters periodically
async function retryDeadLetters() {
  const deadLetters = await db.query(
    `SELECT * FROM webhook_dead_letters 
     WHERE attempts < 5 AND created_at > NOW() - INTERVAL '24 hours'
     ORDER BY created_at ASC LIMIT 10`
  );
  
  for (const dl of deadLetters.rows) {
    try {
      await processWebhook(JSON.parse(dl.payload));
      await db.query('DELETE FROM webhook_dead_letters WHERE id = $1', [dl.id]);
    } catch (err) {
      await db.query(
        'UPDATE webhook_dead_letters SET attempts = attempts + 1, error = $2 WHERE id = $1',
        [dl.id, err.message]
      );
    }
  }
}

Pattern 5: Webhook Event Log

Log every incoming webhook for debugging:

app.use('/webhooks', (req, res, next) => {
  console.log(JSON.stringify({
    timestamp: new Date().toISOString(),
    path: req.path,
    headers: {
      'content-type': req.headers['content-type'],
      'x-shopify-topic': req.headers['x-shopify-topic'],
      'stripe-signature': req.headers['stripe-signature'] ? '[present]' : '[missing]'
    },
    bodySize: req.rawBody?.length || 0
  }));
  next();
});

Service-Specific Tips

| Service | Timeout | Retry Policy | ID Field | |---------|---------|-------------|----------| | Shopify | 5 seconds | Up to 19 retries over 48h | X-Shopify-Webhook-Id header | | Stripe | 20 seconds | Up to 3 days | event.id | | WhatsApp | 20 seconds | Exponential backoff | entry[0].id | | Shopee | 10 seconds | 3 retries | Request body hash |

Monitoring

Set up alerts for:

  • Webhook handler response time exceeding 1 second
  • Dead letter queue size exceeding threshold
  • Signature verification failures (potential attacks)
  • Missing expected webhooks (sender-side issues)

Conclusion

Reliable webhook handling isn't glamorous, but it's the foundation of every integration. Respond immediately, verify signatures, process idempotently, and log everything. These patterns have prevented data loss across thousands of webhook events in production.