ZyVOP Logo
Content That Connects
SeriesAI NewsWhy ZyVOPJoin Discord
LoginGet Started
ZyVOP Logo
Content That Connects

The Developer Publishing Hub. Write once, cross-post to Dev.to, Medium, Hashnode, WordPress & Bluesky with automated canonical source tags and zero paywalls.

Content

  • Categories
  • Tags
  • Badges
  • Leaderboard
  • Write Article
  • Newsletter

Company

  • About Us
  • Why ZyVOP
  • Developer API & CLI
  • Write for Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

ยฉ 2026 ZyVOP. Developer Publishing Hub.

Zero paywalls ยท Full content ownership
All systems operational
HomeStripe Integration in Node.js: Payments, Subscriptions, and the Edge Cases That Cost You Money

Stripe Integration in Node.js: Payments, Subscriptions, and the Edge Cases That Cost You Money

Build a production-ready Stripe integration in Node.js with subscriptions, webhooks, retries, idempotency, and real-world payment edge cases.

ZyVOP
ZyVOP
Senior Developer
May 26, 2026
7 min read
Stripe Integration in Node.js: Payments, Subscriptions, and the Edge Cases That Cost You Money
#Stripe PaymentIntent Node.js#Stripe TypeScript#invoice.paid webhook#Stripe Node.js integration#Stripe subscriptions Node.js#Stripe customer portal#Stripe idempotency#Stripe webhooks 2026

Stripe's documentation is excellent. It shows you how to create a PaymentIntent, how to set up a subscription, how to handle a webhook. What it does not show you is how all the pieces fit together into a payment system that handles failures gracefully, does not double-charge customers, and does not lose revenue when a webhook is delayed.

This guide covers the full production implementation: one-time payments with idempotency, subscription management, webhook handling, and the failure modes that tutorial code ignores.


Setup

npm install stripe
// src/lib/stripe.ts
import Stripe from 'stripe';

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-11-20.acacia',   // Pin to a specific version
  typescript:  true,
  maxNetworkRetries: 2,              // Retry on network failures automatically
  timeout: 20_000,                   // 20 second timeout
});

Pinning the API version is not optional. Stripe releases breaking changes under new versions; without pinning, a Stripe API update can silently change your integration's behavior.


One-Time Payments

The flow: your server creates a PaymentIntent, sends the client_secret to the client, the client confirms it with Stripe.js. Your server never handles raw card data.

// src/routes/payments.ts
import { stripe } from '../lib/stripe';
import { randomUUID } from 'crypto';

router.post('/payments/create-intent', authenticate, async (req, res) => {
  const { amount, currency = 'usd', orderId } = req.body;

  // Idempotency key โ€” if this request is retried, Stripe returns the same
  // PaymentIntent instead of creating a duplicate charge
  const idempotencyKey = `pi-${req.user.id}-${orderId}`;

  const paymentIntent = await stripe.paymentIntents.create(
    {
      amount:   Math.round(amount * 100),   // Stripe uses cents
      currency,
      metadata: {
        userId:   req.user.id,
        tenantId: req.tenant.id,
        orderId,
      },
      // Capture payment immediately (default)
      // Use capture_method: 'manual' for auth-then-capture flows
    },
    { idempotencyKey }
  );

  // Store the PaymentIntent ID โ€” needed to reconcile webhook events
  await db.query(
    `UPDATE orders
     SET stripe_payment_intent_id = $1, status = 'awaiting_payment'
     WHERE id = $2 AND tenant_id = $3`,
    [paymentIntent.id, orderId, req.tenant.id]
  );

  // Only send client_secret to the client โ€” never the full PaymentIntent object
  res.json({ clientSecret: paymentIntent.client_secret });
});

Subscriptions

Subscriptions in Stripe are more complex than one-time payments. The lifecycle involves: creating a customer, attaching a payment method, creating the subscription, and then handling ongoing billing events via webhooks.

// src/services/subscriptionService.ts

export async function createSubscription(params: {
  userId:          string;
  tenantId:        string;
  email:           string;
  priceId:         string;   // Stripe Price ID โ€” e.g. 'price_pro_monthly'
  paymentMethodId: string;   // From Stripe.js on the frontend
}) {
  // Step 1 โ€” Get or create Stripe customer
  let stripeCustomerId = await getStripeCustomerId(params.tenantId);

  if (!stripeCustomerId) {
    const customer = await stripe.customers.create({
      email:    params.email,
      metadata: { tenantId: params.tenantId, userId: params.userId },
    });
    stripeCustomerId = customer.id;

    await db.query(
      'UPDATE tenants SET stripe_customer_id = $1 WHERE id = $2',
      [stripeCustomerId, params.tenantId]
    );
  }

  // Step 2 โ€” Attach payment method to customer
  await stripe.paymentMethods.attach(params.paymentMethodId, {
    customer: stripeCustomerId,
  });

  // Set as default payment method
  await stripe.customers.update(stripeCustomerId, {
    invoice_settings: {
      default_payment_method: params.paymentMethodId,
    },
  });

  // Step 3 โ€” Create subscription
  const subscription = await stripe.subscriptions.create({
    customer: stripeCustomerId,
    items:    [{ price: params.priceId }],
    payment_settings: {
      payment_method_types: ['card'],
      save_default_payment_method: 'on_subscription',
    },
    expand: ['latest_invoice.payment_intent'],
    metadata: {
      tenantId: params.tenantId,
      userId:   params.userId,
    },
  });

  // Step 4 โ€” Store subscription reference
  await db.query(`
    UPDATE tenants
    SET
      stripe_subscription_id = $1,
      plan                   = $2,
      subscription_status    = $3,
      current_period_end     = to_timestamp($4)
    WHERE id = $5
  `, [
    subscription.id,
    getPlanFromPriceId(params.priceId),
    subscription.status,
    subscription.current_period_end,
    params.tenantId,
  ]);

  return subscription;
}

export async function cancelSubscription(tenantId: string) {
  const tenant = await getTenant(tenantId);
  if (!tenant.stripe_subscription_id) {
    throw new NotFoundError('Subscription');
  }

  // Cancel at period end โ€” user keeps access until billing cycle ends
  // Use { immediately: true } to cancel and refund immediately
  const subscription = await stripe.subscriptions.update(
    tenant.stripe_subscription_id,
    { cancel_at_period_end: true }
  );

  await db.query(
    `UPDATE tenants SET subscription_status = 'canceling' WHERE id = $1`,
    [tenantId]
  );

  return subscription;
}

Webhook Handling: The Critical Path

Webhooks are how Stripe tells you what actually happened โ€” payment succeeded, invoice paid, subscription cancelled, payment failed. Your webhook handler is more important than your payment creation endpoint.

// src/routes/webhooks/stripe.ts
import { stripe } from '../../lib/stripe';

const WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET!;

// Map Stripe events to handlers โ€” add new ones here as you expand
const eventHandlers: Partial<Record<Stripe.Event.Type, (event: Stripe.Event) => Promise<void>>> = {
  'payment_intent.succeeded':           handlePaymentSucceeded,
  'payment_intent.payment_failed':      handlePaymentFailed,
  'invoice.paid':                       handleInvoicePaid,
  'invoice.payment_failed':             handleInvoicePaymentFailed,
  'customer.subscription.updated':      handleSubscriptionUpdated,
  'customer.subscription.deleted':      handleSubscriptionDeleted,
  'customer.subscription.trial_will_end': handleTrialEnding,
};

router.post('/webhooks/stripe',
  express.raw({ type: 'application/json' }),  // Must be raw โ€” not parsed JSON
  async (req, res) => {
    const signature = req.headers['stripe-signature'] as string;

    let event: Stripe.Event;
    try {
      event = stripe.webhooks.constructEvent(req.body, signature, WEBHOOK_SECRET);
    } catch (err) {
      logger.warn({ error: (err as Error).message }, 'Stripe webhook signature invalid');
      return res.status(400).json({ error: 'Invalid signature' });
    }

    // Acknowledge immediately โ€” Stripe retries if you don't respond within 30s
    res.status(200).json({ received: true });

    // Deduplicate โ€” Stripe delivers at-least-once, not exactly-once
    const alreadyProcessed = await db.query(
      `INSERT INTO processed_webhooks (event_id, source)
       VALUES ($1, 'stripe')
       ON CONFLICT DO NOTHING`,
      [event.id]
    ).then(r => r.rowCount === 0);

    if (alreadyProcessed) {
      logger.info({ eventId: event.id }, 'Duplicate Stripe event โ€” skipping');
      return;
    }

    const handler = eventHandlers[event.type];
    if (handler) {
      try {
        await handler(event);
      } catch (err) {
        logger.error({
          eventId:   event.id,
          eventType: event.type,
          error:     (err as Error).message,
        }, 'Stripe webhook handler failed');
        // Do not re-throw โ€” the 200 is already sent
        // Queue for retry instead
        await webhookQueue.add('stripe-retry', { event }, {
          attempts: 3,
          backoff:  { type: 'exponential', delay: 5000 },
        });
      }
    }
  }
);

The Event Handlers

async function handlePaymentSucceeded(event: Stripe.Event) {
  const paymentIntent = event.data.object as Stripe.PaymentIntent;
  const { orderId } = paymentIntent.metadata;

  await db.query(`
    UPDATE orders
    SET status = 'paid', paid_at = NOW(), stripe_payment_id = $1
    WHERE stripe_payment_intent_id = $2
      AND status != 'paid'           -- Idempotent
  `, [paymentIntent.id, paymentIntent.id]);

  const order = await db.query(
    'SELECT * FROM orders WHERE stripe_payment_intent_id = $1',
    [paymentIntent.id]
  );

  if (order.rows[0]) {
    await emailQueue.add('order-confirmation', {
      orderId:   order.rows[0].id,
      userId:    order.rows[0].user_id,
      tenantId:  order.rows[0].tenant_id,
    }, {
      jobId: `order-confirmation-${order.rows[0].id}`,  // No duplicate emails
    });
  }
}

async function handleInvoicePaid(event: Stripe.Event) {
  const invoice = event.data.object as Stripe.Invoice;
  const subscription = await stripe.subscriptions.retrieve(invoice.subscription as string);

  await db.query(`
    UPDATE tenants
    SET
      subscription_status = 'active',
      current_period_end  = to_timestamp($1),
      plan                = $2
    WHERE stripe_customer_id = $3
  `, [
    subscription.current_period_end,
    getPlanFromPriceId(subscription.items.data[0].price.id),
    invoice.customer,
  ]);
}

async function handleInvoicePaymentFailed(event: Stripe.Event) {
  const invoice = event.data.object as Stripe.Invoice;

  // Stripe will retry automatically โ€” do not immediately downgrade
  // Update status to reflect the failed payment without removing access
  await db.query(
    `UPDATE tenants
     SET subscription_status = 'past_due'
     WHERE stripe_customer_id = $1`,
    [invoice.customer]
  );

  // Send a payment failed email to the account owner
  const tenant = await db.query(
    'SELECT * FROM tenants WHERE stripe_customer_id = $1',
    [invoice.customer]
  );

  if (tenant.rows[0]) {
    await emailQueue.add('payment-failed', {
      tenantId:  tenant.rows[0].id,
      invoiceUrl: invoice.hosted_invoice_url,
    });
  }
}

async function handleSubscriptionDeleted(event: Stripe.Event) {
  const subscription = event.data.object as Stripe.Subscription;

  await db.query(`
    UPDATE tenants
    SET
      plan                   = 'free',
      subscription_status    = 'cancelled',
      stripe_subscription_id = NULL,
      current_period_end     = NULL
    WHERE stripe_customer_id = $1
  `, [subscription.customer]);
}

The Customer Portal

Instead of building your own subscription management UI, use Stripe's hosted customer portal โ€” billing history, plan upgrades, cancellation, payment method updates all handled by Stripe.

router.post('/billing/portal', authenticate, async (req, res) => {
  const tenant = await getTenant(req.tenant.id);

  if (!tenant.stripe_customer_id) {
    return res.status(400).json({ error: 'No billing account found' });
  }

  const session = await stripe.billingPortal.sessions.create({
    customer:   tenant.stripe_customer_id,
    return_url: `${process.env.APP_URL}/settings/billing`,
  });

  res.json({ url: session.url });
});

The client redirects to session.url. Stripe handles everything. Subscription changes fire webhook events that your handlers process.


The Failure Modes That Cost Money

No idempotency keys on PaymentIntent creation. If a network timeout causes the client to retry the creation request, you create two PaymentIntents and potentially double-charge the customer. Always pass an idempotencyKey derived from the order ID.

Granting access based on subscription creation, not webhook confirmation. The subscription might be created but the initial payment might fail. Wait for invoice.paid before granting access.

Not handling invoice.payment_failed gracefully. Stripe retries failed invoices automatically โ€” do not immediately downgrade customers on the first failure. Mark as past_due, notify them, and give Stripe's retry schedule a chance to work.

Canceling subscriptions immediately instead of at period end. When a customer cancels, they have paid for the current period. Cancel with cancel_at_period_end: true and let them use the service until the period ends. It reduces support requests and chargebacks.

Not testing with Stripe's test cards. Every failure scenario has a test card. 4000000000000002 declines every time. 4000002500003155 requires 3D Secure. 4000000000009995 fails after charge. Run through each one before shipping.

# Stripe test card numbers worth knowing
4242 4242 4242 4242  โ€” Success
4000 0000 0000 0002  โ€” Card declined
4000 0025 0000 3155  โ€” Requires 3D Secure authentication
4000 0000 0000 9995  โ€” Insufficient funds
4000 0000 0000 0069  โ€” Expired card

Price ID Mapping

Keep a lookup table between Stripe Price IDs and your internal plan names. Price IDs change when you create new prices; this layer insulates your code from those changes.

// src/lib/stripe.ts
const PRICE_TO_PLAN: Record<string, string> = {
  [process.env.STRIPE_PRICE_PRO_MONTHLY!]:   'pro',
  [process.env.STRIPE_PRICE_PRO_ANNUAL!]:    'pro',
  [process.env.STRIPE_PRICE_ENTERPRISE!]:    'enterprise',
};

export function getPlanFromPriceId(priceId: string): string {
  return PRICE_TO_PLAN[priceId] || 'free';
}

Comments (0)

Login to post a comment.

ZyVOP
ZyVOP

Founder of Zyvop ๐Ÿš€ | Building AI-driven tools & premium insights for software engineers, CTOs, and tech leaders. Obsessed with automating workflows and exploring the frontier of AI.

Subscribe to ZyVOP's Newsletter

More from ZyVOP

View profile

Debian Adopts "Responsible Use of Generative AI" After Nine-Way Condorcet Vote

Debian's General Resolution 2026-002 closed on August 28 with "Responsible Use of Generative AI" beating eight rival proposals, including a Social Contract ban, by a clear Condorcet margin, per the project secretary's published beat matrix.

3 minAug 30

How I Built a Real-Time Developer Trend Radar Into My SEO Growth Engine

An AI-powered content intelligence system that streams live developer conversations from Hacker News, Dev.to, Google Search, and GitHub โ€” and turns them into ready-to-write blog opportunities with one click.

12 minAug 29

Qwen3.8-Flash-Next Cost Efficiency, OpenExecutive Satire, and Multi-Vector Retrieval Advances

This week's digest covers Qwen3.8-Flash-Next's push for ultimate cost-efficiency, the viral OpenExecutive project, and the technical release of MultiVectorEncoder in Sentence-Transformers v6.0.

4 minAug 28

Anthropicโ€™s Pricing Shock, Granite 4.2 Openโ€‘Source Leap, and AIโ€‘Powered Security & Policy Shifts

From Anthropicโ€™s flagship model losing steam to IBMโ€™s 512โ€ฏKโ€‘token Graniteโ€ฏ4.2, plus a new wave of AIโ€‘driven security exploits and policy alarms, this weekโ€™s digest maps the technical and market forces you need to act on now.

3 minAug 26

Introducing Questions and Discussions: A New Way to Connect!

We are thrilled to announce a major update to how you can interact and share content on our platform! Up until now, sharing your thoughts meant writing a standa...

2 minAug 1