If your product sells credits, tokens, minutes or API calls, someone on your team has probably said "we'll just add a credits column to the users table." It's a reasonable first move. It's also where most teams start building a billing system they never planned to own.
Here's what that column turns into once real customers arrive.
1. The balance isn't one number
A customer on a monthly plan has an allowance that resets. Then they buy a top-up pack, which shouldn't reset. Then you give them a signup bonus that expires in 30 days. Now "how many credits do they have?" is a sum over several buckets, each with its own rules, and you have to decide which bucket to spend first. Usually it's the one that expires soonest.
2. "Monthly" means each customer's month
Allowances renew on the customer's billing date, not on the 1st. A customer who signed up on the 17th renews on the 17th. A customer who upgraded on the 9th might renew on the 9th from now on, or keep the 17th, depending on how you prorate. Your renewal job now needs to know every customer's anchor date.
3. Plan changes mid-period
Upgrade today, and what happens to the allowance? Do they get the full new amount now, a prorated share, or the new amount at the next renewal? Downgrades are worse: what if they've already used more than the smaller plan includes? Whatever you choose, you need a history of which plan applied when, or you can't explain an invoice later.
4. Two requests, one last credit
Your app checks the balance, runs the job, then deducts. With parallel requests, two jobs can both see "10 credits left", both run, and both deduct. The customer ends up at −10. For cheap work that's fine. For a video render or a batch of image generations, it's real money. Fixing it means holding capacity before the work starts and settling after.
5. Failed jobs
If you hold or deduct credits before a job and the job fails, the customer should get them back. If your worker crashes mid-job, nobody calls the refund. So holds need an expiry, and something has to clean them up.
6. Bursts
A monthly allowance doesn't stop one customer's runaway script from spending the whole month in ten minutes. You need rate limits per customer as well as totals, often in more than one window, such as per minute and per hour.
7. Telling customers before they run out
Customers would rather top up at 80% than find out they're at 100% when a job fails. That means tracking thresholds per customer per period and notifying once, not on every request after the line is crossed.
8. Usage for billing
At the end of a period, finance wants an exact number: how much this customer used, how much was included, and how much was overage. That number has to stay put once the period closes.
What it adds up to
None of these are hard on their own. Together they're a small, stateful, concurrency-sensitive system that sits in front of your most expensive operations, and it changes every time your pricing changes. That's the part that rarely makes it into the original estimate.
The short version
This is the problem we're building Meterbase for. You set up meters, plans and allowances once, and your code makes one call before the work and one after:
import { Meterbase } from "mbase-sdk"
const meterbase = new Meterbase({ apiKey: process.env.METERBASE_API_KEY! })
const check = await meterbase.check({
customer_id: "acct_1",
meter_id: "ai_tokens",
quantity: 2_000, // an estimate
})
if (!check.allowed) throw new Error(`Refused: ${check.reason}`)
const reply = await generateReply(prompt)
await meterbase.track({
customer_id: "acct_1",
meter_id: "ai_tokens",
quantity: reply.usage.total_tokens,
})
Allowances, top-ups, plan changes, holds, rate limits and threshold webhooks sit behind those two calls. Meterbase isn't a billing system: Stripe or Paddle still take the money, and you read each customer's usage for a period to bill from.
If you're building this right now, or you already built it and are tired of maintaining it, we'd love to hear how you handled it: meterbase.tech.
Top comments (0)