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, allowing real-time notifications about events in your Stripe integration. Here's my comprehensive guide to building a production-ready Stripe webhook handler in Node.js.

Understanding Stripe Webhook Architecture

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

  1. Endpoint URL: Publicly accessible URL where Stripe sends events
  2. Event Object: JSON payload containing event details
  3. Signature Verification: HMAC signature for security validation
  4. Idempotency: Handling duplicate events safely

Initial Setup

First, install required dependencies:

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

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(`Server running 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'];
  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;

  let event;

  try {
    event = stripe.webhooks.constructEvent(req.rawBody, sig, webhookSecret);
  } 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;
    // Add more event types as needed
    default:
      console.log(`Unhandled event type ${event.type}`);
  }

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

Event Processing Functions

Implement specific handlers for different event types:

async function handlePaymentIntentSucceeded(paymentIntent) {
  console.log('PaymentIntent was successful!');

  // Example: Update your database
  try {
    await updateOrderStatus(paymentIntent.metadata.orderId, 'paid');
    await sendConfirmationEmail(paymentIntent.receipt_email);
  } catch (err) {
    console.error('Error processing payment success:', err);
    // Implement retry logic here
  }
}

async function handleChargeFailed(charge) {
  console.log('Charge failed:', charge.id);

  // Example: Notify customer and update records
  try {
    await updateOrderStatus(charge.metadata.orderId, 'payment_failed');
    await sendPaymentFailedEmail(charge.billing_details.email);
  } catch (err) {
    console.error('Error processing failed charge:', err);
  }
}
Enter fullscreen mode Exit fullscreen mode

Advanced Security Considerations

1. Signature Verification

Stripe signs each webhook request with a timestamped HMAC SHA256 signature. The constructEvent method verifies:

  1. The signature matches your webhook secret
  2. The timestamp is recent (prevent replay attacks)

2. Idempotency Handling

Implement idempotency keys to prevent duplicate processing:

const processedEvents = new Set();

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

  // Process event...
  await handleEvent(event);

  // Store event ID with TTL (e.g., 24 hours)
  processedEvents.add(event.id);
  setTimeout(() => processedEvents.delete(event.id), 86400000);
}
Enter fullscreen mode Exit fullscreen mode

3. Rate Limiting and Retries

Stripe automatically retries failed webhook deliveries with exponential backoff. Your endpoint should:

  1. Process events quickly (<500ms response time)
  2. Implement proper error handling
  3. Return appropriate HTTP status codes:
    • 200-299: Success
    • 400-499: Permanent failure (won't retry)
    • 500-599: Temporary failure (will retry)

Testing Webhooks Locally

Use the Stripe CLI to forward events to your local server:

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. Webhook Secret Rotation

// Support multiple webhook secrets for rotation
const webhookSecrets = [
  process.env.STRIPE_WEBHOOK_SECRET_CURRENT,
  process.env.STRIPE_WEBHOOK_SECRET_OLD
];

let event;
for (const secret of webhookSecrets) {
  try {
    event = stripe.webhooks.constructEvent(req.rawBody, sig, secret);
    break;
  } catch (err) {
    continue;
  }
}
Enter fullscreen mode Exit fullscreen mode

2. Queue Processing

For heavy workloads, use a queue system:

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

// Add event to queue
await eventQueue.add(event.type, event, {
  jobId: event.id, // Use event ID for deduplication
});

// Worker process
const worker = new Worker('stripe_events', async job => {
  const event = job.data;
  // Process event...
});
Enter fullscreen mode Exit fullscreen mode

3. Monitoring and Logging

Implement comprehensive logging:

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

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

// In your webhook handler
logger.info(`Received event: ${event.type}`, {
  eventId: event.id,
  livemode: event.livemode,
  objectId: event.data.object.id
});
Enter fullscreen mode Exit fullscreen mode

Complete Example

Here's a full implementation with all best practices:

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

const app = express();
app.use(bodyParser.json({ verify: (req, res, buf) => { req.rawBody = buf; } }));

// In-memory event tracking (replace with Redis in production)
const processedEvents = new Set();

// Queue setup
const eventQueue = new Queue('stripe_events', {
  connection: { host: process.env.REDIS_HOST }
});

// Webhook endpoint
app.post('/webhook', async (req, res) => {
  const sig = req.headers['stripe-signature'];
  const secrets = [
    process.env.STRIPE_WEBHOOK_SECRET_CURRENT,
    process.env.STRIPE_WEBHOOK_SECRET_OLD
  ];

  let event;
  for (const secret of secrets) {
    try {
      event = stripe.webhooks.constructEvent(req.rawBody, sig, secret);
      break;
    } catch (err) {
      continue;
    }
  }

  if (!event) return res.status(400).send('Invalid signature');

  // Check for duplicate events
  if (processedEvents.has(event.id)) {
    return res.status(200).send('Event already processed');
  }

  // Add to processing queue
  await eventQueue.add(event.type, event, { jobId: event.id });
  processedEvents.add(event.id);

  res.status(200).send('Event received');
});

// Worker process
const worker = new Worker('stripe_events', async job => {
  const event = job.data;

  try {
    switch (event.type) {
      case 'payment_intent.succeeded':
        await handlePaymentIntentSucceeded(event.data.object);
        break;
      // Add other event handlers
    }
  } catch (err) {
    console.error(`Error processing ${event.type}:`, err);
    throw err; // Will trigger retry
  }
});

worker.on('completed', job => {
  console.log(`Processed ${job.name} (${job.id})`);
});

worker.on('failed', (job, err) => {
  console.error(`Failed ${job.name} (${job.id}):`, err);
});

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

Key Takeaways

  1. Always verify webhook signatures
  2. Implement idempotency to handle duplicate events
  3. Use queue systems for reliable processing
  4. Monitor and log all webhook activity
  5. Test thoroughly with Stripe's test events

This implementation provides a robust foundation for handling Stripe webhooks in production Node.js applications. Remember to adapt the queue system and event tracking to your specific infrastructure needs.


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