Intro — the job to be done

Our video calling API is a self-serve product with monthly subscription billing. We've used Stripe for credit card processing since we started our company, in 2016. But when we were planning the launch of a new API product, we realized that the requirements were different enough from what we'd done before that we'd need to write new billing and payments code.

This promised to be a fun exercise! Stripe's own API design, documentation, and systems infrastructure are the gold standard that all of us who make API-based products aspire to. So writing Stripe-related code is always a pleasure and a learning experience.

In addition, we knew that Stripe had added quite a lot of functionality to Stripe Billing (Stripe's suite of subscriptions and recurring revenue features) since we'd last had a chance to update our payments code.

We start out most projects by writing up a quick jobs to be done outline. Here's what our new billing and payments code would need to do:

  • Give our users an easy way to sign-up for (and cancel) subscriptions to our API products
  • Keep track of who is subscribed to what, and how much usage they've accrued, so we can do respond correctly to each API call, and set up the right functionality and UI for each video call
  • Send monthly invoices, which include charges for several different metered usage line items, and appropriately handle payment and invoice overdue events
Our dashboard, where users can subscribe to an API plan

A data model

If we start a project with a jobs-to-be-done outline, we start development with a data model. Or, put a different way, until we have a data model we're confident in, we're just prototyping, not implementing!

For this project, the data model needs to translate between our representation of customers, subscriptions, and settings and those same concepts in Stripe's APIs.

So we needed to dig in to understand how Stripe's recurring billing features are designed.

Stripe customers, products, plans, subscriptions, and invoices

Almost all recurring billing tasks, in Stripe's systems, are built on top of the following five top-level abstractions:

  • A customer record is an account with a unique id, an email address, and (usually) a stored payment method.
  • A product is something that you charge a customer for. It's useful to think of a product as mapping directly to a line item on an invoice.
  • A plan is what you charge for a specific product (how much, and how the amount is calculated). You can create several different plans for each product. For example, you might have different plans for customers who are paying in different currencies.
  • A subscription is an association between a customer record, and one or more plans. A subscription generates recurring invoices.
  • An invoice generated by a subscription (usually) attempts to charge a customer's credit card, and Stripe events/webhooks are created to help you track and respond to pre- and post-payment events.

Daily.co users, teams, and API plans

In our world, we have users, teams, and plans.

  • A user is someone with an email address who has created a Daily.co account.
  • A team is a Daily.co subdomain (for example, your-team.daily.co). All video call rooms are created under a team subdomain, and multiple users can share access to a team.
  • An API plan is a paid tier of service, billed monthly, that allows a team to use our API calls to create, configure, and use rooms, recordings, and other Daily.co features. (Note that what we call a "plan" is more like what Stripe calls a "subscription" than what Stripe calls a "plan!")

Mapping between two systems

A good first question to ask about a data model is, "where does the data live?"

In general, we think it's a good idea to make Stripe the "one true source" for as much of your customer and billing data as possible. Stripe's systems are proven, secure, and well designed. Stripe's interactive dashboards are very helpful for viewing your payments data, and let you do things like modify customer subscriptions and issue refunds (so you don't have to write code for every corner case you'll need to handle).

A platonically simple (but totally workable) recurring billing implementation

Often, a recurring billing data model can look like this:

  • Your system
    • User account ←→ Stripe customer id
    • A Stripe plan id for each thing a customer can subscribe to
  • In Stripe
    • Customer data
    • Credit card data
    • Subscriptions data
    • Invoices and payments history

All you need to keep track of, in your database, is a Stripe customer ID for each of your user accounts. Then, somewhere in your code, you also hardcode a Stripe plan id for each product that your customers can subscribe to. And you're done. (Well, done with the data model. You still have a little bit of Stripe API code to write!)

The user lifecycle looks like this:

  • New subscription
    1. Use the Stripe Elements UI library to get credit card information and then create a Stripe customer with the token provided by the library. (The credit card data goes straight to Stripe, so you don't have to worry about any of the many, many, security and compliance issues that come with handling credit card data.)
    2. Look up the Stripe plan id in your code, and create a Stripe subscription that sets this customer up on the plan. If you need to store extra information about the subscription, you can embed arbitrary metadata in the Stripe subscription object.
  • Look up subscription data for a user (to verify access to features, or to display info to the user)
    1. Using the stored Stripe customer ID, list the subscriptions for the customer. The subscription will include information about billing, such as payment status. You can also use any metadata you embedded in the subscription object.
  • Cancel a subscription
    1. Using the stored Stripe customer ID, list the subscriptions for the customer.
    2. Find the subscription you need to cancel (if there's more than one, you can filter by which plans the subscription includes, or by any metadata you've embedded in the subscription object). Cancel the subscription, optionally specifying a cancellation date and whether or not to prorate unused time.

That's it! You just need to write five or six back-end functions, add Stripe Elements to your UI, and you have a fully functioning recurring billing setup. (You'll need to use the Stripe dashboard to keep track of customers with credit cards that expire, or who have invoices that fail to automatically charge for any other reason. We didn't talk at all, above, about webhooks, handling payment events, or updating credit card info. But if you're just launching a product, that's probably okay — you can add webhooks and credit card update forms later.)

A little more complexity

We prototyped our recurring billing system, initially, so that it looked exactly like the one described above. Then we added a bit more information and logic in our database and code, based on a couple of additional requirements we have.

We track several kinds of usage on a per-minute basis. Our free API tier is limited to 2,000 video call minutes per month. Our paid tiers have a monthly subscription cost that includes a certain number of video call minutes, plus per-minute charges for additional basic call usage, for large calls, and for recordings. This means we need real-time access to usage data in order to gate functionality based on accrued usage.

We also need to store configuration information about each video call room. A team can have thousands of rooms. When we check whether someone can join a room, and what features to enable in our in-call UI, we have to look at combined room configuration and team configuration fields. Because we already have this code written, and we need access to "plan" information every time we check room access, we added two subscription-related properties to the team configuration info we store in our database: api_plan_id and api_plan_expires.

Parts of our db schema that relate to billing

  • StripeCustomers table
    • Team id — the Daily.co team the Stripe customer record is associated with
    • Stripe customer id
  • Team properties table
    • Team id
    • Api_plan_id — which of our API plans (not Stripe plans) the user is subscribed to
    • Api_plan_expires — when the plan will expire, set by our code that handles the Stripe invoice.payment_succeeded webhook
  • Several events tables that track usage
    • Team id
    • Event properties
    • Event start time
    • Event end time

Metadata stored in the Stripe subscriptions object

Every Stripe subscription that we create includes two metadata fields, api_plan_id and team_name. We use api_plan_id to filter the subscriptions list for customers that have multiple subscriptions with us. (We have other products in addition to our API.) We use the team_name field as a convenient way to know what customer we're looking at, when we're using the Stripe dashboard.

Billing for usage

Stripe has a suite of metered billing features that are quite powerful. You can send usage data to Stripe, and Stripe can automatically calculate usage-based costs on each invoice. Advanced calculations like tiered pricing are supported, too!

We initially planned to use these metered billing features to handle invoicing for our per-minute usage charges. We ran into three issues, though.

  1. A Stripe unit cost must be an even number of cents. Our per-minute prices aren't even number of cents. So to use Stripe's metered billing features we would have to charge for blocks of minutes (10 minutes, or 100 minutes, or an hour).

  2. Usage data needs to be sent to Stripe continuously. There's no way to submit usage data to Stripe after the end of a billing period.

    Much of our usage data is highly granular (video call minutes). One obvious approach would be to submit a usage record to Stripe when a client exits a call. This would create a very large number of usage records, though, and would inevitably miss some usage. (Sometimes people exit calls by, for example, closing the lid on the laptop they are using!) At the same time, though, some of our video calls are very, very long-running. Lots of people use our systems for always-on video portals. An always-on user can stay connected to a video call for weeks!

    Another approach would be to run a cron job to regularly total up usage and submit usage records. This would require that we either drop, or roll over, usage between the time of the last cron job and the time of the monthly invoice. We'd also need to handle some slightly tricky corner cases (cache status info from previous cron jobs, re-run cron jobs that fail, etc).

  3. Using metered billing plans makes changing customer subscriptions more complicated. Stripe requires that you delete usage records before removing metered plan items from a subscription. There's no automatic way to roll those usage charges over to the following invoice (which would be analogous to the proration option that's available for non-metered plans).

We could certainly have worked around all of these. And we did start down that road. But in the end we decided that calculating usage-based costs ourselves would, for our use case, result in cleaner and easier to maintain code.

Stripe sends an invoice.created webhook event for each subscription upon the close of each billing period. Our handler for that webhook runs a usage query on our database, then adds line items to the invoice for each usage-based charge.

The only small thing we're not able to do is show each line's usage number in the Qty column, the way that a natively generated Stripe metered billing item would. Instead, we include the units used in the item description.

Sample invoice line items. All the usage-based items are "Qty 1", with the actual usage numbers included in the item description text

Example code

API Plan definitions

Each of our API plans is defined by an entry in a plans object, in a top-level utility file called PlansAndConfigs.js. Both our front-end and back-end code include parts of this file.

You can view the entire plans object literal, if you'd like to see the full structure. The parts that relate to Stripe are stripePlans, and usage.

The stripePlans property is a list of Stripe plan ids that make up a subscription to our API plan. We define these plans in the Stripe dashboard, and we force the Stripe plan id to be the same in both our test and production Stripe data.

The usage property is a list of objects that are used to add metered usage line items to each monthly invoice.

  • The field property is a key in the usage records returned by our internal usage query functions
  • The description property is the first part of the line item description for the invoice
  • The costf property is a function that takes a number of minutes of usage as an argument, and returns a cost, in dollars.

Creating a customer

We use the react-stripe-elements package to embed credit card data entry forms into our user interface. Here's what the (customized) form looks like in a modal that's part of the Daily.co dashboard.

The Elements machinery creates an opaque token that we can use to create a Stripe customer. When the "Start subscription" button is clicked, above, we run code that looks like this. (The production version of this code is actually spread out across three functions, and we're ignoring error handling. We'll ignore error handling in all the code samples here.)

// display UI "spinner" and block input
this.setState({ processing: true });
// get an opaque token from Stripe Elements
let payload = await stripe.createToken({
  type: 'card',
  name: name,
});
// send a request to our own server to create a Stripe customer record
let response = await fetch("/dashboard/create-customer", {
  method: "POST",
  headers: { Authorization ... },
  body: { stripetoken: payload.token.id, teamId: dailyTeam.id } 
});
// update the UI
dispatch(refreshBillingInfo(await response.json()));
this.setState({ processing: false });

On the server, the code to create the new customer looks like this.

// ping Stripe to create a new customer object
// (res.locals.user is set by an auth middleware function)
let customer = await stripe.customers.create({
  email: res.locals.user.email,
  source: req.body.stripeToken,
});
// make a database record to associate the Stripe customer object
// with a Daily.co team. we're using the Sequelize ORM library
let cusRecord = models.StripeCustomer.create({
  teamId: req.body.teamId,
  stripeCustomerId: customer.id,
});
// return the new customer billing info in a form that our
// front-end code can consume
let billingInfo = {
  stripeCustomerId: customer.id,
  email: customer.email,
  card: customer.sources.data[0],
  subscriptions: [],
};
res.status(200).send({ billingInfo });

Updating payment info

Updating credit card info is very similar. On the server side, we look up an existing customer record and modify it, instead of creating a new one.

let cusRecord = await models.StripeCustomer.findOne({
  where: { teamId }
});
let customer = await stripe.customers.update(
  cusRecord.stripeCustomerId, { source: req.body.newStripeToken }
);
let billingInfo = {
  stripeCustomerId: customer.id,
  email: customer.email,
  card: customer.sources.data[0],
  subscriptions: customer.subscriptions.data,
};
res.status(200).send({ billingInfo });

Subscribing, canceling, and upgrading

Subscribing customers to plans is easy. Handling upgrades, downgrades, and cancellations requires making some policy decisions. It's surprisingly easy for corner cases to proliferate if you allow customers to transition between any plan, any time during a billing cycle.

We decided:

  • Customers can't downgrade from our top paid tier to our lower paid tier. If someone really wants to downgrade, she can cancel and then subscribe again.
  • Upon cancellation, we cancel the plan immediately. (We don't cancel at the end of the billing cycle, which is a common way to structure Software as a Service plans.) We do this to avoid the possibility that a customer continues to rack up usage-based charges, and then is surprised when the final bill arrives. When a customer cancels, we refund a prorated amount of the base plan charge, and also charge for any accrued usage.
  • Customers can upgrade from our lower paid tier to our upper paid tier. When a customer upgrades, the prorated difference between the plans is charged on the next monthly invoice. (This is Stripe's default approach to handling plan changes.)

Here's code that subscribes a customer to one of our paid tiers. (Again, simplified and with no error handling!)

// this code is inside an HTTP POST request handler, and the
// stripeCustomerId and dailyPlanId are supplied as body
// arguments to the POST request. res.locals.team is filled
// in by a middleware function. we look up the list of
// stripePlanIds that map to a Daily Plan in the `plans` config
// structure described above.
let stripeCustomerId = req.body.stripeCustomerId,
    dailyPlanId = req.body.dailyPlanId,
    stripePlanIds = plans[dailyPlanId].stripePlans;
// ask Stripe to create a subscription for this customer, consisting
// of our list of plan ids.
let subscription = stripe.subscriptions.create({
  customer: stripeCustomerId,
  items: stripePlanIds.map((id) => ({ plan:id })),
  metadata: {
    api_plan_id: dailyPlanId,
    team_name: res.locals.team.name,
  },
});
// keep track in our database of the (Daily.co) plan this team
// is subscribed to
await res.locals.team.setProperty({
  name: 'api_plan_id',
  value: dailyPlanId
});
// set the plan expiration time to one hour in the future, to allow
// the credit card charge time to clear. when the payment processes,
// our webhook handling code will update the plan expiration time
await res.locals.team.setProperty({
  name: 'api_plan_expires',
  value: Math.floor(Date.now()/1000) + 60*60
});
res.status(200).send({ subscription });

Here's code that cancels a subscription.

let stripeCustomerId = req.body.stripeCustomerId,
    apiPlanId = req.body.apiPlanId,
    stripeCustomer = await stripe.customers.retrieve(stripeCustomerId),
    subscription = stripeCustomer.subscriptions.find(
      (s) => s.metadata.api_plan_id === apiPlanId
);

await stripe.subscriptions.del(
  subscription.id, { invoice_now: true, prorate: true }
);
await res.locals.team.setDomainProperty({
  name: 'api_plan_id',
  value: null
});
await res.locals.domain.setDomainProperty({
  name: 'api_plan_expires',
  value: null
});
res.status(200).send({ deleted: true });

Here's code that upgrades a subscription, which is more complicated than subscribing to and cancelling subscriptions. We have to "diff" the subscription items list ourselves. This code is generalized and should work to switch between arbitrary plans. But note that this approach fails if the upgrade process tries to remove any metered billing plan items from the subscription.

import { pull } from 'lodash';

let stripeCustomerId = req.body.stripeCustomerId,
    oldPlanId = req.body.oldPlanId,
    newPlanId = req.body.newPlanId,
    newStripePlanIds = plans[newPlanId].stripePlans,
    stripeCustomer = await stripe.customers.retrieve(stripeCustomerId),
    subscription = stripeCustomer.subscriptions.find(
      (s) => s.metadata.api_plan_id === oldPlanId
    );

let items = buildStripeChangedItemsList(subscription, newStripePlanIds);
await stripe.subscriptions.update(subscription.id, {
  items, metadata: { api_plan_id: dailyPlanId }
});
await res.locals.team.setProperty({
  name: 'api_plan_id',
  value: newPlanId
});
res.status(200).send({ modified: true });

function buildStripeChangedItemsList(subscription, newStripePlanIds) {
  // loop through a subscription's items checking if they are
  // present in the new items list. if present, consider them
  // unchanged and ignore them. if not present, we need to mark them
  // deleted in our subscriptions.update call. finally, we also need
  // to add any new items into the subscriptions.update call

  // first, copy the Stripe plan ids array so we can modify it
  // destructively
  let planIds = newStripePlanIds.slice(),
      itemsModList = [];
  for (var item of subscription.items.data) {
    if (planIds.includes(item.plan.id)) {
      // unchanged - remove from planIds array.
      pull(planIds, item.plan.id);
    } else {
      // not present in new plan, so we need to mark this as
      // deleted. (this will fail for metered-plan items)
      itemsModList.push({ id: item.id, deleted: true });
    }
  }
  // add in remaining new items and return
  return itemsModList.concat(planIds.map((id) => ({ plan: id })));
}

Webhook: adding usage-based line items to an invoice

As described in Billing for usage, above, we don't use Stripe's metered pricing plan features. Instead, we manually add line items to our invoices for our usage-based costs.

Here's how we handle the invoice.created webhook that Stripe sends just before finalizing an invoice.

// inside an HTTP handler for Stripe webhooks
const event = stripe.webhooks.constructEvent(
      req.rawBody,
      req.headers['stripe-signature'],
      process.env.STRIPE_INVOICE_HOOK_SECRET
    );

if (event.type === 'invoice.created') {
  let invoice = event.data.object;
  if (invoice.finalized_at) {
    // this invoice is already finalized. it's likely an initial
    // subscription charge. in any case, we can't modify a
    // finalized invoice.
    res.status(200).send();
    return;

    let subscription = await stripe.subscriptions.retrieve(invoice.subscription);
    await stripeInvoiceCreated(invoice, subscription);
    res.status(200).send();
    return; 
  }
}

//

async function stripeInvoiceCreated(invoice, subscription) {
  let apiPlan = plans[subscription.metadata.api_plan_id],
      stripeCustomerId = invoice.customer,
      timeStart = invoice.period_start,
      timeEnd = invoice.period_end;
  let stripeCustomer = await models.StripeCustomer.findOne({
    where: { stripeCustomerId }
  });
  let team = await models.Team.findOne({ where: {id: stripeCustomer.teamId} });

  let periodUsage = await usageTotals({ tfStart, tfEnd, team });
  for (var u of apiPlan.usage) {
    // here we create a new invoice line item. we have to set the
    // amount because our unit costs may be sub-cent. we aren't
    // allowed to pass both a quantity and an amount, so we embed
    // the quantity in the item description.
    let minutes = periodUsage[u.field],
        cost = Math.floor(u.costf(minutes)*100); // convert to cents
    await stripe.invoiceItems.create({
      customer: stripeCustomerId,
      invoice: invoice.id,
      currency: 'usd',
      amount: cost,
      description: `${u.description} (qty ${minutes})`
    });
  }
}

Webhook: extending a subscription's expires time

We also listen for Stripe's invoice.payment_succeeded webhook, and — as suggested in the Stripe subscription webhooks tutorial — update our api_plan_expires team property when each monthly subscription payment succeeds.

This code is very simple!

// again, inside our HTTP handler for Stripe webhooks
if (event.type === 'invoice.payment_succeeded') {
  let invoice = event.data.object,
      subscription = await stripe.subscriptions.retrieve(invoice.subscription);
  await stripeInvoicePaymentSucceeded(invoice, subscription)
  res.status(200).send();
  return;
}

//

async function stripeInvoicePaymentSucceeded(invoice, subscription) {
  // canceled subscription, ignore. assume we set api_plan_expires in
  // our cancellation code
  if (subscription.canceled_at) {
    return;
  }

  // period end in the past. ignore. old invoices being paid, or
  // something pathological
  if (subscription.current_period_end < Date.now()/1000) {
    return;
  }

  let stripeCustomerId = invoice.customer,
      paidUntil = subscription.current_period_end,
      stripeCustomer = await models.StripeCustomer.findOne({
        where: { stripeCustomerId }
      }),
      team = await models.Team.findOne({ where: {id: stripeCustomer.teamId} });

  // set expires to 3 days after the next billing date, to give
  // failed charges a chance to automatically retry
  await team.setProperty({
    name: 'api_plan_expires',
    value: paidUntil + 60*60*24*3
  });
}

Questions, comments, suggestions?

We love to talk about writing production-quality code! If you have thoughts, please send us email: help@daily.co. Or find us on Twitter at @trydaily.