Credits-based billing
Build a credits-based billing system on top of TurboStarter's billing primitives.
For the complete documentation index, see llms.txt. Prefer markdown by appending.mdto documentation URLs or sendingAccept: text/markdown.
Credits-based billing is a great fit when you want customers to spend a balance over time instead of paying only for seats or raw metered usage.
AI tokens or generations
Customers pay for each token or generation they use.
Image processing jobs
Customers pay for each image processing job they perform.
Document conversions
Customers pay for each document they convert.
Scraping runs
Customers pay for each scraping run they perform.
TurboStarter does not ship a built-in credits ledger, top-up flow, or renewal policy. Instead, it gives you the billing building blocks you need:
- provider-agnostic checkout
- synced
customer,subscription, andorderrecords - webhook callbacks you can extend
- one-time and recurring billing variants
This guide shows one practical way to build a credits system on top of that foundation.
Recommended architecture
The simplest mental model is:
- Billing sells access or packages through recurring subscriptions and one-time purchases.
- Your database stores the actual credit balance.
- Your backend grants, deducts, and resets credits based on billing events and product usage.
In practice, most apps end up with:
- a table for the current credit balance
- a table for credit transactions
- a mapping from billing
variantIdto how many credits should be granted - webhook logic for initial grants, renewals, and top-ups
- application logic that deducts credits when users perform billable actions
Credits model
Before writing code, decide which model you want.
Subscription credits
A fresh credit allowance is granted every billing period.
Works best with recurring billing variants such as 1,000 credits every
month or 10,000 credits every year.
Top-up credits
Customers buy extra credits separately whenever they need them.
Works best with one-time billing variants such as 500 or 10,000 extra
credits.
Hybrid model
Recurring credits plus one-time top-ups.
This is the most common model for AI and API products.
Define the billing variants
Credits-based billing usually combines:
- recurring subscription variants for the base allowance
- one-time variants for top-ups
For example:
export const config = billingConfigSchema.parse({
plans: [
{
id: BillingPlan.PREMIUM,
name: "Premium",
description: "For teams with recurring credit usage",
badge: "Popular",
features: ["Monthly credit allowance", "Priority support"],
variants: [
{
id: "price_premium_monthly",
cost: 2_900,
currency: "usd",
model: BillingModel.RECURRING,
interval: RecurringInterval.MONTH,
trialDays: 7,
},
{
id: "price_credits_500",
cost: 900,
currency: "usd",
model: BillingModel.ONE_TIME,
},
],
},
],
}) satisfies BillingConfig;TurboStarter handles checkout and syncs the purchase into the billing tables. Your app decides what each variant means in terms of credits.
Map variants to credit grants
You need a place to define how many credits each billing variant should grant.
There are two reasonable approaches:
- keep the mapping in application code
- store the mapping in a database table
For most apps, a code-based mapping is the easiest place to start.
export const CREDIT_GRANTS = {
price_premium_monthly: {
kind: "subscription",
credits: 1_000,
},
price_premium_yearly: {
kind: "subscription",
credits: 15_000,
},
price_credits_500: {
kind: "topup",
credits: 500,
},
price_credits_5000: {
kind: "topup",
credits: 5_000,
},
} as const;This keeps the relationship between billing configuration and credit allocation very explicit.
Variant IDs must stay in sync
Your credit grant mapping should use the same variant.id values that you configured in billing. If those IDs drift, credits will not be granted correctly.
Create a credits ledger in your database
TurboStarter syncs purchases for you, but it does not create a credit balance table. You will need one.
The most useful setup is:
- a balance table with the current available credits
- a transaction table with every credit change
Here is a Drizzle-friendly example:
import {
integer,
pgEnum,
pgTable,
text,
timestamp,
unique,
} from "drizzle-orm/pg-core";
import { generateId } from "@workspace/shared/utils";
export const creditReferenceTypeEnum = pgEnum("credit_reference_type", [
"user",
"organization",
]);
export const creditTransactionTypeEnum = pgEnum("credit_transaction_type", [
"grant",
"topup",
"consume",
"refund",
"adjustment",
"expiration",
]);
export const creditBalance = pgTable(
"credit_balance",
{
id: text("id").primaryKey().$defaultFn(generateId),
referenceId: text("reference_id").notNull(),
referenceType: creditReferenceTypeEnum("reference_type").notNull(),
balance: integer("balance").notNull().default(0),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at")
.defaultNow()
.$onUpdate(() => new Date())
.notNull(),
},
(t) => [unique().on(t.referenceId, t.referenceType)],
);
export const creditTransaction = pgTable("credit_transaction", {
id: text("id").primaryKey().$defaultFn(generateId),
referenceId: text("reference_id").notNull(),
referenceType: creditReferenceTypeEnum("reference_type").notNull(),
type: creditTransactionTypeEnum("type").notNull(),
amount: integer("amount").notNull(),
balanceAfter: integer("balance_after").notNull(),
variantId: text("variant_id"),
subscriptionExternalId: text("subscription_external_id"),
orderExternalId: text("order_external_id"),
idempotencyKey: text("idempotency_key").notNull().unique(),
description: text("description"),
createdAt: timestamp("created_at").defaultNow().notNull(),
});You can simplify this further if you only support user billing or only support organization billing.
Add atomic credit helpers
Credit deduction and credit grants should be atomic. If two requests hit at the same time, you do not want users overspending their balance.
At minimum, you will usually want helpers for:
- reading the current balance
- adding credits
- resetting credits
- consuming credits only if enough balance exists
import { and, eq } from "@workspace/db";
import { db } from "@workspace/db/server";
import { creditBalance, creditTransaction } from "./schema/credits";
export const addCredits = async ({
referenceId,
referenceType,
amount,
type,
idempotencyKey,
variantId,
subscriptionExternalId,
orderExternalId,
description,
}: {
referenceId: string;
referenceType: "user" | "organization";
amount: number;
type: "grant" | "topup" | "adjustment" | "refund";
idempotencyKey: string;
variantId?: string;
subscriptionExternalId?: string;
orderExternalId?: string;
description?: string;
}) => {
return db.transaction(async (tx) => {
const [existing] = await tx
.select()
.from(creditTransaction)
.where(eq(creditTransaction.idempotencyKey, idempotencyKey));
if (existing) {
return existing;
}
const [balanceRow] = await tx
.insert(creditBalance)
.values({
referenceId,
referenceType,
balance: 0,
})
.onConflictDoNothing()
.returning();
const [current] = await tx
.select()
.from(creditBalance)
.where(
and(
eq(creditBalance.referenceId, referenceId),
eq(creditBalance.referenceType, referenceType),
),
);
const nextBalance = (current?.balance ?? balanceRow?.balance ?? 0) + amount;
await tx
.update(creditBalance)
.set({ balance: nextBalance })
.where(
and(
eq(creditBalance.referenceId, referenceId),
eq(creditBalance.referenceType, referenceType),
),
);
const [transaction] = await tx
.insert(creditTransaction)
.values({
referenceId,
referenceType,
type,
amount,
balanceAfter: nextBalance,
idempotencyKey,
variantId,
subscriptionExternalId,
orderExternalId,
description,
})
.returning();
return transaction;
});
};For consumption, use a transaction that checks the current balance and only deducts when enough credits remain.
Deduct credits when work is completed
Use credits when your product performs a billable action.
Good examples:
- an AI generation succeeds
- a file export finishes
- a crawl completes
- an image is rendered
The safest approach is:
- determine how many credits the action costs
- verify the user or organization is allowed to spend them
- perform the action
- deduct credits in a transaction
export const consumeCredits = async ({
referenceId,
referenceType,
amount,
idempotencyKey,
description,
}: {
referenceId: string;
referenceType: "user" | "organization";
amount: number;
idempotencyKey: string;
description?: string;
}) => {
return db.transaction(async (tx) => {
const [existing] = await tx
.select()
.from(creditTransaction)
.where(eq(creditTransaction.idempotencyKey, idempotencyKey));
if (existing) {
return existing;
}
const [current] = await tx
.select()
.from(creditBalance)
.where(
and(
eq(creditBalance.referenceId, referenceId),
eq(creditBalance.referenceType, referenceType),
),
);
const balance = current?.balance ?? 0;
if (balance <Steps amount) {
throw new Error("Insufficient credits");
}
const nextBalance = balance - amount;
await tx
.update(creditBalance)
.set({ balance: nextBalance })
.where(
and(
eq(creditBalance.referenceId, referenceId),
eq(creditBalance.referenceType, referenceType),
),
);
const [transaction] = await tx
.insert(creditTransaction)
.values({
referenceId,
referenceType,
type: "consume",
amount: -amount,
balanceAfter: nextBalance,
idempotencyKey,
description,
})
.returning();
return transaction;
});
};Deduct after successful work whenever possible
For many products, it is safer to consume credits after the billable action succeeds. If you deduct before work starts, you also need a refund path for failures.
Grant credits from billing events
This is where TurboStarter’s billing sync becomes useful.
After checkout and webhook processing, you already have synced:
customersubscriptionorder
You can use webhook callbacks to translate billing events into credit grants.
Initial subscription grant
When a customer first subscribes, grant the recurring credit allowance once.
Renewal grant
When the subscription enters a new billing period, either reset the balance to the plan allowance or add more credits on top of the remaining balance.
Top-up grant
When a one-time credit package is purchased, add the purchased credits to the current balance.
Webhook extension
TurboStarter lets you extend the billing webhook handler with callbacks.
import { Hono } from "hono";
import { provider, webhookHandler } from "@workspace/billing-web/server";
export const billingRouter = new Hono().post(`/webhook/${provider}`, (c) =>
webhookHandler(c.req.raw, {
onCheckoutSessionCompleted: async (sessionId) => {
// grant initial subscription credits or top-up credits
},
onSubscriptionUpdated: async (subscriptionId) => {
// detect a new billing period and grant renewal credits
},
onEvent: async (event) => {
// optional: handle provider-specific events if needed
},
}),
);The easiest rule is:
- if the purchase came from a recurring variant, grant the subscription allowance
- if the purchase came from a one-time variant, add the top-up amount
Because webhook payload shapes differ across providers, many teams use the synced billing tables as the stable source of truth after the webhook runs.
Renewal strategy
There is no single correct renewal strategy. Pick the one that matches your product.
At every renewal, the balance becomes the new plan allowance.
Good for:
- “1000 credits per month”
- use-it-or-lose-it plans
At every renewal, add the new allowance on top of the remaining balance.
Good for:
- annual contracts
- friendlier customer policies
Keep recurring credits and top-up credits separate.
Good for:
- products where top-ups should never expire
- products where subscription credits reset but purchased credits do not
idempotency pattern
Credit grants and deductions should always be idempotent.
A good pattern is to create deterministic keys such as:
subscription:{externalId}:period_end:{timestamp}order:{externalId}usage:{jobId}
Store those keys in your credit_transaction table and reject duplicate processing.
This protects you from:
- webhook retries
- double form submissions
- job retries
- provider-side event duplication
Show credits in the UI
Once you have a balance table, the UI is straightforward.
At minimum, most apps show:
- current balance
- recent credit transactions
- the active plan
- a button to buy more credits
The “buy more credits” action usually points to:
- a one-time top-up variant in your pricing page
- a dedicated billing/settings page
- a custom modal that triggers checkout
Top-ups
Top-ups are usually the easiest part of a credits system because they map naturally to one-time billing variants.
Recommended flow:
- Create a one-time billing variant for each top-up package.
- Map each variant ID to a credit amount.
- When checkout completes successfully, add the matching credits.
- Record the top-up in
credit_transactionusing the order’s external ID as the idempotency key.
This gives you a simple “buy more credits” feature without changing your subscription model.
Common variations
Expiring credits
Add an expiration date to the balance or transaction records and ignore expired credits during consumption.
Bonus credits
Use the same credit ledger for admin grants, referral rewards, and promotional campaigns.
Different costs for different actions
You do not need a separate billing variant for every action. In many apps, one subscription funds a shared credit wallet, and your backend decides the cost of each action.
Organization billing
If you support organizations, store balances by organization `referenceId` and consume credits on behalf of the organization instead of the user.
Testing
Before shipping, test the full lifecycle:
- Start a subscription checkout and verify the initial credits are granted once.
- Purchase a one-time top-up and verify credits are added once.
- Trigger a renewal event and verify your reset or rollover logic behaves correctly.
- Consume credits from a real billable action and verify the deduction is atomic.
- Retry the same webhook or job and verify idempotency prevents double processing.
If something looks off, the most common causes are:
- the
variant.idvalues do not match your grant mapping - webhook logic is not idempotent
- credits are deducted before failed work and never refunded
- renewals and top-ups are writing to the same balance without a clear policy
Recommended setup
If you want the fastest path to production, start with this:
- Use recurring billing variants for monthly or yearly credit allowances.
- Use one-time billing variants for top-ups.
- Keep a single
credit_balancetable plus acredit_transactionaudit table. - Store grant rules in code keyed by
variant.id. - Use webhook callbacks to grant credits and your backend services to consume them.
That gives you a simple, understandable credits system that fits naturally into TurboStarter’s existing billing flow while still leaving room for more advanced policies later.
How is this guide?
Last updated on