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 Technical Guide)

Webhooks are essential for modern payment processing systems, and Stripe's implementation is one of the most robust in the industry. 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's systems (like successful payments or failed charges), they send HTTP POST requests to your configured endpoint. The critical components:

  1. Event Object: JSON payload containing event metadata and relevant object (charge, customer, etc.)
  2. Signature Verification: HMAC signature to verify request authenticity
  3. Idempotency: Handling duplicate events safely

Initial Setup

First, install the required dependencies:

npm install stripe express body-parser crypto-js
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 get raw body for signature verification
app.use(bodyParser.json({
  verify: (req, res, buf) => {
    req.rawBody = buf.toString();
  }
}));

const PORT = process.env.PORT || 3000;
const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET;

app.listen(PORT, () => console.log(`Webhook listener on port ${PORT}`));
Enter fullscreen mode Exit fullscreen mode

Signature Verification

The most critical security aspect is verifying the webhook signature:

function verifyStripeSignature(req) {
  const signature = req.headers['stripe-signature'];
  if (!signature) throw new Error('No signature found');

  const signedPayload = `${signature.t},${req.rawBody}`;
  const expectedSignature = crypto
    .createHmac('sha256', WEBHOOK_SECRET)
    .update(signedPayload, 'utf8')
    .digest('hex');

  if (!crypto.timingSafeEqual(
    Buffer.from(signature.v1, 'hex'),
    Buffer.from(expectedSignature, 'hex')
  )) {
    throw new Error('Invalid signature');
  }
}
Enter fullscreen mode Exit fullscreen mode

Event Processing Architecture

For production systems, we need robust event handling:

// Event processor map
const eventHandlers = {
  'payment_intent.succeeded': handleSuccessfulPayment,
  'charge.failed': handleFailedCharge,
  'customer.subscription.deleted': handleSubscriptionCanceled,
  // Add more event types as needed
};

async function processStripeEvent(event) {
  const handler = eventHandlers[event.type];
  if (!handler) {
    console.warn(`Unhandled event type: ${event.type}`);
    return { processed: false };
  }

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

Implementing Idempotency

Stripe may send duplicate events - our handlers must be idempotent:

const processedEvents = new Set();

async function handleWebhook(req, res) {
  try {
    verifyStripeSignature(req);

    const event = req.body;

    // Check for duplicate events
    if (processedEvents.has(event.id)) {
      return res.status(200).json({ received: true });
    }

    // Process the event
    const result = await processStripeEvent(event);

    // Store processed event ID
    if (result.processed) {
      processedEvents.add(event.id);
      // In production, use Redis or database for persistence
    }

    res.status(200).json({ received: true });
  } catch (err) {
    console.error('Webhook error:', err);
    res.status(400).json({ error: err.message });
  }
}

app.post('/webhook', handleWebhook);
Enter fullscreen mode Exit fullscreen mode

Example Event Handlers

Let's implement concrete handlers for common scenarios:

1. Successful Payment Handler

async function handleSuccessfulPayment(event) {
  const paymentIntent = event.data.object;

  // Business logic implementation
  await fulfillOrder(paymentIntent.metadata.orderId);

  // Log for analytics
  trackPaymentEvent(paymentIntent);

  // Update database
  await updateOrderStatus(paymentIntent.metadata.orderId, 'paid');
}
Enter fullscreen mode Exit fullscreen mode

2. Failed Charge Handler

async function handleFailedCharge(event) {
  const charge = event.data.object;

  // Notify customer
  await sendPaymentFailedEmail(charge.customer);

  // Update inventory or retry logic
  await releaseInventoryHold(charge.metadata.orderId);

  // Log for fraud analysis
  logFailedPaymentAttempt(charge);
}
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. Error Handling: Implement retry logic with exponential backoff
  2. Queue Processing: Use Redis or SQS for event queuing
  3. Logging: Structured logging for all events
  4. Monitoring: Alert on failed webhook deliveries
  5. Scaling: Horizontal scaling with proper event deduplication

Complete Webhook Handler

Here's the full implementation:

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();
app.use(bodyParser.json({
  verify: (req, res, buf) => {
    req.rawBody = buf.toString();
  }
}));

const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET;
const processedEvents = new Set();

// Verification middleware
const verifyStripeWebhook = (req, res, next) => {
  try {
    const signature = req.headers['stripe-signature'];
    if (!signature) throw new Error('No signature found');

    const signedPayload = `${signature.t},${req.rawBody}`;
    const expectedSignature = crypto
      .createHmac('sha256', WEBHOOK_SECRET)
      .update(signedPayload, 'utf8')
      .digest('hex');

    if (!crypto.timingSafeEqual(
      Buffer.from(signature.v1, 'hex'),
      Buffer.from(expectedSignature, 'hex')
    )) {
      throw new Error('Invalid signature');
    }
    next();
  } catch (err) {
    res.status(403).json({ error: err.message });
  }
};

// Event handlers
const eventHandlers = {
  'payment_intent.succeeded': async (event) => {
    const paymentIntent = event.data.object;
    console.log(`Payment succeeded for ${paymentIntent.amount}`);
    // Implement your business logic
  },
  'charge.failed': async (event) => {
    const charge = event.data.object;
    console.warn(`Charge failed for ${charge.amount}`);
    // Implement failure handling
  }
};

// Webhook endpoint
app.post('/webhook', verifyStripeWebhook, async (req, res) => {
  const event = req.body;

  if (processedEvents.has(event.id)) {
    return res.status(200).json({ received: true });
  }

  try {
    const handler = eventHandlers[event.type];
    if (handler) {
      await handler(event);
      processedEvents.add(event.id);
    }
    res.status(200).json({ received: true });
  } catch (err) {
    console.error('Webhook processing error:', err);
    res.status(400).json({ error: 'Processing failed' });
  }
});

app.listen(3000, () => console.log('Webhook listener running'));
Enter fullscreen mode Exit fullscreen mode

Advanced Pattern: Event Sourcing

For complex systems, consider an event-sourcing approach:

class StripeEventStore {
  constructor() {
    this.events = [];
  }

  async processEvent(event) {
    if (this.hasProcessed(event.id)) return;

    // Validate event structure
    if (!this.isValidEvent(event)) {
      throw new Error('Invalid event structure');
    }

    // Persist event
    await this.persistEvent(event);

    // Process in pipeline
    await this.applyEvent(event);

    // Mark as processed
    this.markProcessed(event.id);
  }

  async applyEvent(event) {
    // Implement your event processing pipeline
    switch(event.type) {
      case 'invoice.paid':
        await this.handleInvoicePaid(event);
        break;
      // Add more cases
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Monitoring and Alerting

Implement proper monitoring:

const { createLogger, transports } = require('winston');

const logger = createLogger({
  transports: [
    new transports.Console(),
    new transports.File({ filename: 'webhook.log' })
  ]
});

// Instrument the webhook handler
app.post('/webhook', async (req, res) => {
  const start = Date.now();
  try {
    // ... existing handler code
    logger.info('Webhook processed', {
      eventId: event.id,
      type: event.type,
      duration: Date.now() - start
    });
  } catch (err) {
    logger.error('Webhook failed', {
      error: err.message,
      event: event.id,
      stack: err.stack
    });
  }
});
Enter fullscreen mode Exit fullscreen mode

Conclusion

Building a production-ready Stripe webhook handler requires careful attention to:

  1. Security through proper signature verification
  2. Idempotency to handle duplicate events
  3. Robust error handling and logging
  4. Scalable architecture for high-volume systems

The implementation shown provides a solid foundation that can be extended with additional event types, retry logic, and integration with your business workflow. Always test webhooks thoroughly using Stripe's test mode before deploying to production.

Remember that webhook processing should generally be fast - if you need to perform long-running operations, consider queuing the event and processing it asynchronously.


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