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 walk through building a production-grade Stripe webhook handler in Node.js that handles events securely and efficiently.

Webhook Architecture Fundamentals

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

  1. Endpoint Verification: Verify requests originate from Stripe
  2. Event Processing: Handle different event types appropriately
  3. Idempotency: Prevent duplicate processing
  4. Error Handling: Graceful failure and retry logic

Step 1: Project Setup

First, initialize a Node.js project and install dependencies:

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

Step 2: Webhook Endpoint Implementation

Create webhook.js with this foundation:

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

const app = express();

// Middleware to verify raw body for signature validation
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}`);
  }

  // Process the event
  await handleWebhookEvent(event);

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

Step 3: Event Processing Logic

Implement the event handler with proper type checking:

const eventHandlers = {
  'payment_intent.succeeded': handlePaymentSuccess,
  'payment_intent.payment_failed': handlePaymentFailure,
  'charge.refunded': handleRefund,
  'customer.subscription.deleted': handleSubscriptionCancel
};

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

  if (handler) {
    try {
      await handler(event.data.object);
    } catch (err) {
      console.error(`Error processing ${event.type}:`, err);
      throw err; // Will trigger Stripe retry
    }
  } else {
    console.log(`Unhandled event type: ${event.type}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Implementing Event Handlers

Here's how to implement specific handlers:

async function handlePaymentSuccess(paymentIntent) {
  // Verify idempotency
  if (await checkIfProcessed(paymentIntent.id)) {
    console.log(`Payment ${paymentIntent.id} already processed`);
    return;
  }

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

  console.log(`Successfully processed payment ${paymentIntent.id}`);
}

async function handleSubscriptionCancel(subscription) {
  await revokeAccess(subscription.customer);
  await sendCancellationEmail(subscription.customer);

  console.log(`Processed cancellation for ${subscription.id}`);
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Idempotency Implementation

Prevent duplicate processing with Redis:

const redis = require('redis');
const client = redis.createClient(process.env.REDIS_URL);

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

async function markAsProcessed(eventId) {
  // Store for 72 hours (Stripe's retry window)
  client.setex(`processed:${eventId}`, 72 * 3600, '1');
}
Enter fullscreen mode Exit fullscreen mode

Step 6: Advanced Error Handling

Implement exponential backoff for retries:

async function withRetry(fn, maxRetries = 3, attempt = 1) {
  try {
    return await fn();
  } catch (err) {
    if (attempt >= maxRetries) throw err;

    const delay = Math.pow(2, attempt) * 1000;
    await new Promise(resolve => setTimeout(resolve, delay));

    return withRetry(fn, maxRetries, attempt + 1);
  }
}

// Usage in handler:
await withRetry(() => fulfillOrder(orderId));
Enter fullscreen mode Exit fullscreen mode

Step 7: Testing Webhooks Locally

Use the Stripe CLI for local testing:

stripe listen --forward-to localhost:3000/webhook
stripe trigger payment_intent.succeeded
Enter fullscreen mode Exit fullscreen mode

Step 8: Production Considerations

  1. Rate Limiting: Protect your endpoint
  2. Queue Processing: Use Bull or similar for heavy tasks
  3. Monitoring: Track event processing times and failures
// Rate limiting example
const rateLimit = require('express-rate-limit');

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100 // Stripe recommends handling at least 100 req/min
});

app.post('/webhook', limiter, bodyParser.raw(...));
Enter fullscreen mode Exit fullscreen mode

Complete Webhook Class

Here's a production-ready implementation:

class StripeWebhook {
  constructor(options = {}) {
    this.stripe = require('stripe')(options.apiKey);
    this.redis = require('redis').createClient(options.redisUrl);
    this.secret = options.webhookSecret;
    this.handlers = new Map();
  }

  addHandler(eventType, handler) {
    this.handlers.set(eventType, handler);
  }

  async processEvent(rawBody, signature) {
    const event = this.stripe.webhooks.constructEvent(
      rawBody,
      signature,
      this.secret
    );

    if (await this.isDuplicate(event.id)) {
      return { status: 'already_processed' };
    }

    const handler = this.handlers.get(event.type);
    if (!handler) {
      return { status: 'unhandled_event_type' };
    }

    try {
      await handler(event.data.object);
      await this.markAsProcessed(event.id);
      return { status: 'success' };
    } catch (err) {
      console.error(`Event processing failed: ${err}`);
      throw err;
    }
  }

  async isDuplicate(eventId) {
    return new Promise((resolve) => {
      this.redis.exists(`event:${eventId}`, (err, reply) => {
        resolve(reply === 1);
      });
    });
  }

  async markAsProcessed(eventId) {
    this.redis.setex(`event:${eventId}`, 72 * 3600, '1');
  }
}
Enter fullscreen mode Exit fullscreen mode

Deployment Best Practices

  1. HTTPS: Always use HTTPS in production
  2. Secret Rotation: Regularly rotate webhook secrets
  3. IP Allowlisting: Restrict to Stripe's IP ranges
  4. Logging: Comprehensive event logging
// IP allowlisting middleware
const stripeIps = ['54.187.174.169', '54.187.205.235', /* ... */];

function stripeIpWhitelist(req, res, next) {
  const ip = req.ip.replace('::ffff:', '');
  if (!stripeIps.includes(ip)) {
    return res.status(403).send('Forbidden');
  }
  next();
}

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

Monitoring and Alerting

Implement Prometheus metrics for observability:

const client = require('prom-client');

const webhookRequests = new client.Counter({
  name: 'stripe_webhook_requests_total',
  help: 'Total Stripe webhook requests',
  labelNames: ['type', 'status']
});

// In your handler:
webhookRequests.inc({ type: event.type, status: 'success' });
Enter fullscreen mode Exit fullscreen mode

Conclusion

This implementation provides:

  • Secure endpoint verification
  • Proper event handling
  • Idempotent processing
  • Error recovery
  • Production-grade reliability

The complete code is available on GitHub (link would go here). Remember to thoroughly test your webhook handler before deploying to production.


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