DEV Community

Apollo
Apollo

Posted on

How I built a Stripe Webhook in Node.js (Full Guide)

How I Built a Stripe Webhook in Node.js (Full Guide)

Webhooks are essential for modern payment processing systems, and Stripe's implementation is particularly powerful. In this deep dive, I'll walk through building a production-grade Stripe webhook handler in Node.js that handles events securely and efficiently.

Understanding Stripe Webhook Architecture

Stripe webhooks operate on a push model - when events occur in Stripe (like successful payments or failed charges), Stripe sends HTTP POST requests to your configured endpoint. The critical components:

  1. Endpoint Verification: Validating requests actually come from Stripe
  2. Event Processing: Handling different event types appropriately
  3. Idempotency: Ensuring duplicate events don't cause duplicate actions
  4. Error Handling: Gracefully managing failures

Initial Setup

First, install required dependencies:

npm install stripe express body-parser crypto
Enter fullscreen mode Exit fullscreen mode

Here's our basic server structure:

const express = require('express');
const bodyParser = require('body-parser');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const crypto = require('crypto');

const app = express();

// Middleware to verify Stripe webhook signatures
const verifyStripeSignature = (req, res, next) => {
  const signature = req.headers['stripe-signature'];
  if (!signature) {
    return res.status(400).send('No signature header');
  }

  try {
    const event = stripe.webhooks.constructEvent(
      req.body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET
    );
    req.stripeEvent = event;
    next();
  } catch (err) {
    console.error('Signature verification failed:', err);
    return res.status(400).send('Invalid signature');
  }
};

app.use(bodyParser.json({
  verify: (req, res, buf) => {
    req.rawBody = buf.toString();
  }
}));

app.post('/webhook', verifyStripeSignature, async (req, res) => {
  const event = req.stripeEvent;

  try {
    await handleStripeEvent(event);
    res.json({ received: true });
  } catch (err) {
    console.error('Error handling webhook:', err);
    res.status(500).send('Error processing webhook');
  }
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => console.log(`Webhook listening on port ${PORT}`));
Enter fullscreen mode Exit fullscreen mode

Signature Verification Deep Dive

The most critical security aspect is verifying the webhook actually came from Stripe. Here's what happens under the hood:

  1. Stripe generates a signature using your webhook signing secret
  2. The signature is a HMAC-SHA256 hash of:
    • The timestamp
    • A period (.)
    • The raw request body

Our implementation uses Stripe's SDK method, but here's the manual verification process:

function verifySignatureManual(rawBody, signature, secret) {
  const [timestamp, signatures] = signature.split(',')
    .reduce((acc, item) => {
      const [key, value] = item.split('=');
      acc[key] = value;
      return acc;
    }, {});

  const signedPayload = `${timestamp}.${rawBody}`;
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(signatures),
    Buffer.from(expectedSignature)
  );
}
Enter fullscreen mode Exit fullscreen mode

Event Processing Architecture

For production systems, we need a robust event processor. Here's an advanced pattern:

const eventHandlers = {
  'payment_intent.succeeded': async (event) => {
    const paymentIntent = event.data.object;
    await fulfillOrder(paymentIntent);
    await sendConfirmationEmail(paymentIntent);
  },
  'payment_intent.payment_failed': async (event) => {
    const paymentIntent = event.data.object;
    await handleFailedPayment(paymentIntent);
  },
  'charge.refunded': async (event) => {
    const charge = event.data.object;
    await processRefund(charge);
  }
};

async function handleStripeEvent(event) {
  const handler = eventHandlers[event.type];

  if (!handler) {
    console.log(`Unhandled event type: ${event.type}`);
    return;
  }

  try {
    await handler(event);
  } catch (err) {
    console.error(`Error handling ${event.type}:`, err);
    throw err;
  }
}
Enter fullscreen mode Exit fullscreen mode

Implementing Idempotency

Stripe may resend webhooks, so we need to prevent duplicate processing:

const processedEvents = new Set();

async function handleStripeEvent(event) {
  if (processedEvents.has(event.id)) {
    console.log(`Event ${event.id} already processed`);
    return;
  }

  // Process event...

  // Store in memory (for demo - use Redis in production)
  processedEvents.add(event.id);

  // Set TTL for automatic cleanup (24 hours)
  setTimeout(() => {
    processedEvents.delete(event.id);
  }, 86400000);
}
Enter fullscreen mode Exit fullscreen mode

For production, use Redis with:

const redis = require('redis');
const client = redis.createClient();

async function isEventProcessed(eventId) {
  return new Promise((resolve) => {
    client.exists(`stripe_event:${eventId}`, (err, reply) => {
      resolve(reply === 1);
    });
  });
}

async function markEventProcessed(eventId) {
  return new Promise((resolve) => {
    client.set(`stripe_event:${eventId}`, '1', 'EX', 86400, (err) => {
      resolve(!err);
    });
  });
}
Enter fullscreen mode Exit fullscreen mode

Advanced Error Handling

Production systems need comprehensive error handling:

const RETRYABLE_ERRORS = ['ETIMEDOUT', 'ECONNRESET', 'ENOTFOUND'];
const MAX_RETRIES = 3;

async function handleWithRetry(handler, event, attempt = 1) {
  try {
    await handler(event);
  } catch (err) {
    if (RETRYABLE_ERRORS.includes(err.code) && attempt <= MAX_RETRIES) {
      console.log(`Retrying ${event.type} (attempt ${attempt})`);
      await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
      return handleWithRetry(handler, event, attempt + 1);
    }
    throw err;
  }
}

// Usage in handleStripeEvent:
await handleWithRetry(handler, event);
Enter fullscreen mode Exit fullscreen mode

Testing Webhooks Locally

Use the Stripe CLI for local testing:

stripe listen --forward-to localhost:3000/webhook
Enter fullscreen mode Exit fullscreen mode

Trigger test events:

stripe trigger payment_intent.succeeded
Enter fullscreen mode Exit fullscreen mode

Production Considerations

  1. Queue Processing: For heavy loads, use a queue system:
const { Worker } = require('bullmq');

const worker = new Worker('stripe-events', async job => {
  await handleStripeEvent(job.data);
}, {
  connection: { host: 'redis' },
  limiter: { max: 10, duration: 1000 }
});
Enter fullscreen mode Exit fullscreen mode
  1. Logging: Implement structured logging:
const { createLogger, transports } = require('winston');

const logger = createLogger({
  transports: [
    new transports.Console({
      format: format.combine(
        format.timestamp(),
        format.json()
      )
    })
  ]
});

// Usage:
logger.info('Processing Stripe event', { 
  eventId: event.id,
  type: event.type 
});
Enter fullscreen mode Exit fullscreen mode
  1. Monitoring: Track key metrics:
const client = require('prom-client');
const gauge = new client.Gauge({
  name: 'stripe_webhook_events',
  help: 'Count of Stripe webhook events by type',
  labelNames: ['type']
});

// In handleStripeEvent:
gauge.set({ type: event.type }, 1);
Enter fullscreen mode Exit fullscreen mode

Complete Production Example

Here's a consolidated production-ready implementation:

const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const Redis = require('ioredis');
const { Worker } = require('bullmq');
const { createLogger, transports, format } = require('winston');

// Initialize services
const redis = new Redis(process.env.REDIS_URL);
const logger = createLogger({ /* ... */ });

// Webhook verification middleware
const verifySignature = async (req, res, next) => {
  try {
    const event = stripe.webhooks.constructEvent(
      req.rawBody,
      req.headers['stripe-signature'],
      process.env.STRIPE_WEBHOOK_SECRET
    );
    req.stripeEvent = event;
    next();
  } catch (err) {
    logger.error('Webhook verification failed', { error: err.message });
    res.status(400).send('Invalid signature');
  }
};

// Queue processor
const worker = new Worker('stripe-events', async job => {
  const event = job.data;
  if (await redis.get(`event:${event.id}`)) {
    return logger.info('Duplicate event ignored', { eventId: event.id });
  }

  try {
    await handleEvent(event);
    await redis.set(`event:${event.id}`, 'processed', 'EX', 86400);
  } catch (err) {
    logger.error('Event processing failed', { 
      eventId: event.id,
      error: err.message
    });
    throw err;
  }
});

// Express setup
const app = express();
app.use(express.json({ verify: (req, _, buf) => { req.rawBody = buf; } }));
app.post('/webhook', verifySignature, async (req, res) => {
  await worker.add(req.stripeEvent.type, req.stripeEvent);
  res.sendStatus(200);
});

app.listen(3000, () => logger.info('Webhook server running'));
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. Always verify webhook signatures using your endpoint secret
  2. Implement idempotency to handle duplicate events
  3. Use queues for reliable event processing under load
  4. Monitor and log all webhook activity
  5. Handle errors gracefully with retry mechanisms

This implementation gives you a solid foundation for processing Stripe webhooks in production. The patterns can be extended to handle more complex workflows and integrated with your existing infrastructure.


Stop Reinventing The Wheel

If you want to skip the boilerplate and launch your app today, check out my Ultimate AI Micro-SaaS Boilerplate ($49). It includes full Stripe integration, Next.js, and an external API suite.

Or, let my AI teardown your existing funnels at Apollo Roaster.

Top comments (0)