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 .md to documentation URLs or sending Accept: 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, and order records
  • 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.

The simplest mental model is:

  1. Billing sells access or packages through recurring subscriptions and one-time purchases.
  2. Your database stores the actual credit balance.
  3. 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 variantId to 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:

For example:

index.ts
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.

credit-grants.ts
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:

credits.ts
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
credits.service.ts
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:

  1. determine how many credits the action costs
  2. verify the user or organization is allowed to spend them
  3. perform the action
  4. deduct credits in a transaction
consume-credits.ts
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:

  • customer
  • subscription
  • order

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.

router.ts
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.

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:

  1. Create a one-time billing variant for each top-up package.
  2. Map each variant ID to a credit amount.
  3. When checkout completes successfully, add the matching credits.
  4. Record the top-up in credit_transaction using 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:

  1. Start a subscription checkout and verify the initial credits are granted once.
  2. Purchase a one-time top-up and verify credits are added once.
  3. Trigger a renewal event and verify your reset or rollover logic behaves correctly.
  4. Consume credits from a real billable action and verify the deduction is atomic.
  5. Retry the same webhook or job and verify idempotency prevents double processing.

If something looks off, the most common causes are:

  • the variant.id values 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

If you want the fastest path to production, start with this:

  1. Use recurring billing variants for monthly or yearly credit allowances.
  2. Use one-time billing variants for top-ups.
  3. Keep a single credit_balance table plus a credit_transaction audit table.
  4. Store grant rules in code keyed by variant.id.
  5. 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

On this page

Ship your startup everywhere. In minutes.Try TurboStarter