Credits and metered usage

The credit ledger, where credits come from (plans, packs, admins), how to charge for work safely on D1, and the worked example of a metered API endpoint.

Credits are how ShipKit turns money into usage. A subscription grants a monthly allowance, a credit pack adds a fixed amount that never expires, and your code charges credits for the work it does. If your product sells access to features rather than usage, you can remove the whole module (see the end of this page).

The ledger

Everything lives in one table, credit_ledger (src/features/credits/schema.ts). There is no balance column: the balance is the sum of a user's rows, so it cannot drift from the history that explains it. A positive delta is a grant, a negative one is a spend, and reason says why (monthly, plan-topup, purchase, refund, api-run, or any string your code writes).

Two details matter when you write code against it:

  • Rows are stored in micro-credits (1 credit = 1,000,000). This lets you charge a fiftieth of a credit for a cheap operation without rounding it up to a whole one. The functions in src/features/credits/server/credits.ts take and return whole credits; only the metered helpers take micro-credits.
  • refId is unique. Every grant that comes from an external event carries the order id, so a redelivered webhook cannot credit twice.

Two pools

A balance has two parts with different rules, and the UI shows both:

PoolFilled byExpires
MonthlyThe plan's allowance and upgrade top-ups00:00 UTC on the 1st of the next month
PermanentCredit packs and admin adjustmentsNever

Spending always drains the monthly pool first. The reverse order would burn credits the user paid for outright while letting the rented ones expire unused.

Where credits come from

The monthly allowance. Every account gets its plan's monthlyCredits once per calendar month (UTC). The nightly cron runs the grant every day, but the row id is derived from the user and the month, so only the first run in a month writes anything and a missed day is corrected by the next. A new account gets its allowance at signup instead of waiting for the cron.

The payment event does not grant the monthly credits itself. It records an entitlement (how many credits, until when), and the monthly job reads it. That is what makes a yearly plan grant every month rather than once a year. When a subscription stops renewing, its entitlement goes stale and the user falls back to the free plan's allowance with nothing to cancel. A fresh subscription or an upgrade tops the current month up immediately.

Credit packs. A paid order.paid with kind: 'credit_pack' grants the pack's credits to the permanent pool. Buy buttons live on /credits and in the account dialog.

Admin adjustments. See below.

Pricing constants

The top of src/config/plans.ts holds three numbers the rest derive from:

  • CREDIT_LIST_CENTS: the list price of one credit (1 cent by default). Packs sell at it; subscriptions sell below it.
  • CREDIT_COST_CENTS: what one credit's worth of work costs you. Measure it.
  • CREDITS_PER_ACTION: what your cheapest headline action costs, so plan cards can say "about N actions a month".

src/config/plans.test.ts fails the test run if any plan's rate falls below 1.15 times your cost, or a pack sells below cost. Changing that floor is a decision about your margin, so it is an edit to the test, not a side effect of a price change. See Payments for the rest of the file.

Spending credits

D1 has no interactive transactions, so "read the balance, then write a debit" is a race: two requests read the same balance and both pass. Every spending function here does the check inside the statement that writes (see Database).

Pick the helper by when you know the cost:

Cost knownUseCan overdraw?
Before the workspendCredits (server/credits.ts)No
After the work, with a ceiling up frontholdCredits, then settleHold or releaseHold (server/usage.ts)No
After the work, already donedebitUsage (server/usage.ts)Yes, on purpose

The simple case, inside a server function:

import { spendCredits } from '@/features/credits/server/credits'

const ok = await spendCredits({
  userId: context.session.user.id,
  amount: 3,
  reason: 'report',
})
if (!ok) throw new Error('Not enough credits') // nothing was written

spendCredits returns false and writes nothing when the balance is short, so you can turn that into a "top up" prompt.

For work whose cost is only known afterwards (a model call billed by tokens, a render billed by seconds), use hold, then settle:

  1. holdCredits debits the most the call could cost, guarded. If the balance cannot cover the worst case, it writes nothing and returns false.
  2. Do the work.
  3. settleHold shrinks the hold to the actual cost. If the work failed, releaseHold removes it so the same key can be retried.

If the Worker dies between steps 1 and 3, the hold stays at the ceiling. That errs in your favour; if your work runs long enough for it to matter, sweep for stale holds.

debitUsage is for recording consumption that already happened with no gate in front of it. It may push the balance negative, because the alternative is losing the record of work you were billed for.

Give every kind of charge its own reason, and add a label for it in src/features/credits/reasons.ts plus a credits_reason_* message, or the user's history shows the raw slug.

A metered API endpoint

POST /api/v1/me/run (src/routes/api/v1/me.run.ts) is the worked example: an endpoint authenticated with a user API key that does work and charges for it. Replace doTheWork with your real work and keep the rest.

  • It holds the caller's ceiling (maxUnits), does the work, and settles the real cost. One unit costs 0.02 credits.
  • A caller who cannot afford the worst case gets 402 insufficient_credits.
  • The Idempotency-Key header becomes the hold's refId, scoped to the caller. A second request with a key that was already charged gets 409 idempotency_key_reused rather than a second run. A retry after a failed attempt goes through, because the failure released the hold.
  • The response includes the units used, the credits charged and the remaining balance.

See API keys for the user key and the other /api/v1/me/* endpoints, including GET /api/v1/me/credits (balance and ledger).

Refunds

A full refund of a credit pack takes its credits back, even if that sends the balance negative: spent credits were spent, and a refund should not make them free. A full refund of the latest subscription payment drops the month back to the free allowance, and a full refund of an upgrade takes back its top-up. A partial refund takes nothing; if you want to claw some back, use an admin adjustment. The details are in Payments.

Admin tools

  • Revenue → Credits (/admin/credits, permission credits.read): every user's balance, searchable and sortable.
  • The user drawer or page: balance and latest ledger rows, plus an adjustment form (permission credits.write). A positive amount grants, a negative one deducts and may push the balance below zero. The reason you type is shown to the user, who also gets an in-app notification, and the change is written to the audit log as credits.adjusted.
  • Settings → Credits: credits.low_balance (default 50). Below it, the balance chip at the bottom of the sidebar turns into a top-up nudge.
  • Admin API: /api/v1/admin/credits (the ledger) and /api/v1/admin/credits/balances.

See Admin console and Permissions.

Not using credits

If your plans unlock features rather than grant usage, set monthlyCredits to 0 on every plan (the billing code still expects the field) and run /delete-feature credits. Billing and subscriptions keep working; they share nothing with credits but the payment event stream. See Deleting features.