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. In this technical deep dive, I'll show you how to implement a production-grade Stripe webhook handler in Node.js with proper security, error handling, and idempotency.

Webhook Architecture Fundamentals

Stripe webhooks use HTTP POST requests to notify your server about events. The critical components we need to implement:

  1. Endpoint verification - Validate Stripe signatures
  2. Idempotency - Handle duplicate events
  3. Error resilience - Queue failed events for retry
  4. Event processing - Business logic execution

Initial Setup

First, install required dependencies:

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

Create a basic Express server:

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

const app = express();

// Stripe requires raw body for signature verification
app.post('/webhook', 
  bodyParser.raw({type: 'application/json'}),
  handleStripeWebhook
);

app.listen(4242, () => console.log('Listening on port 4242'));
Enter fullscreen mode Exit fullscreen mode

Signature Verification

Security is critical - we must verify requests actually come from Stripe:

async function handleStripeWebhook(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}`);
  }

  // Process the verified event
  await processEvent(event);

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

Event Processing with Idempotency

We need to handle duplicate events safely:

const processedEvents = new Set();

async function processEvent(event) {
  // Check for duplicate events
  if (processedEvents.has(event.id)) {
    console.log(`Event ${event.id} already processed`);
    return;
  }

  try {
    switch (event.type) {
      case 'payment_intent.succeeded':
        await handlePaymentSuccess(event.data.object);
        break;
      case 'charge.failed':
        await handleChargeFailure(event.data.object);
        break;
      // Add other event types as needed
      default:
        console.log(`Unhandled event type ${event.type}`);
    }

    // Mark event as processed
    processedEvents.add(event.id);
  } catch (err) {
    console.error(`Error processing event ${event.id}:`, err);
    throw err; // Will trigger Stripe's automatic retry
  }
}
Enter fullscreen mode Exit fullscreen mode

Handling Payment Events

Here's how to implement business logic for common events:

async function handlePaymentSuccess(paymentIntent) {
  // Extract relevant data
  const { id, amount, customer, metadata } = paymentIntent;

  console.log(`Payment ${id} succeeded for ${amount}`);

  // Update your database
  await db.payments.updateOne(
    { paymentId: id },
    { $set: { status: 'completed' } }
  );

  // Fulfill order
  if (metadata.orderId) {
    await fulfillOrder(metadata.orderId);
  }
}

async function handleChargeFailure(charge) {
  console.log(`Charge failed: ${charge.id}`);

  // Notify customer
  if (charge.customer) {
    await sendFailedPaymentEmail(charge.customer);
  }
}
Enter fullscreen mode Exit fullscreen mode

Production Considerations

1. Event Queueing

For production, use a proper queue system instead of in-memory Set:

const { Worker } = require('bullmq');

const eventQueue = new Worker('stripe-events', async job => {
  const event = job.data;
  await processEvent(event);
}, {
  connection: redisClient,
  limiter: { max: 10, duration: 1000 } // Rate limiting
});
Enter fullscreen mode Exit fullscreen mode

2. Database Integration

Store events for auditing:

async function storeEvent(event) {
  await db.events.insertOne({
    id: event.id,
    type: event.type,
    data: event.data,
    created: new Date(event.created * 1000),
    processedAt: new Date()
  });
}
Enter fullscreen mode Exit fullscreen mode

3. Error Handling and Retries

Implement exponential backoff for failed events:

async function processWithRetry(event, attempt = 1) {
  try {
    await processEvent(event);
  } catch (err) {
    if (attempt >= 3) {
      await db.failedEvents.insertOne({ event, error: err.message });
      return;
    }

    const delay = Math.pow(2, attempt) * 1000;
    await new Promise(resolve => setTimeout(resolve, delay));
    return processWithRetry(event, attempt + 1);
  }
}
Enter fullscreen mode Exit fullscreen mode

Testing Webhooks Locally

Use the Stripe CLI to test:

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

Trigger test events:

stripe trigger payment_intent.succeeded
Enter fullscreen mode Exit fullscreen mode

Complete Implementation

Here's the full production-ready version:

const express = require('express');
const bodyParser = require('body-parser');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const { Worker } = require('bullmq');
const redis = require('redis');

const app = express();
const redisClient = redis.createClient(process.env.REDIS_URL);

// Event queue for processing
const eventQueue = new Worker('stripe-events', processEventJob, {
  connection: redisClient
});

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

    try {
      const event = stripe.webhooks.constructEvent(
        req.body,
        sig,
        process.env.STRIPE_WEBHOOK_SECRET
      );

      // Add to queue for processing
      await eventQueue.add(event.id, event);

      res.json({received: true});
    } catch (err) {
      console.error(`Webhook error: ${err.message}`);
      res.status(400).send(`Webhook Error: ${err.message}`);
    }
  }
);

async function processEventJob(job) {
  const event = job.data;

  try {
    await storeEvent(event);

    switch (event.type) {
      case 'payment_intent.succeeded':
        await handlePaymentSuccess(event.data.object);
        break;
      // Other event handlers...
    }

  } catch (err) {
    console.error(`Failed to process event ${event.id}:`, err);
    throw err; // Will trigger BullMQ's retry mechanism
  }
}

app.listen(4242, () => console.log('Listening on port 4242'));
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. Always verify webhook signatures
  2. Implement idempotency to prevent duplicate processing
  3. Use queue systems for reliable event processing
  4. Store events for auditing and debugging
  5. Implement proper error handling and retries

This implementation handles all critical aspects of production-grade Stripe webhook processing while maintaining security and reliability. The queuing system ensures no events are lost during failures, and the idempotency checks prevent duplicate processing.

Remember to monitor your webhook processing and set up alerts for failed events that exceed your retry thresholds.


🚀 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)