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 elegant. In this deep dive, I'll walk through building a production-grade Stripe webhook handler in Node.js with proper security, error handling, and queue processing.

Understanding Stripe Webhook Architecture

Stripe webhooks operate via HTTP POST requests to your endpoint when events occur in your Stripe account. The critical components:

  1. Event Object: JSON payload containing event metadata and relevant object data
  2. Signature Verification: HMAC-based security using your webhook secret
  3. Idempotency: Handling duplicate events safely
  4. Retry Logic: Built-in exponential backoff from Stripe

Initial Setup

First, install the Stripe Node.js library:

npm install stripe @types/stripe
Enter fullscreen mode Exit fullscreen mode

Configure your environment variables (I recommend using dotenv):

STRIPE_SECRET_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
Enter fullscreen mode Exit fullscreen mode

Core Webhook Handler Implementation

Here's the foundation of our webhook handler:

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

const app = express();

// Stripe requires raw body for signature verification
app.post('/webhook', bodyParser.raw({type: 'application/json'}), async (req, res) => {
  const sig = req.headers['stripe-signature'];
  let event;

  try {
    event = stripe.webhooks.constructEvent(
      req.body,
      sig,
      process.env.STRIPE_WEBHOOK_SECRET
    );
  } catch (err) {
    console.error(`Webhook signature verification failed: ${err.message}`);
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }

  // Handle the event
  switch (event.type) {
    case 'payment_intent.succeeded':
      await handlePaymentIntentSucceeded(event.data.object);
      break;
    case 'charge.failed':
      await handleChargeFailed(event.data.object);
      break;
    // ... handle other event types
    default:
      console.log(`Unhandled event type ${event.type}`);
  }

  // Acknowledge receipt
  res.json({received: true});
});
Enter fullscreen mode Exit fullscreen mode

Advanced Security Considerations

Replay Attack Protection

Stripe includes a timestamp in the signature header. We should verify it's recent:

const MAX_TOLERANCE_SECONDS = 300; // 5 minutes

function verifyTimestamp(sig) {
  const [, timestamp] = sig.split(',')[0].split('=');
  const timeDiff = Math.floor(Date.now() / 1000) - parseInt(timestamp);
  return timeDiff <= MAX_TOLERANCE_SECONDS;
}
Enter fullscreen mode Exit fullscreen mode

Secret Rotation

Implement webhook secret rotation:

async function getWebhookSecret(version = 'latest') {
  // In production, fetch from secure storage
  const secrets = {
    'v1': process.env.STRIPE_WEBHOOK_SECRET_V1,
    'v2': process.env.STRIPE_WEBHOOK_SECRET_V2,
    'latest': process.env.STRIPE_WEBHOOK_SECRET
  };
  return secrets[version];
}

// Modified verification:
try {
  const secret = await getWebhookSecret();
  event = stripe.webhooks.constructEvent(req.body, sig, secret);
} catch (err) {
  // Try with previous version if latest fails
  try {
    const secret = await getWebhookSecret('v1');
    event = stripe.webhooks.constructEvent(req.body, sig, secret);
  } catch (err2) {
    throw err;
  }
}
Enter fullscreen mode Exit fullscreen mode

Event Processing Architecture

For production workloads, you'll want to:

  1. Validate the event
  2. Push to a queue
  3. Process asynchronously

Here's a Redis-based queue implementation:

const { createClient } = require('redis');
const redisClient = createClient({ url: process.env.REDIS_URL });

async function enqueueStripeEvent(event) {
  await redisClient.connect();
  try {
    await redisClient.lPush(
      'stripe_events',
      JSON.stringify({
        id: event.id,
        type: event.type,
        data: event.data,
        created: event.created
      })
    );
  } finally {
    await redisClient.disconnect();
  }
}

// Worker process example
async function processStripeEvents() {
  await redisClient.connect();
  while (true) {
    const eventStr = await redisClient.rPop('stripe_events');
    if (!eventStr) {
      await new Promise(resolve => setTimeout(resolve, 1000));
      continue;
    }

    const event = JSON.parse(eventStr);
    try {
      await handleEvent(event);
    } catch (err) {
      console.error(`Failed processing event ${event.id}:`, err);
      // Implement retry logic here
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Idempotency Handling

Stripe events can be delivered multiple times. Implement idempotency:

const { MongoClient } = require('mongodb');
const client = new MongoClient(process.env.MONGODB_URI);

async function ensureIdempotency(eventId, handler) {
  await client.connect();
  const db = client.db('stripe_webhooks');
  const collection = db.collection('processed_events');

  const existing = await collection.findOne({ eventId });
  if (existing) {
    console.log(`Event ${eventId} already processed`);
    return existing.result;
  }

  try {
    const result = await handler();
    await collection.insertOne({
      eventId,
      processedAt: new Date(),
      result
    });
    return result;
  } catch (err) {
    await collection.insertOne({
      eventId,
      processedAt: new Date(),
      error: err.message
    });
    throw err;
  }
}

// Usage:
await ensureIdempotency(event.id, () => handlePaymentIntentSucceeded(event.data.object));
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

For automated tests, mock the signature header:

const crypto = require('crypto');

function generateTestSignature(payload, secret) {
  const timestamp = Math.floor(Date.now() / 1000);
  const signedPayload = `${timestamp}.${payload}`;
  const signature = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');
  return `t=${timestamp},v1=${signature}`;
}
Enter fullscreen mode Exit fullscreen mode

Production Deployment Considerations

  1. HTTPS: Mandatory for production webhooks
  2. Rate Limiting: Protect your endpoint
  3. Scaling: Use Kubernetes or similar for worker processes
  4. Monitoring: Track event processing metrics

Example monitoring setup:

const Prometheus = require('prom-client');
const eventCounter = new Prometheus.Counter({
  name: 'stripe_events_total',
  help: 'Count of Stripe webhook events',
  labelNames: ['type', 'status']
});

// In your handler:
eventCounter.inc({ type: event.type, status: 'received' });

try {
  await handleEvent(event);
  eventCounter.inc({ type: event.type, status: 'processed' });
} catch (err) {
  eventCounter.inc({ type: event.type, status: 'failed' });
  throw err;
}
Enter fullscreen mode Exit fullscreen mode

Complete Production Example

Here's a consolidated production-ready version:

const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const { createClient } = require('redis');
const { MongoClient } = require('mongodb');
const crypto = require('crypto');
const bodyParser = require('body-parser');

const app = express();
const redisClient = createClient({ url: process.env.REDIS_URL });
const mongoClient = new MongoClient(process.env.MONGODB_URI);

// Middleware to verify Stripe signature
async function verifyStripeWebhook(req, res, next) {
  const sig = req.headers['stripe-signature'];
  if (!sig) return res.status(401).send('No signature provided');

  try {
    const secret = await getWebhookSecret();
    req.stripeEvent = stripe.webhooks.constructEvent(
      req.body,
      sig,
      secret
    );
    next();
  } catch (err) {
    console.error('Signature verification failed:', err);
    return res.status(401).send('Invalid signature');
  }
}

// Main webhook handler
app.post(
  '/webhook',
  bodyParser.raw({ type: 'application/json' }),
  verifyStripeWebhook,
  async (req, res) => {
    try {
      await enqueueStripeEvent(req.stripeEvent);
      res.json({ received: true });
    } catch (err) {
      console.error('Event processing failed:', err);
      res.status(500).json({ error: 'Internal server error' });
    }
  }
);

// Start workers
async function startWorkers(count = 3) {
  await redisClient.connect();
  await mongoClient.connect();

  for (let i = 0; i < count; i++) {
    processStripeEvents().catch(err => {
      console.error(`Worker ${i} failed:`, err);
    });
  }
}

startWorkers();

app.listen(3000, () => {
  console.log('Webhook handler listening on port 3000');
});
Enter fullscreen mode Exit fullscreen mode

Final Thoughts

Building a robust Stripe webhook handler requires attention to:

  1. Security: Proper signature verification
  2. Reliability: Queue processing and idempotency
  3. Observability: Monitoring and logging
  4. Scalability: Horizontal scaling for high-volume events

The implementation above addresses all these concerns while maintaining clean, maintainable code. Remember to adjust queue processing and worker counts based on your actual event volume.

For further optimization, consider:

  • Event batching for high-volume scenarios
  • Dead-letter queues for failed events
  • Circuit breakers for downstream service failures
  • Canary deployments for webhook handler updates

🚀 Stop Writing Boilerplate Prompts

If you want to skip the setup and code 10x faster with complete AI architecture patterns, grab my Senior React Developer AI Cookbook ($19). It includes Server Action prompt libraries, UI component generation loops, and hydration debugging strategies.

Browse all 10+ developer products at the Apollo AI Store | Or snipe Solana tokens free via @ApolloSniper_Bot.

Top comments (0)