DEV Community

Michal
Michal

Posted on

Stripe Subscriptions: The Gotchas That Cost Me 3 Weeks

I spent three weeks fighting Stripe's subscription API while building a multi-tenant SaaS. The documentation is comprehensive, but it buries critical details that you only discover when everything breaks. Here's what I wish someone had told me on day one.

Stripe's Confusing Terminology

These terms meant nothing to me at first. If you're confused too, you're not alone.

payment intent, invoice, subscription item, subscription, customer

Here's how they actually connect:

Customer → has Subscriptions → contain Subscription Items → generate Invoices → create Payment Intents

Quick definitions that would have saved me days:

  • customer - The Stripe object representing your user's payment profile
  • subscription - NOT your product's subscription tier, but a specific customer's active subscription instance
  • subscription item - Individual line items within a subscription (crucial for updates - more on this later)
  • invoice - Automatically generated via webhooks, controls payment completion status
  • payment intent - Handles one-time payments and the actual charge process

Stripe's 4 Integration Approaches

  1. No-code customer portal - Great for MVPs, zero flexibility
  2. Pre-built checkout - Stripe-hosted, limited customization
  3. Advanced integration - Full control, maximum pain
  4. Direct payment links - Works like the portal, good for invoicing

I went with option 3 because I needed custom checkout flows for a multi-tenant SaaS. Here's what the docs don't make clear.

Essential Resources Before You Start

Watch this first (seriously, it'll save you hours):

Then read:

The diagram will confuse you initially. Quick tip: "Two-step confirmation" is just Amazon-style checkout (contact info → payment). I used "Collect payment details before creating an Intent" for single-page checkout.

The tutorial is straightforward for basic payments, but completely glosses over subscription handling and multi-tenant scenarios where you need both one-time purchases and subscriptions. Here's what's missing.

Critical Gotchas Nobody Mentions

1. Never Allow Guest Subscription Purchases

Sounds obvious? It's not. If you allow guests to buy subscriptions, you can't upgrade/downgrade them later without a massive headache. Force account creation first.

2. Stripe Prices are Immutable

You can't edit a price object in Stripe. Ever. You must:

  • Create new price objects for changes
  • Track active/inactive status in YOUR database
  • Archive old prices properly

I learned this after trying to update prices for 50 products.

3. Store Both Subscription ID and Subscription Item ID

This is the killer. Everyone stores the subscription ID. Nobody mentions you NEED the subscription item ID for updates.

// THIS WILL FAIL without the subscription item ID
await this.stripeClient.subscriptions.update(
  sub.stripeSubscriptionId,
  {
    items: [
      {
        id: sub.stripeSubscriptionItemId, // THIS is what everyone misses
        price: newStripePrice.id,
      },
    ],
    proration_behavior: 'none',
  },
);
Enter fullscreen mode Exit fullscreen mode

Alternative: Use subscriptionSchedule for future-dated changes.

4. Idempotency Keys and Database Transactions are Mandatory

Not optional. Not "best practice." Mandatory. Without them, you'll have:

  • Duplicate subscriptions
  • Orphaned database records
  • Angry customers with multiple charges

Working Implementation

Database Schema

Here's my working schema (adjust for your needs):
Database Diagram

Key point: Prices are mirrored from Stripe with additional fields for your business logic.

Subscription Checkout Flow

The docs cover one-time payments here. Subscriptions are different. Here's what actually works:

async startSubscriptionCheckout(
  customer: UserEntity | OrganizationEntity,
  idempotencyKey: string,
  subscriptionRequest: CreateCheckoutDto,
) {
  // Validate your products/prices against Stripe
  const preparedItems =
    await this.subscriptionValidator.validateAndPrepareItems(
      subscriptionRequest,
    );

  // Create order in YOUR database first (as draft)
  const draftOrder = await this.orderManager.createDraftOrder(
    preparedItems,
    customer,
  );

  try {
    // Create Stripe subscription
    const subscriptionSession =
      await this.paymentGateway.createSubscriptionSession(
        customer,
        idempotencyKey,
        preparedItems,
      );

    // Link Stripe session to your order
    await this.orderManager.attachPayment(
      draftOrder.id.toString(),
      subscriptionSession.id,
      'subscription',
    );

    return {
      clientSecret: subscriptionSession.clientSecret,
    };
  } catch (error) {
    // Clean up on failure
    await this.orderManager.markAsFailed(draftOrder.id.toString());

    throw new BadRequestException(
      `Could not create subscription checkout: ${error}`,
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

The magic happens in creating the subscription with the right parameters:

private async createPaymentSubscription(
  customerId: string,
  preparedItems: SubscriptionItem[],
  idempotencyKey: string,
  customer: UserEntity | OrganizationEntity,
) {
  const subscription = await this.paymentProvider.subscriptions.create(
    {
      customer: customerId,
      items: preparedItems.map((item) => ({
        price: item.priceId,
        quantity: item.quantity,
      })),
      payment_behavior: 'default_incomplete', // Critical for checkout flow
      payment_settings: {
        save_default_payment_method: 'on_subscription', // Saves card for renewals
      },
      metadata: {
        type: 'subscription',
        customerId: customer.id.toString(), // Link to YOUR database
      },
      expand: ['latest_invoice.confirmation_secret'], // Need this for client-side confirmation
    },
    {
      idempotencyKey: `${customer.id.toString()}_${idempotencyKey}`,
    },
  );

  // Extract the client secret for payment element
  const invoice = subscription.latest_invoice as Invoice & {
    confirmation_secret?: {
      client_secret: string;
    };
  };

  if (!invoice || typeof invoice === 'string' || !invoice.confirmation_secret) {
    throw new BadGatewayException('Payment processing temporarily unavailable');
  }

  return {
    clientSecret: invoice.confirmation_secret.client_secret,
    id: subscription.id,
  };
}
Enter fullscreen mode Exit fullscreen mode

Webhook Hell: What Actually Matters

You'll find 30+ webhook events. You need 4:

async handleWebhookEvent(signature: string, req: RawBodyRequest<Request>) {
  const event = this.constructEvent(signature, req);

  switch (event.type) {
    case 'payment_intent.succeeded':
      await this.handlePaymentIntentSucceeded(event.data.object);
      break;
    case 'payment_intent.payment_failed':
      await this.handlePaymentIntentFailed(event.data.object);
      break;
    case 'invoice.paid':
      await this.handleInvoicePaid(event.data.object);
      break;
    case 'invoice.payment_failed':
      await this.handleInvoiceFailed(event.data.object);
      break;
    default:
      console.warn(`Unhandled event type: ${event.type}`);
  }
  return { received: true };
}
Enter fullscreen mode Exit fullscreen mode

Critical distinction:

  • payment_intent.* = One-time payments
  • invoice.* = Subscription payments

Filter them properly:

// In payment intent handlers
if (paymentIntent.invoice) {
  return; // Skip - this is subscription-related
}

// In invoice handlers
if (!invoice.subscription) {
  return; // Skip - this is a one-time invoice
}
Enter fullscreen mode Exit fullscreen mode

NestJS Webhook Middleware

If you're using NestJS, raw body parsing is tricky. Here's what works:

import { json, Request, RequestHandler, Response } from 'express';

interface RequestWithRawBody extends Request {
  rawBody: Buffer;
}

function rawBodyMiddleware(): RequestHandler {
  return json({
    verify: (
      request: RequestWithRawBody,
      response: Response,
      buffer: Buffer,
    ) => {
      if (request.url === '/stripe-webhook' && Buffer.isBuffer(buffer)) {
        request.rawBody = Buffer.from(buffer);
      }
      return true;
    },
  });
}

export default rawBodyMiddleware;
Enter fullscreen mode Exit fullscreen mode

Add to main.ts:

app.use(rawBodyMiddleware());
Enter fullscreen mode Exit fullscreen mode

Resources That Actually Help

Final Thoughts

Stripe's subscription API is powerful but assumes you already know the gotchas. You don't need to learn them the hard way like I did. Store subscription item IDs, handle webhooks correctly, use idempotency keys, and force authentication for subscriptions.

Questions? Hit me up in the comments. I've probably hit the same wall you're facing.

Top comments (1)

Collapse
 
x3n1_7e821849d8f4d96fbefe profile image
X3n1

this helped me a lot!! Thanks!!