Skip to content
lazy devs
5 min readLazy Devs

Implementing subscription billing the right way

Subscription billing looks simple until proration, failed payments, and tax show up. Here is how to scope it realistically, what to watch out for, and rough costs.

Subscription billing is the feature everyone underestimates. The happy path (someone enters a card, you charge them every month) takes an afternoon. The other ninety percent (proration, failed payments, plan changes mid-cycle, tax, refunds, dunning, invoices that have to be correct enough for an accountant) is where teams quietly lose a month. This post is about scoping that work honestly so you do not get surprised.

Do not build the money parts yourself

The single most important decision: use a billing provider for the parts that touch money. Stripe Billing is the default for most teams, and there are good reasons to pick Paddle or Lemon Squeezy instead. The difference matters more than people realize.

With Stripe, you are the merchant of record. You collect the payment, and you are responsible for charging sales tax and VAT in every jurisdiction where you have customers. Stripe Tax can calculate the rates for you, but you still file and remit. That is real ongoing accounting work once you sell across borders.

With Paddle or Lemon Squeezy, they are the merchant of record. They handle tax calculation, collection, and filing globally, and they pay you out as a single vendor. You give up some flexibility and pay a higher percentage (roughly 5 percent plus fixed fees versus Stripe's 2.9 percent plus 30 cents), but for a small team selling internationally, not owning global tax compliance is worth a lot.

Rough rule: if you are a small team selling to customers in many countries and you do not want a tax headache, start with a merchant of record. If you are mostly domestic, have specific billing logic, or expect to negotiate enterprise contracts, Stripe gives you more room.

Whatever you choose, the provider owns card data, PCI scope, and the actual charging. You should never store a raw card number. If anyone proposes building card handling in-house, that is a red flag.

What actually takes the time

Here is where the real hours go, roughly in order of how often they get underestimated.

Webhooks and your own source of truth

The provider charges the card, but your app needs to know what happened. That communication is webhooks: events like invoice.paid, customer.subscription.updated, invoice.payment_failed. The mistake is treating the provider as your database. You should mirror subscription state into your own tables so your app does not have to call Stripe on every page load and so you have a record when the provider is slow or down.

This means building idempotent webhook handlers. Webhooks arrive out of order, get delivered twice, and occasionally show up an hour late. Your handler has to be safe to run more than once with the same event.

// Idempotent webhook handling: record the event id before acting on it
export async function handleStripeEvent(event: Stripe.Event) {
  const seen = await db.webhookEvent.findUnique({ where: { id: event.id } });
  if (seen) return; // already processed, ignore the duplicate
 
  await db.webhookEvent.create({ data: { id: event.id, type: event.type } });
 
  switch (event.type) {
    case "invoice.payment_failed":
      await markPastDue(event.data.object);
      break;
    case "customer.subscription.updated":
      await syncSubscription(event.data.object);
      break;
  }
}

That tiny seen check prevents a customer from being upgraded, charged, or emailed twice when a webhook redelivers. Skipping it is the most common billing bug we get called to fix.

Plan changes and proration

When someone moves from a 29 dollar plan to a 99 dollar plan on day 12 of a 30 day cycle, what do they pay today? Proration answers that, and your provider can compute it, but you have to decide the policy. Do upgrades take effect immediately with a prorated charge? Do downgrades wait until the period ends so nobody games refunds? What happens to annual plans? These are product decisions, not engineering ones, and the engineering only goes smoothly once they are settled. Get answers in writing before anyone codes.

Failed payments and dunning

Cards expire. Banks decline. On a healthy subscription business, a meaningful slice of monthly charges fail on the first try, and most of them recover if you retry intelligently and email the customer. This recovery flow is called dunning. Providers offer built-in retry schedules and reminder emails (Stripe Smart Retries, Paddle's recovery), and turning those on is the highest-leverage hour in the whole project. Failing to handle dunning is just throwing away revenue you already earned.

The boring correctness work

Invoices that match what the customer was actually charged. Receipts. A way for the customer to update their card and see their billing history (the provider's hosted customer portal saves you days here). Cancellation that does the right thing with access. Refunds. None of this is glamorous, and all of it generates support tickets when it is wrong.

A realistic timeline and budget

Rough ranges, not quotes. Real numbers depend on your stack and how settled your pricing is.

A basic version (one or two flat plans, monthly and annual, hosted checkout, hosted customer portal, webhooks syncing to your database, dunning turned on) is usually two to four weeks of focused work. Call it a few thousand to low five figures of dev cost depending on rate and polish.

It grows from there. Usage-based or metered billing (charging per seat, per API call, per gigabyte) adds metering infrastructure and a whole class of edge cases, and can easily double the timeline. Multiple currencies, free trials with card capture, coupons, enterprise invoicing with net-30 terms, and custom contracts each add real time. If a salesperson has promised a customer something bespoke, that is rarely a config change.

The thing that blows up estimates is not code. It is unsettled pricing. If the plan structure changes halfway through (and it often does, because pricing is a business experiment), the billing logic changes with it. Lock the pricing model before the build starts, or at least agree that changes mid-build reset the clock.

What to watch out for

A few things that reliably cause pain:

  • Testing in production by accident. Use the provider's test mode and test clocks to simulate a year of renewals in minutes. If a vendor cannot show you renewals being tested without waiting a month, push back.
  • No reconciliation. At least once, compare what the provider says you charged against what your database thinks. Drift between the two is how money goes quietly missing.
  • Treating tax as an afterthought. Decide your merchant-of-record stance on day one. Retrofitting global tax onto a domestic Stripe setup is a real migration, not a checkbox.
  • Hardcoded plan IDs everywhere. Prices change. Keep plan and price IDs in config, not scattered through the codebase.

The takeaway

Buy the money parts, build the glue. Pick your provider based on tax exposure, not just fees. Settle pricing before anyone writes code, make your webhook handlers idempotent, mirror state into your own database, and turn on dunning early. Do those five things and subscription billing becomes a predictable two-to-four week project instead of an open-ended one.

If you want a second pair of eyes on your billing plan before you commit, that is exactly the kind of thing we are happy to look at.

Related service

SaaS Platforms

From first login to multi-tenant scale.

Learn more

Want this built right?

This is the work we do every day. Tell us what you are building and we will show you exactly how we would ship it.

hello@lazydevsagency.com