Stripe Payment Integration with Webhook Handling: A Production Guide

Building a robust payment system is crucial for any SaaS platform. In this comprehensive guide, I’ll walk you through implementing Stripe payment integration with subscription management and secure webhook handling - knowledge gained from building real estate analytics platforms with complex billing requirements.

Architecture Overview

A production-ready Stripe integration involves several key components:

Setting Up Stripe with Node.js

First, install the necessary dependencies:

npm install stripe express dotenv crypto

Create your Stripe service:

// services/stripe.service.js
const Stripe = require("stripe");
const stripe = Stripe(process.env.STRIPE_SECRET_KEY);

class StripeService {
  async createCustomer(email, name, metadata = {}) {
    try {
      const customer = await stripe.customers.create({
        email,
        name,
        metadata,
      });
      return customer;
    } catch (error) {
      throw new Error(`Failed to create customer: ${error.message}`);
    }
  }

  async createSubscription(customerId, priceId, trialDays = 0) {
    try {
      const subscription = await stripe.subscriptions.create({
        customer: customerId,
        items: [{ price: priceId }],
        trial_period_days: trialDays,
        expand: ["latest_invoice.payment_intent"],
        metadata: {
          created_at: new Date().toISOString(),
        },
      });
      return subscription;
    } catch (error) {
      throw new Error(`Failed to create subscription: ${error.message}`);
    }
  }

  async updateSubscription(subscriptionId, newPriceId) {
    try {
      const subscription = await stripe.subscriptions.retrieve(subscriptionId);

      const updatedSubscription = await stripe.subscriptions.update(
        subscriptionId,
        {
          items: [
            {
              id: subscription.items.data[0].id,
              price: newPriceId,
            },
          ],
          proration_behavior: "create_prorations",
        },
      );

      return updatedSubscription;
    } catch (error) {
      throw new Error(`Failed to update subscription: ${error.message}`);
    }
  }

  async cancelSubscription(subscriptionId, cancelAtPeriodEnd = true) {
    try {
      const subscription = await stripe.subscriptions.update(subscriptionId, {
        cancel_at_period_end: cancelAtPeriodEnd,
      });
      return subscription;
    } catch (error) {
      throw new Error(`Failed to cancel subscription: ${error.message}`);
    }
  }
}

module.exports = new StripeService();

Implementing Secure Webhook Handling

Webhooks are critical for maintaining data consistency. Here’s how to implement them securely:

// routes/webhooks.js
const express = require("express");
const crypto = require("crypto");
const stripeService = require("../services/stripe.service");
const User = require("../models/User");

const router = express.Router();

// Middleware to verify webhook signature
const verifyWebhookSignature = (req, res, next) => {
  const signature = req.headers["stripe-signature"];
  const endpointSecret = process.env.STRIPE_WEBHOOK_SECRET;

  let event;
  try {
    event = stripe.webhooks.constructEvent(req.body, signature, endpointSecret);
    req.stripeEvent = event;
    next();
  } catch (err) {
    console.error("Webhook signature verification failed:", err.message);
    return res.status(400).send(`Webhook Error: ${err.message}`);
  }
};

// Raw body parser for webhook verification
router.use(
  "/stripe",
  express.raw({ type: "application/json" }),
  verifyWebhookSignature,
);

router.post("/stripe", async (req, res) => {
  const event = req.stripeEvent;

  try {
    switch (event.type) {
      case "customer.subscription.created":
        await handleSubscriptionCreated(event.data.object);
        break;

      case "customer.subscription.updated":
        await handleSubscriptionUpdated(event.data.object);
        break;

      case "customer.subscription.deleted":
        await handleSubscriptionDeleted(event.data.object);
        break;

      case "invoice.payment_succeeded":
        await handlePaymentSucceeded(event.data.object);
        break;

      case "invoice.payment_failed":
        await handlePaymentFailed(event.data.object);
        break;

      default:
        console.log(`Unhandled event type: ${event.type}`);
    }

    res.status(200).json({ received: true });
  } catch (error) {
    console.error("Webhook handler error:", error);
    res.status(500).json({ error: "Webhook handler failed" });
  }
});

async function handleSubscriptionCreated(subscription) {
  const customerId = subscription.customer;
  const user = await User.findOne({ stripeCustomerId: customerId });

  if (user) {
    user.subscriptionId = subscription.id;
    user.subscriptionStatus = subscription.status;
    user.currentPeriodEnd = new Date(subscription.current_period_end * 1000);
    await user.save();

    // Send welcome email
    await sendWelcomeEmail(user.email);
  }
}

async function handlePaymentSucceeded(invoice) {
  const subscriptionId = invoice.subscription;
  const user = await User.findOne({ subscriptionId });

  if (user) {
    user.subscriptionStatus = "active";
    user.currentPeriodEnd = new Date(invoice.period_end * 1000);
    await user.save();

    // Log payment for analytics
    await logPaymentEvent(user.id, invoice.amount_paid, "succeeded");
  }
}

async function handlePaymentFailed(invoice) {
  const subscriptionId = invoice.subscription;
  const user = await User.findOne({ subscriptionId });

  if (user) {
    user.subscriptionStatus = "past_due";
    await user.save();

    // Send payment failure notification
    await sendPaymentFailureEmail(user.email, invoice.hosted_invoice_url);
  }
}

module.exports = router;

Implementing Idempotent Operations

To prevent duplicate charges and maintain data consistency:

// middleware/idempotency.js
const redis = require("redis");
const client = redis.createClient();

const idempotencyMiddleware = (ttl = 3600) => {
  return async (req, res, next) => {
    const idempotencyKey = req.headers["idempotency-key"];

    if (!idempotencyKey) {
      return res.status(400).json({ error: "Idempotency-Key header required" });
    }

    try {
      const cached = await client.get(`idempotency:${idempotencyKey}`);

      if (cached) {
        return res.json(JSON.parse(cached));
      }

      // Store response after successful operation
      const originalSend = res.json;
      res.json = function (data) {
        if (res.statusCode < 400) {
          client.setex(
            `idempotency:${idempotencyKey}`,
            ttl,
            JSON.stringify(data),
          );
        }
        originalSend.call(this, data);
      };

      next();
    } catch (error) {
      console.error("Idempotency check failed:", error);
      next();
    }
  };
};

module.exports = idempotencyMiddleware;

Production Considerations

Error Handling and Retry Logic

const retryPaymentWithExponentialBackoff = async (
  operation,
  maxRetries = 3,
) => {
  for (let i = 0; i < maxRetries; i++) {
    try {
      return await operation();
    } catch (error) {
      if (i === maxRetries - 1) throw error;

      const delay = Math.pow(2, i) * 1000; // Exponential backoff
      await new Promise((resolve) => setTimeout(resolve, delay));
    }
  }
};

Monitoring and Alerting

// Monitor failed payments and subscription churns
const monitorPaymentMetrics = async () => {
  const failedPayments = await getFailedPaymentsCount();
  const churnRate = await calculateChurnRate();

  if (failedPayments > FAILED_PAYMENT_THRESHOLD) {
    await sendAlert("High failed payment rate detected");
  }

  if (churnRate > CHURN_RATE_THRESHOLD) {
    await sendAlert("Churn rate exceeding threshold");
  }
};

Testing Your Integration

Always test your webhook endpoints thoroughly:

// Use Stripe CLI for local testing
// stripe listen --forward-to localhost:3000/webhooks/stripe

// Test with different scenarios
const testScenarios = [
  "customer.subscription.created",
  "invoice.payment_succeeded",
  "invoice.payment_failed",
  "customer.subscription.deleted",
];

Key Takeaways

  1. Always verify webhook signatures to ensure security
  2. Implement idempotency to prevent duplicate operations
  3. Handle edge cases like failed payments and subscription changes
  4. Monitor your payment metrics for business insights
  5. Test thoroughly with different payment scenarios

This approach has proven reliable in production environments handling thousands of transactions. The key is building robust error handling and monitoring from day one, not as an afterthought.

Remember: Payment systems are critical infrastructure. Invest time in getting them right, and your users (and your business) will thank you.

MirzoDev

© 2025 MirzoDev

Email Telegram GitHub