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 technical deep dive, I'll walk through building a production-grade Stripe webhook handler in Node.js that's secure, scalable, and maintainable.

Understanding Stripe Webhook Architecture

Stripe webhooks operate on a push model - instead of polling their API, Stripe pushes events to your endpoint when important actions occur. The critical components:

  1. Event Object: JSON payload containing event metadata and relevant object data
  2. Signature Verification: HMAC-based security mechanism
  3. Idempotency: Handling duplicate events safely

Initial Setup

First, install required dependencies:

npm install stripe express body-parser crypto-js
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();

// Middleware to parse raw body for signature verification
app.use(
  bodyParser.json({
    verify: (req, res, buf) => {
      req.rawBody = buf.toString();
    },
  })
);

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

Webhook Endpoint Implementation

Here's the core webhook handler:

app.post('/webhook', async (req, res) => {
  const sig = req.headers['stripe-signature'];
  let event;

  try {
    event = stripe.webhooks.constructEvent(
      req.rawBody,
      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;
    case 'customer.subscription.deleted':
      await handleSubscriptionCancelled(event.data.object);
      break;
    // ... handle other event types
    default:
      console.log(`Unhandled event type ${event.type}`);
  }

  // Return a 200 response to acknowledge receipt of the event
  res.json({ received: true });
});
Enter fullscreen mode Exit fullscreen mode

Event Processing Functions

Let's implement robust handlers for common events:

Payment Intent Succeeded

async function handlePaymentIntentSucceeded(paymentIntent) {
  try {
    // Important: Implement idempotency
    if (await checkIfProcessed(paymentIntent.id)) {
      console.log(`Payment ${paymentIntent.id} already processed`);
      return;
    }

    // Business logic here
    await fulfillOrder(paymentIntent.metadata.orderId);
    await updateAccountingSystem(paymentIntent.amount);
    await sendConfirmationEmail(paymentIntent.receipt_email);

    // Mark as processed
    await recordProcessing(paymentIntent.id);
  } catch (err) {
    console.error(`Error processing payment ${paymentIntent.id}:`, err);
    // Implement retry logic or dead letter queue
    await queueForRetry(paymentIntent, err);
  }
}
Enter fullscreen mode Exit fullscreen mode

Subscription Cancellation

async function handleSubscriptionCancelled(subscription) {
  const customer = await stripe.customers.retrieve(subscription.customer);

  try {
    await downgradeUserAccess(customer.metadata.userId);
    await sendCancellationConfirmation(customer.email);
    await scheduleDataRetention(subscription.id);
  } catch (err) {
    console.error(`Error handling cancelled subscription ${subscription.id}:`, err);
    // Critical failures should alert the team
    alertTeam(`Subscription cancellation failed for ${subscription.id}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Security Best Practices

Signature Verification

The most critical security measure is properly verifying the webhook signature:

function verifyStripeSignature(rawBody, signature) {
  const crypto = require('crypto');
  const secret = process.env.STRIPE_WEBHOOK_SECRET;

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

  return crypto.timingSafeEqual(
    Buffer.from(calculatedSignature),
    Buffer.from(signature)
  );
}
Enter fullscreen mode Exit fullscreen mode

Additional Security Measures

  1. IP Whitelisting: Only accept requests from Stripe's IP ranges
  2. Rate Limiting: Prevent abuse of your webhook endpoint
  3. Payload Validation: Validate all incoming data

Error Handling and Retry Logic

Stripe will retry failed webhook deliveries. Implement proper error handling:

const { MongoClient } = require('mongodb');

async function logWebhookAttempt(eventId, status, error = null) {
  const client = new MongoClient(process.env.MONGODB_URI);
  try {
    await client.connect();
    const db = client.db('webhooks');
    await db.collection('delivery_attempts').insertOne({
      eventId,
      status,
      error: error?.message,
      timestamp: new Date(),
    });
  } finally {
    await client.close();
  }
}
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. Horizontal Scaling: Webhook handlers should be stateless
  2. Database Optimization: Index event processing logs
  3. Monitoring: Track processing times and failure rates
  4. Alerting: Set up alerts for critical failures

Complete Example

Here's a production-ready implementation:

const express = require('express');
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
const { MongoClient } = require('mongodb');
const crypto = require('crypto');
const app = express();

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

// Webhook handler
app.post('/webhook', async (req, res) => {
  const sig = req.headers['stripe-signature'];

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

    await logWebhookAttempt(event.id, 'received');

    // Process event
    const processor = eventProcessors[event.type];
    if (processor) {
      await processor(event);
      await logWebhookAttempt(event.id, 'processed');
    } else {
      await logWebhookAttempt(event.id, 'unhandled');
    }

    res.json({ received: true });
  } catch (err) {
    await logWebhookAttempt(
      req.body?.id || 'unknown', 
      'failed', 
      err
    );
    res.status(400).send(`Webhook Error: ${err.message}`);
  }
});

// Event processors
const eventProcessors = {
  'payment_intent.succeeded': async (event) => {
    const paymentIntent = event.data.object;
    if (await isProcessed(paymentIntent.id)) return;

    await fulfillOrder(paymentIntent);
    await markAsProcessed(paymentIntent.id);
  },
  // Add other event handlers
};

// Database helpers
async function isProcessed(eventId) {
  const client = new MongoClient(process.env.MONGODB_URI);
  try {
    await client.connect();
    const count = await client.db('webhooks')
      .collection('processed_events')
      .countDocuments({ eventId });
    return count > 0;
  } finally {
    await client.close();
  }
}

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

Performance Optimization

For high-volume applications:

  1. Batch Processing: Group similar events
  2. Worker Queues: Offload processing to background workers
  3. Connection Pooling: Reuse database connections
  4. Event Filtering: Only subscribe to needed events

Monitoring and Analytics

Track these key metrics:

  1. Delivery Success Rate
  2. Processing Time
  3. Event Volume by Type
  4. Error Rates

Conclusion

Building a robust Stripe webhook handler requires attention to:

  1. Security (signature verification)
  2. Reliability (idempotency, error handling)
  3. Performance (scalability)
  4. Maintainability (clean code structure)

The implementation shown handles all these aspects while remaining flexible enough to adapt to your specific business requirements. Remember to thoroughly test your webhook handler with Stripe's test events before going live.


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