skip to content

Tips for integrating Stripe

Published on • 10 min read

web

I recently integrated Stripe into Claras with what I believe are pretty common pricing models for the SaaS world. Yet I still spent a considerable amount of time crawling through the docs, piecing things together myself, and coming up with creative work arounds. So here they are in case anyone else is in a similar position!

Modeling

We wanted to provide two paid plans:

  • Unlimited
    • No usage limits
    • Billed per seat
    • Two types of seats (one more expensive than the other)
    • Tiered volume based discounts
    • Annual discounts
  • Pay as you go
    • Fixed monthly fee with included usage
    • Charge for overage at the end of the billing period

I landed on creating three products:

  • Adviser licence (more expensive seat)
    • Monthly price with volume discounts
    • Annual price with volume discounts
  • Assistant licence (less expensive set)
    • Monthly price with volume discounts
    • Annual price with volume discounts
  • Pay as you go
    • Flat Rate monthly price
    • Usage-based, Per Tier and Graduated price as per their docs
    • A meter to capture usage events

The “Unlimited plan” is then a subscription to the two seat based products.

Keeping it simple

I wanted to use Stripe as the source of truth - minimising the amount of data we needed to keep in sync. We’ve ended up storing a stripe_customer_id and is_subscribed field against the account records. As for environment variables, we only have STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET. This has been surprisingly sufficent.

First, use lookup keys for backwards compatability and to be enviroment agnostic. This allows:

  • New customers to go on the most recent price
  • Existing customers to remain on old prices

When creating a checkout session for “Customer B”, you get the latest active price for that key and use it for the subscription. But let’s say “Customer A”, who signed up a year ago and is still on an old price, wants to add a new seats to their subscription (updating quantity of the subscription item). You can:

  1. Get the subscription for that customer
  2. Find the subscription item with the correct lookup key for that seat
  3. Update the quantity, which will remain at the old price

…all without having to store historical price_ids throughout your codebase for each environment. And if “Customer B” needs to move to the new pricing at some point, that’s where subscription schedules come into play.

Second, subscribe to the following webhook events: customer.created, customer.subscription.created, customer.subscription.resumed, customer.subscription.deleted, customer.subscription.paused. In your endpoint, simply handle these events like so:

switch (event.type) {
case 'customer.created': {
await handleCreateCustomer(event);
break;
}
case 'customer.subscription.created':
case 'customer.subscription.resumed': {
await handleEnableSubscription(event);
break;
}
case 'customer.subscription.deleted':
case 'customer.subscription.paused': {
await handleDisableSubscription(event);
break;
}
default:
console.warn(`Unhandled event type ${event.type}`);
}

Finally, when needing to know what plan a customer is on, I simply make an api call to get their subscription, and match the line items with the lookup keys. I’d recommend limiting customers to one subscription at a time so you can safely assume the first subscription is the only one.

const subscriptions = await stripe.subscriptions.list({
customer: 'cus_xxx',
});
const isPayg = subscriptions.data[0].items.data.some(
(item) =>
item.price.lookup_key ===
STRIPE_PRODUCT_CATALOG.payg.base_price_lookup_key,
);

This was only required in areas such as billing and team management, while we used the is_subscribed field in our database to provision access.

Proratering usage

Updating and moving between products was key for our modeling, but this is where things got tricky. The “Pay as you go” plan aimed to provide a low commitment entry point, with customers transitioning to the “Unlimited” plan when confident in the product.

The problem is Stripe doesn’t prorate charges for usage based pricing. So when moving from “Pay as you go” to “Unlimited” the overage wouldn’t be charged. Initially I explored cancelling the subscription and starting a new one, but it quickly became messy with invoicing, account credits, manual prorations, negative line items, tax etc. Plus, the customer wasn’t actually “Cancelling” their subscription. So reports like MRR, churn, and LTV would’ve been incorrect.

Here’s what I landed on:

  1. Update the subscription with new seat based products. For the PAYG product (which as two prices remember) delete the base recurring price and leave the overage price.
const subscription = await stripe.subscriptions.update(
'sub_1NpKx2B9wXjYkLmP4nRtV3q8',
{
proration_behavior: 'always_invoice',
billing_cycle_anchor: 'now',
items: [
{
id: 'si_H2MnKjLp9qRs5T', // PAYG base price
deleted: true,
},
{
price: 'price_1Mn7vWB2HkLpXjN8mQtY5x3Z', // Seat based price
quantity: 3,
},
],
}
);
  1. Immediatly update the subscription again to delete the overage price.
const subscription = await stripe.subscriptions.update(
'sub_1NpKx2B9wXjYkLmP4nRtV3q8',
{
items: [
{
id: 'si_H7XnPkLm3vTj9Y', // PAYG overage price
deleted: true,
},
],
}
);

It’s important you reset the billing cycle in step 1 as this is the only trigger Stripe uses (apart from canclling) to charge for overage up until that point. But the overage item needs to remain against the subscription when this takes place, hence removing it in a second step.

Providing the customer hasn’t recorded new usage during the few milliseconds between the two updates, you’ve now captured all overage and update their plan while retaining the original subscription.

Previewing changes

We wanted our customers to know whenever a billable event was about to occur, and exactly what to expect. Not only is this a good user experience, but it would prevent surprise charges and the support requests that come along with it. In our case, this was any time they:

  • Invited new team members
  • Updated the role of existing team members
  • Switched plans
  • Switched billing intervals (e.g. monthly to annual)

Once again, I went down a rabbit hole of trying to make these calculations myself. But with different seats, different plans, old prices, proration, volume discounts, coupon codes, tax etc. it got tricky. Plus, this is what Stripe is for right!

That’s where I stumbled across the preview invoice feature.

const currentUpcomingInvoice = await stripe.invoices.retrieveUpcoming({
customer: 'cus_xxx',
subscription: 'sub_xxx',
preview_mode: 'recurring',
});
const newUpcomingInvoice = await stripe.invoices.retrieveUpcoming({
customer: 'cus_xxx',
subscription: 'sub_xxx',
preview_mode: 'recurring',
subscription_details: {
items: newLineItems, // new line items with updated prices and/or quantity
},
});
const totalCurrentCost =
(currentUpcomingInvoice.total_excluding_tax ??
currentUpcomingInvoice.total) / 100;
const totalNewCost =
(newUpcomingInvoice.total_excluding_tax ?? newUpcomingInvoice.total) /
100;
const costIncrease = totalNewCost - totalCurrentCost;

Using preview_mode: 'recurring' was key as it allows you to see the changes to their overall monthly or yearly price. Otherwise proration and where the customer is in their billing cycle would provide incorrect values. This is great for simple changes like inviting a new team member.

For complex changes like switching plans I omit the preview mode parameter and render the full invoice in a table. This allows customers to see the prorations that will take place, reducing friction and support requests.

Migrating between stripe accounts

In the early days of Claras we needed validation people would pay for the product before investing too much time. So we used an existing Stripe account and sent customers a payment link. Once we had 50 paying customers we knew we’d found product market fit, and it was time to do things properly (hence setting up all this automated self serve billing).

Part of this was moving to a dedicated Stripe account for Claras. Stripe provides some good migration tools, but I ran into a few edge cases. The two biggest ones were:

  • Subscription schedules. When usig the CSV import tool, Stripe would schedule the new subscriptions to go live a minimum of 24h in the future - but usually when the next billing cycle started. During the time inbetween, existing customers wouldn’t be able to update their subscription, move to the new PAYG plan, or even preview their subscription in the new account (the preview invoice feature doesn’t work with subscriptions). So a zero downtime migration was looking tricky.
  • Backdating subscriptions. For some reason I couldn’t backdate the start date of the subscriptions despite there being a field in the CSV schema. I wanted to retain the original start date as it would simplify the discounts applied to subscriptions. For example, if you have a three month discount and a subscription that’s two months old needs to be migrated, you could recreate the discount in the new Stripe account and when applied to a backdated subscription, Stripe would only apply it for one more month. Without backdating, I was looking at setting up discounts for one month, two months, three months etc.

Instead, I ended up creating a simple script that looped used the CSV file and created the subscriptions via the API. Here’s the general steps I took:

  1. Setup new products, prices and coupons in new Stripe account.
  2. Export old subscriptions as a csv.
  3. Remap subscriptions, prices, customer, coupons etc. The new csv should have the headings: customer, price, quantity, old_stripe_sub_id, automatic_tax, coupon, billing_cycle_anchor, backdate_start_date.
  4. Copy customers to the new account.
  5. Set the default payment method on all customers, otherwise the new subscriptions will fail during renewal.
  6. Import subscriptions via the script.
  7. Update environment variables in Vercel (or wherever you host your app).
  8. Deploy changes and/or rebuild and deploy for new environment variables to be live.
  9. Enable webhooks on the new account.
  10. Disable webhooks from the old account.
  11. Ensure everything works in production.
  12. Cancel the old subscriptions.
  13. Notify any customers with unsupported payment methods to update their payment methods via the customer protal.
import Stripe from 'stripe';
import parse from 'csv-simple-parser';
import { z } from 'zod';
const stripe_new = new Stripe('live_stripe_key_from_new_account');
const stripe_old = new Stripe('live_stripe_key_from_old_account');
async function paymentMethods() {
const customers = await stripe_new.customers.list({ limit: 1 });
console.log(`Found ${customers.data.length} customers`);
for (const customer of customers.data) {
const paymentMethods = (
await stripe_new.customers.listPaymentMethods(customer.id)
).data;
console.log(
`Found ${paymentMethods.length} payment methods for ${customer.id}`,
);
if (
paymentMethods.length > 0 &&
!customer.invoice_settings.default_payment_method
) {
console.log(
`Setting ${paymentMethods[0].id} as default for ${customer.id}`,
);
await stripe_new.customers.update(customer.id, {
invoice_settings: {
default_payment_method: paymentMethods[0].id,
},
});
}
}
}
const subscriptionSchema = z.object({
customer: z.string(),
price: z.string(),
quantity: z.number(),
old_stripe_sub_id: z.string(),
automatic_tax: z.boolean(),
coupon: z.string().optional(),
billing_cycle_anchor: z.number(),
backdate_start_date: z.number(),
});
type SubscriptionRow = z.infer<typeof subscriptionSchema>;
async function createSubscription(row: SubscriptionRow) {
try {
const subscription = await stripe_new.subscriptions.create({
customer: row.customer,
automatic_tax: {
enabled: row.automatic_tax,
},
backdate_start_date: row.backdate_start_date,
billing_cycle_anchor: row.billing_cycle_anchor,
...(row.coupon && {
discounts: [
{
coupon: row.coupon,
},
],
}),
proration_behavior: 'none', // This is important
items: [
{
price: row.price,
quantity: row.quantity,
},
],
metadata: {
old_stripe_sub_id: row.old_stripe_sub_id,
},
});
console.log(
`Created subscription ${subscription.id} for customer ${row.customer}`,
);
} catch (e) {
console.error(
`Error creating subscription for customer ${row.customer}: ${JSON.stringify(e, null, 2)}`,
);
}
}
async function getRecords() {
const file = Bun.file('./import-subscriptions.csv');
const transform = (
value: string,
x: number,
y: number,
quoted: boolean,
): string | boolean | undefined =>
value === ''
? undefined
: quoted
? value
: value === 'FALSE'
? false
: value === 'TRUE'
? true
: value;
const records: SubscriptionRow[] = [];
const csv = parse(await file.text(), {
header: true,
infer: true,
transform,
});
for (const row of csv) {
const validatedRow = subscriptionSchema.parse(row);
records.push(validatedRow);
}
return records;
}
async function cancelOldSubscription(row: SubscriptionRow) {
try {
const subscription = await stripe_old.subscriptions.cancel(
row.old_stripe_sub_id,
{ invoice_now: false, prorate: false },
);
console.log(
`Canceled subscription ${subscription.id} for customer ${row.customer}`,
);
} catch (e) {
console.error(
`Error canceling subscription for customer ${row.customer}: ${JSON.stringify(e, null, 2)}`,
);
}
}
async function main() {
await paymentMethods();
const records = await getRecords();
for (const record of records) {
await createSubscription(record);
await new Promise((resolve) => setTimeout(resolve, 500));
}
for (const record of records) {
await cancelOldSubscription(record);
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
main();

For details, do checkout their docs on migrating subscriptions. And I recommend trying this via your test accounts first. But from start to finish this only took ~15min and avoided any downtime. As soon as it was done, we could send an update out to customers and they could change or update their plans.

Bonus

  • Download the app and subscribe to the push notifications. It’s a great motivator seeing all the payments come through, and constant reminder to keep shipping value.
  • Create a customer before creating the checkout session, then use metadata to link the customer to your database record (with email as a fallback). You can then use push notification or run a report in Stripe to know when a high intent customer started a checkout session, but didn’t convert.