Subscription billing integration: lessons from production
We've learned the hard way that recurring revenue systems demand more care than one-off payments. A single mistake in your subscription state machine or webhook handling can silently lock paying customers out of your product, or worse—stop charging them entirely. This post distills what we've discovered across multiple production systems running Stripe Billing at scale.
Products and prices — Define plans with recurring prices
Your first decision in Stripe Billing is structural: how you model your offerings as Products and Prices. A Product represents what you're selling (e.g., "Pro Plan"). A Price is the commercial term attached to it—the amount, currency, and billing interval. For subscriptions, you create a Price with recurring parameters instead of a one-time charge.
In production, we typically create Products and Prices in Stripe's dashboard during setup, then store the Price IDs in our database for retrieval at checkout time. This keeps configuration out of code and makes plan changes auditable.
// Fetch a plan's price details for checkout
const getSubscriptionPrice = async (planId: string) => {
// planId maps to a Stripe Price ID stored in your config or database
const price = await stripe.prices.retrieve(planId);
return {
id: price.id,
amount: price.unit_amount, // in cents
currency: price.currency,
interval: price.recurring?.interval, // 'month' | 'year'
intervalCount: price.recurring?.interval_count,
};
};
When designing your product tier hierarchy, think about trial periods, setup fees, and metered billing upfront. Stripe Prices support all of these via the trial_period_days, transform_quantity, and billing_scheme fields. Decide on these before going live; retroactively changing a Price's terms is not straightforward.
A common pattern we use: store plan metadata in your database alongside the Price ID so your UI can display feature lists without hitting Stripe's API repeatedly.
// Example plan config in your database
interface Plan {
id: string;
name: string;
stripePriceId: string;
features: string[];
monthlyPrice: number;
}
const PLANS: Plan[] = [
{
id: 'starter',
name: 'Starter',
stripePriceId: 'price_1234567890',
features: ['Up to 10 projects', 'Email support'],
monthlyPrice: 29,
},
{
id: 'pro',
name: 'Pro',
stripePriceId: 'price_0987654321',
features: ['Unlimited projects', '24/7 support', 'Custom integrations'],
monthlyPrice: 99,
},
];
Create subscriptions — Via Checkout or the API with a customer
Once your products and prices are defined, you need a way to convert a prospect into a subscriber. Stripe offers two main paths: Checkout (hosted, PCI-compliant, handles payment method collection) or the API (more control, but you handle payment method validation yourself).
In most cases, we prefer Checkout for its simplicity and built-in compliance. You redirect the user to a Stripe-hosted page, they enter payment details, and Stripe calls a webhook to confirm the subscription was created.
// Create a Checkout session for a new subscription
const createCheckoutSession = async (
customerId: string,
priceId: string,
returnUrl: string
) => {
const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: customerId, // Link to existing Stripe customer
line_items: [
{
price: priceId,
quantity: 1,
},
],
success_url: returnUrl + '?session_id={CHECKOUT_SESSION_ID}',
cancel_url: returnUrl,
// Optional: Apply a coupon
discounts: [{coupon: 'COUPON_ID'}],
});
return session.url;
};
// In your route handler
app.post('/subscribe', async (req, res) => {
const { priceId } = req.body;
const user = req.user; // Authenticated user
// Ensure a Stripe customer exists for this user
let stripeCustomerId = user.stripeCustomerId;
if (!stripeCustomerId) {
const customer = await stripe.customers.create({
email: user.email,
metadata: { userId: user.id },
});
stripeCustomerId = customer.id;
// Persist to database
await db.users.update(user.id, { stripeCustomerId });
}
const checkoutUrl = await createCheckoutSession(
stripeCustomerId,
priceId,
'https://app.example.com/billing/success'
);
res.json({ url: checkoutUrl });
});
For API-driven subscriptions (e.g., team invitations creating instant subscriptions), you'll call stripe.subscriptions.create() directly. This requires the customer to already have a saved payment method on file.
// Create subscription via API (requires existing payment method)
const createSubscriptionDirectly = async (
customerId: string,
priceId: string
) => {
const subscription = await stripe.subscriptions.create({
customer: customerId,
items: [{ price: priceId }],
// Stripe will attempt to charge the default payment method immediately
});
return subscription;
};
The key insight: always create a Stripe Customer object before creating a subscription. This object is your anchor for linking Stripe data back to your user. Store the stripe_customer_id in your database immediately.
Sync status — Listen to customer.subscription.* events
Subscriptions are stateful. They move through phases: trialing, active, past_due, canceled, incomplete, and others. Stripe publishes webhook events whenever these states change. You must listen to them and update your database accordingly.
The core events you'll handle:
customer.subscription.created— New subscription initiated.customer.subscription.updated— Subscription modified (e.g., plan change, payment method updated).customer.subscription.deleted— Subscription canceled.invoice.payment_succeeded— Recurring payment processed successfully.invoice.payment_failed— Payment declined; subscription may move topast_due.
// Webhook handler for subscription events
app.post('/webhooks/stripe', express.raw({type: 'application/json'}), async (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
res.status(400).send(`Webhook Error: ${err.message}`);
return;
}
try {
switch (event.type) {
case 'customer.subscription.created':
case 'customer.subscription.updated': {
const subscription = event.data.object;
await syncSubscriptionToDatabase(subscription);
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object;
await db.subscriptions.update(subscription.id, {
status: 'canceled',
canceledAt: new Date(subscription.canceled_at * 1000),
});
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object;
const subscription = await stripe.subscriptions.retrieve(invoice.subscription);
await notifyUserOfPaymentFailure(subscription.customer, invoice);
break;
}
default:
break;
}
} catch (err) {
console.error('Error processing webhook:', err);
}
res.json({ received: true });
});
// Core sync function
const syncSubscriptionToDatabase = async (stripeSubscription: Stripe.Subscription) => {
const user = await db.users.findByStripeCustomerId(stripeSubscription.customer);
await db.subscriptions.upsert(stripeSubscription.id, {
userId: user.id,
stripeSubscriptionId: stripeSubscription.id,
stripeCustomerId: stripeSubscription.customer,
status: stripeSubscription.status,
priceId: stripeSubscription.items.data[0].price.id,
planId: stripeSubscription.items.data[0].price.product,
currentPeriodStart: new Date(stripeSubscription.current_period_start * 1000),
currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000),
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end,
canceledAt: stripeSubscription.canceled_at ? new Date(stripeSubscription.canceled_at * 1000) : null,
});
};
Process webhooks asynchronously if possible. A failed webhook delivery should be retried by Stripe (it will for up to 3 days), so don't block the response. Use a message queue or async job processor to ensure idempotency—same webhook received twice should produce the same result.
Entitlements — Grant access based on subscription status in your database
The final piece: translating subscription data into product access. Your application checks the user's subscription status whenever they perform a gated action (upload a file, view a report, invite a teammate). This is where many teams make mistakes—they either check Stripe directly on every request (slow) or cache stale data (dangerous).
The correct pattern: keep subscription data in your database, synced via webhooks, and check your database at runtime. Only talk to Stripe for billing page displays or admin tools.
// Helper to determine if a user has an active subscription
const isSubscriptionActive = async (userId: string): Promise => {
const subscription = await db.subscriptions.findByUserId(userId);
if (!subscription) return false;
// Active if status is 'active' or 'trialing'
if (['active', 'trialing'].includes(subscription.status)) {
return true;
}
// Subscription is ending at period end; still allow access until then
if (subscription.cancelAtPeriodEnd && subscription.status === 'active') {
return new Date() < subscription.currentPeriodEnd;
}
return false;
};
// Middleware to enforce entitlement
const requireActiveSubscription = async (req, res, next) => {
const isActive = await isSubscriptionActive(req.user.id);
if (!isActive) {
return res.status(403).json({ error: 'Subscription required' });
}
next();
};
// Use it on protected routes
app.post('/api/reports', requireActiveSubscription, (req, res) => {
// Generate report for premium user
});
For teams needing feature-level control, store plan features in your database and check them at runtime. If a user downgrades from Pro to Starter mid-cycle, disable Pro-only features immediately.
// Fetch user's current plan and its features
const getUserPlan = async (userId: string) => {
const subscription = await db.subscriptions.findByUserId(userId);
if (!subscription) return null;
const plan = await db.plans.findById(subscription.planId);
return plan;
};
const canUseFeature = async (userId: string, featureKey: string): Promise => {
const plan = await getUserPlan(userId);
if (!plan) return false;
return plan.features.includes(featureKey);
};
app.post('/api/custom-integrations', async (req, res) => {
if (!(await canUseFeature(req.user.id, 'custom_integrations'))) {
return res.status(403).json({ error: 'Feature not available in your plan' });
}
// Proceed...
});
Common mistakes
- Checking Stripe on every request: Don't call
stripe.subscriptions.retrieve()for access control. It's slow and you'll hit rate limits. Sync to your database and query there. - Ignoring webhook ordering: Webhooks can arrive out of order. If
subscription.updatedarrives beforesubscription.created, your sync will fail. Use upsert logic and idempotent IDs. - Not idempotent webhook handlers: Process the same webhook twice and you'll double-charge or double-grant access. Store processed webhook IDs to detect duplicates.
- Forgetting to handle
past_due: When a payment fails, the subscription enterspast_due. Stripe will retry, but you should deny access until it recovers. Don't wait forcanceled. - Hardcoding plan IDs: Use environment variables or a config table for Price IDs. Hardcoding makes migrations painful.
- Missing the cancel_at_period_end flag: Users often request cancellation but expect service through the end of their billing cycle. If
cancel_at_period_endis true, keep access live untilcurrent_period_end.
Key takeaways
- Model your tiers as Stripe Products and Prices early; retroactive changes are painful.
- Use Checkout for most new subscriptions; it handles PCI compliance and reduces your liability.
- Create a Stripe Customer for every user before subscription creation and persist the ID.
- Sync subscription state to your database via webhooks; never query Stripe on the critical path.
- Implement idempotent webhook handlers with duplicate detection based on event ID.
- Build entitlements on top of your local subscription table, checking status and plan features at request time.
- Handle
past_due,cancel_at_period_end, and trial states explicitly; they're common edge cases.






Comments
Loading comments…