Credits are the product quota unit. They are used by AI calls, generation jobs, signup rewards, affiliate rewards, redemption codes, payment products, and manual admin grants.
Do not confuse credits with payment money. Credits use 1 credit = 1_000_000 units. Payment price_amount uses provider minor currency units such as cents.
Model
The credit system has one current balance and two history layers:
credit_balances
current balance per user
credit_entries
positive grant batches
remaining amount used for expiration
credit_transactions
append-only balance changes
credit_balances.balance is the current wallet value. It can become negative when concurrent paid actions pass the pre-check and then deduct.
credit_entries tracks positive credit batches. It is not the user-visible ledger. Its remaining_amount is the part of a grant that can still expire. If a grant first offsets a negative balance, only the remaining positive balance is expirable.
credit_transactions is the visible ledger. It records every grant, consume, refund reversal, and expiration with amount, balance_after, source_type, and source_id.
Data Ownership
Credit state is split by responsibility:
META_DB
credit_redemption_codes
checkout_orders
payment_transactions
user_subscriptions
Tenant Shard DB
credit_balances
credit_entries
credit_transactions
Current-user credit reads and writes must use ctx.get('tenantDb'). This keeps the request's tenant bookmark aligned with the write.
Admin operations and payment webhooks may need to credit another user. Those flows resolve the user's shard through Meta DB and then open that shard with createTenantShardAccess(...).openUserDb(userId).
There is no cross-DB transaction between Meta DB and Tenant Shard DB. Any flow that touches both databases must be treated as a saga with idempotent side effects.
Amount Rules
API requests use decimal strings:
{
"amount": "100.500000"
}
Internal services use integer units:
1 credit = 1_000_000 units
100 credits = 100_000_000 units
0.5 credits = 500_000 units
Responses always format credit amounts with 6 decimal places:
{
"balance": "100.000000"
}
Only positive amounts are accepted for grants, deductions, generated codes, daily checkin, and admin grant input. Negative balance is a state, not an input format.
Transaction Types
The built-in transaction types are:
| Type | Writer | Meaning |
|---|---|---|
signup |
Better Auth user creation hook | Signup reward |
daily_checkin |
POST /api/daily_checkin |
Daily checkin reward |
redemption_code |
POST /api/redeem_credit_code |
Redeemed credit code |
manual_grant |
POST /api/admin/grant_credits |
Admin grant |
payment_purchase |
Payment webhook | Credits granted by paid product or subscription |
payment_refund |
Payment webhook | Credits reversed after refund |
affiliate_inviter |
Affiliate flow | Inviter reward |
affiliate_invitee |
Affiliate flow | Invitee reward |
consume |
Paid product feature | Feature usage deduction |
expired |
Cron job | Expired grant amount |
For custom paid features, use consume unless the feature needs its own reporting category.
Idempotency
Every grant and deduction must have a stable source_type + source_id.
The uniqueness key is not scoped by user_id. If two users may share the same external id, include the user id in source_id.
Examples:
signup user_id
daily_checkin user_id:yyyy-mm-dd
redemption_code credit_redemption_codes.id
manual_grant operator provided source_id
payment_transaction payment_transactions.id
payment_refund provider:provider_refund_id
ai_image task_id
Do not use a random source_id for retryable business operations. A retry must hit the same idempotency key.
Grant Flow
Use CreditsService.grant for positive credits.
The service writes one D1 batch:
insert credit_balances if missing
↓
insert credit_entries when source_type + source_id is new
↓
increase credit_balances.balance
↓
set credit_entries.remaining_amount
↓
insert credit_transactions
If the same source_type + source_id already exists, the call returns duplicated: true and does not grant credits again.
Granting into a negative balance is allowed. Example:
balance before grant = -30 credits
grant amount = 100 credits
balance after grant = 70 credits
remaining_amount = 70 credits
Only the actual positive remainder can expire later.
Deduct Flow
Paid actions should use this shape:
ensure enough credits
↓
run business operation
↓
deduct credits after success
CreditsService.runPaidAction implements that pattern:
import { CreditsService } from '../../credits'
import { parseDecimal } from '../../lib/decimal'
type GenerateResult = {
taskId: string
}
type GenerateRequest = {
idempotencyKey: string
}
const request: GenerateRequest = await parseGenerateRequest(ctx)
const credits: CreditsService = new CreditsService(ctx.get('tenantDb'))
const result: GenerateResult = await credits.runPaidAction<GenerateResult>({
userId: ctx.get('userId'),
amount: parseDecimal('5'),
sourceType: 'ai_image',
sourceId: request.idempotencyKey,
description: 'AI image generation',
execute: async (): Promise<GenerateResult> => {
return createImageTask(request)
}
})
If execute throws, credits are not deducted.
deduct consumes credit_entries.remaining_amount in expiration order:
earliest expires_at first
then non-expiring entries
then created_at
then id
The balance deduction itself is idempotent by source_type + source_id. A duplicate deduction returns duplicated: true.
This is not a hard reservation system. ensureEnough and deduct are separate operations. Concurrent paid actions can make the balance negative. That is intentional for the current template because it keeps the D1 write path simple.
Signup Reward
Better Auth user creation creates the tenant credit balance first:
user.create.after
↓
open user's tenant shard
↓
create credit balance
↓
grant signup credits when enabled
The Credits configuration in Meta D1 controls whether the reward is enabled and its amount. The hook reads one configuration snapshot after user creation.
The grant uses:
type signup
sourceType signup
sourceId user_id
Email signup and first OAuth sign-in share the same user creation hook.
Daily Checkin
Daily checkin is a normal grant with a date-based idempotency key:
type daily_checkin
sourceType daily_checkin
sourceId user_id:yyyy-mm-dd
The Credits configuration in Meta D1 controls whether daily check-in is enabled and its amount.
The date is UTC. A second checkin on the same UTC day returns 409 DAILY_CHECKIN_ALREADY_DONE.
If daily checkin is disabled, POST /api/daily_checkin returns an empty JSON response and does not grant credits.
Redemption Codes
Redemption codes live in Meta DB because they are global inventory, not tenant ledger state.
The flow crosses Meta DB and Tenant Shard DB:
claim code in META_DB
status: unused -> claimed
claimed_by = user_id
↓
grant credits in user's Tenant Shard DB
sourceType = redemption_code
sourceId = credit_redemption_codes.id
↓
mark code granted in META_DB
status: granted
granted_at = now
If the Meta claim succeeds but the tenant grant fails, the API returns 202 CREDIT_GRANT_PENDING. The code remains claimed by that user. Retrying the same code by the same user can continue the grant.
Code status values:
| Status | Meaning |
|---|---|
unused |
Can be claimed |
claimed |
Claimed in Meta DB, tenant grant may still be pending |
granted |
Tenant grant completed |
Expired codes cannot be claimed.
Payment Credits
Payment products can grant credits through credits_amount or period_credits_amount.
Successful payment webhook:
payment transaction in META_DB
↓
grant credits in user's Tenant Shard DB
type payment_purchase
sourceType payment_transaction
sourceId payment_transactions.id
Refund webhook:
mark payment transaction refunded in META_DB
↓
deduct granted credits in user's Tenant Shard DB
type payment_refund
sourceType payment_refund
sourceId provider:provider_refund_id
Refund uses deduct, not a negative grant. Negative grants do not exist.
Expiration And Cleanup
The built-in cron is:
CRONS=*/10 * * * *
The scheduled job runs every 10 minutes. For each tenant shard it:
expire up to 20 credit_entries
↓
delete up to 100 old credit_transactions
Expiration writes a negative expired transaction and sets the entry's remaining_amount to 0.
Transaction cleanup reads historyRetentionDays from the Credits configuration in Meta D1 once per cron execution.
credit_entries are not deleted. They preserve grant idempotency and expiration state.
API
User endpoints require an authenticated session and beta gate access.
Get Summary
POST /api/get_credit_summary
Response:
{
"balance": "100.000000",
"daily_checked_in": false,
"daily_checkin_amount": "10.000000"
}
List Transactions
POST /api/list_credit_transactions
Request:
{
"page": 1,
"page_size": 20,
"type": "consume",
"source_type": "ai_image",
"source_id": "task_123",
"created_at_start": 1767139200000,
"created_at_end": 1767225600000
}
Response:
{
"items": [
{
"id": "tx_1",
"type": "consume",
"amount": "-5.000000",
"balance_after": "95.000000",
"source_type": "ai_image",
"source_id": "task_123",
"description": "AI image generation",
"expires_at": null,
"created_at": 1767139200000
}
],
"total": 1
}
Daily Checkin
POST /api/daily_checkin
Response:
{
"balance": "110.000000",
"checked_in": true,
"amount": "10.000000"
}
Redeem Code
POST /api/redeem_credit_code
Request:
{
"code": "FREE100"
}
Response:
{
"balance": "200.000000",
"amount": "100.000000"
}
Errors:
| Code | Status |
|---|---|
INVALID_CREDIT_CODE |
400 |
CREDIT_CODE_USED |
409 |
CREDIT_GRANT_PENDING |
202 |
Admin API
Admin endpoints require administratorMiddleware, which verifies the authenticated user's current D1 administrator role for browser sessions and OAuth access.
For generating credit codes or granting credits through the browser, see Admin Console.
Generate Codes
POST /api/admin/generate_credit_codes
Request:
{
"count": 10,
"amount": "100",
"expires_at": 1767139200000
}
count defaults to 1 and maxes at 200.
List Codes
POST /api/admin/list_credit_codes
Request:
{
"page": 1,
"page_size": 20,
"code": "FREE100",
"claimed_by": "user_id",
"status": "claimed",
"amount": "100",
"created_at_start": 1767139200000,
"created_at_end": 1767225600000,
"expires_at_start": 1767139200000,
"expires_at_end": 1769817600000
}
Response:
{
"items": [
{
"id": "code_1",
"code": "FREE100",
"amount": "100.000000",
"status": "unused",
"expires_at": null,
"claimed_by": null,
"claimed_at": null,
"granted_at": null,
"created_at": 1767139200000
}
],
"total": 1
}
Grant Credits
POST /api/admin/grant_credits
Request:
{
"user_id": "user_id",
"amount": "100",
"source_id": "manual-2026-001",
"description": "Manual grant",
"expires_at": null
}
Response:
{
"balance": "300.000000"
}
Use a stable source_id for the operator action. Include the user id or grant id if the operator source can collide. Reusing the same source_id for manual_grant in the same tenant shard returns 409 CREDIT_GRANT_DUPLICATED.
Configuration
Credits settings are managed in the admin Configuration area and stored in Meta D1. API amounts are decimal strings: 100 and 100.000000 both mean 100 credits. D1 stores the same value as 100000000 integer units.
CRONS=*/10 * * * * remains deployment topology configuration and is required for expiration and cleanup jobs.
Common Mistakes
Mixing credit units and payment units. credits_amount is credits. price_amount is provider minor currency units.
Writing the current user's credits with openUserDb. Use ctx.get('tenantDb') inside authenticated user requests.
Using random idempotency keys. Retries must reuse the same source_type + source_id.
Charging before the business operation succeeds. Use runPaidAction or deduct after success.
Expecting redemption claim to mean grant completed. claimed means Meta DB accepted the code. granted means the Tenant Shard grant completed.
Deleting credit_entries. They are part of idempotency and expiration state.
Forgetting the cron. Without CRONS=*/10 * * * *, credits do not expire and transaction cleanup does not run.