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
- No-code customer portal - Great for MVPs, zero flexibility
- Pre-built checkout - Stripe-hosted, limited customization
- Advanced integration - Full control, maximum pain
- 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',
},
);
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}`,
);
}
}
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,
};
}
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 };
}
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
}
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;
Add to main.ts:
app.use(rawBodyMiddleware());
Resources That Actually Help
- Complete webhook event types - Bookmark this
- Subscription lifecycle - Read twice
- Testing subscriptions - Use test clocks, not trial and error
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)
this helped me a lot!! Thanks!!