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. Here's my comprehensive guide to building a production-grade Stripe webhook handler in Node.js with proper security, error handling, and idempotency.

Understanding the Architecture

Stripe webhooks operate on a push model - when events occur in Stripe (payment succeeded, charge failed, etc.), Stripe sends HTTP POST requests to your endpoint. The critical components:

  1. Endpoint Verification - Validating Stripe signatures
  2. Idempotency - Handling duplicate events
  3. Error Handling - Proper failure modes
  4. Event Processing - Business logic execution

Initial Setup

First, install required packages:

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

Basic server setup:

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();

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

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

Signature Verification

Security is paramount. Always verify the webhook signature:

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

  const secret = process.env.STRIPE_WEBHOOK_SECRET;
  const expectedSignature = crypto
    .createHmac('sha256', secret)
    .update(req.body)
    .digest('hex');

  const signatures = signature.split(',')
    .map(item => item.split('='))
    .reduce((acc, [key, value]) => ({...acc, [key]: value}), {});

  if (!crypto.timingSafeEqual(
    Buffer.from(signatures['t'], 'utf8'),
    Buffer.from(expectedSignature, 'utf8')
  )) {
    throw new Error('Invalid signature');
  }
}
Enter fullscreen mode Exit fullscreen mode

Idempotency Handling

Webhooks may deliver the same event multiple times. Implement idempotency:

const processedEvents = new Set();

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

    const event = JSON.parse(req.body.toString());

    // Check for duplicate events
    if (processedEvents.has(event.id)) {
      return res.status(200).send(`Already processed event ${event.id}`);
    }

    processedEvents.add(event.id);

    // Process the event
    await processEvent(event);

    res.status(200).send('Webhook processed');
  } catch (err) {
    console.error(`Webhook error: ${err.message}`);
    res.status(400).send(`Webhook error: ${err.message}`);
  }
}
Enter fullscreen mode Exit fullscreen mode

Event Processing

Different event types require different handling:

async function processEvent(event) {
  switch (event.type) {
    case 'payment_intent.succeeded':
      await handlePaymentSuccess(event.data.object);
      break;
    case 'payment_intent.payment_failed':
      await handlePaymentFailure(event.data.object);
      break;
    case 'charge.refunded':
      await handleRefund(event.data.object);
      break;
    // Add more event types as needed
    default:
      console.log(`Unhandled event type ${event.type}`);
  }
}

async function handlePaymentSuccess(paymentIntent) {
  // Business logic for successful payment
  console.log(`Payment succeeded for ${paymentIntent.amount}`);

  // Example: Update database
  await db.updateOrderStatus(paymentIntent.metadata.orderId, 'paid');

  // Example: Send confirmation email
  await emailService.sendReceipt(paymentIntent.customer_email, {
    amount: paymentIntent.amount / 100,
    currency: paymentIntent.currency
  });
}
Enter fullscreen mode Exit fullscreen mode

Error Handling and Retries

Implement proper error handling with exponential backoff:

async function processWithRetries(event, maxRetries = 3) {
  let attempts = 0;

  while (attempts < maxRetries) {
    try {
      await processEvent(event);
      return;
    } catch (err) {
      attempts++;
      const delay = Math.pow(2, attempts) * 1000;

      if (attempts >= maxRetries) {
        throw err;
      }

      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Testing Webhooks Locally

Use the Stripe CLI for local testing:

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

Then trigger test events:

stripe trigger payment_intent.succeeded
Enter fullscreen mode Exit fullscreen mode

Production Considerations

For production deployment:

  1. HTTPS - Mandatory for webhooks
  2. Queue Processing - Offload heavy processing
  3. Logging - Comprehensive event logging
  4. Monitoring - Alert on failures

Example with queue:

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

// In your webhook handler
queue.add('process-stripe-event', { event });

// Worker setup
const worker = new Worker('process-stripe-event', async job => {
  await processEvent(job.data.event);
}, { connection: redisClient });
Enter fullscreen mode Exit fullscreen mode

Complete Implementation

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 { Worker } = require('bullmq');

const app = express();
const processedEvents = new Set();

// Middleware
app.post('/webhook', 
  bodyParser.raw({type: 'application/json'}),
  handleStripeWebhook
);

// Webhook handler
async function handleStripeWebhook(req, res) {
  try {
    verifyStripeSignature(req);
    const event = JSON.parse(req.body.toString());

    if (processedEvents.has(event.id)) {
      return res.status(200).end();
    }

    processedEvents.add(event.id);
    await processWithRetries(event);

    res.status(200).end();
  } catch (err) {
    console.error(`Webhook error: ${err.message}`);
    res.status(400).send(`Webhook error: ${err.message}`);
  }
}

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

  const secret = process.env.STRIPE_WEBHOOK_SECRET;
  const expectedSig = crypto
    .createHmac('sha256', secret)
    .update(req.body)
    .digest('hex');

  const sigs = signature.split(',')
    .map(item => item.split('='))
    .reduce((acc, [k, v]) => ({...acc, [k]: v}), {});

  if (!crypto.timingSafeEqual(
    Buffer.from(sigs['t'], 'utf8'),
    Buffer.from(expectedSig, 'utf8')
  )) {
    throw new Error('Invalid signature');
  }
}

// Event processing
async function processEvent(event) {
  switch (event.type) {
    case 'payment_intent.succeeded':
      await handlePaymentSuccess(event.data.object);
      break;
    // Add other cases
    default:
      console.log(`Unhandled event: ${event.type}`);
  }
}

// Start server
app.listen(4242, () => console.log('Webhook ready'));
Enter fullscreen mode Exit fullscreen mode

Best Practices

  1. Secret Rotation - Regularly rotate webhook secrets
  2. Idempotency Keys - Use Stripe's idempotency keys for API calls
  3. Timeouts - Configure appropriate timeout values
  4. DB Transactions - Use transactions for database updates
  5. Circuit Breakers - Implement for dependent services

This implementation provides a solid foundation for handling Stripe webhooks in production Node.js applications. The key aspects are security verification, proper error handling, and idempotent processing to ensure reliable operation.


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