OPC Stack includes a credit system for AI calls, generation jobs, free quota, and simple usage based billing.
Credit API amounts use decimal strings with 6 decimal places. The database stores integer units where 1 credit = 1_000_000 units.
Features
- Credit balance
- Credit transaction history
- Signup reward
- Daily checkin reward
- Redemption code top up
- Admin credit grant
- Credit expiration
- Old transaction cleanup
Configuration
# Signup reward
CREDITS_SIGNUP_ENABLED=true
CREDITS_SIGNUP_AMOUNT=100
# Daily checkin reward
CREDITS_DAILY_CHECKIN_ENABLED=true
CREDITS_DAILY_CHECKIN_AMOUNT=10
# Cleanup old credit_transactions
CREDITS_HISTORY_RETENTION_DAYS=90
# Expire credits every 10 minutes
CRONS=*/10 * * * *
Data model
The credit system uses three main records:
user.credit_balance: current balance. It can be negativecredit_entries: positive credit batches with remaining expirable amountcredit_transactions: balance change history
The paid business flow is:
check balance
↓
execute business
↓
deduct credits after success
Failed business operations do not deduct credits. Concurrent requests may make the balance negative. Later signup rewards, checkins, affiliate rewards, redemption codes, or admin grants first offset the negative balance.
API
Get balance
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": "signup",
"source_type": "signup",
"source_id": "user_id",
"created_at_start": 1767139200000,
"created_at_end": 1767225600000
}
Response:
{
"items": [
{
"id": "tx_1",
"type": "signup",
"amount": "100.000000",
"balance_after": "100.000000",
"source_type": "signup",
"source_id": "user_id",
"description": "Signup reward",
"expires_at": null,
"created_at": 1767139200000
}
],
"total": 1
}
Daily checkin
POST /api/daily_checkin
Repeated checkin returns 409 DAILY_CHECKIN_ALREADY_DONE.
Redeem credit code
POST /api/redeem_credit_code
Request:
{
"code": "FREE100"
}
Used code returns 409 CREDIT_CODE_USED. Missing or expired code returns 400 INVALID_CREDIT_CODE.
Generate credit codes
POST /api/admin/generate_credit_codes
Request:
{
"count": 10,
"amount": "100",
"expires_at": 1767139200000
}
This endpoint requires ADMIN_SECRET.
List credit codes
POST /api/admin/list_credit_codes
Request:
{
"page": 1,
"page_size": 20,
"code": "FREE100",
"used_by": "user_id",
"used": false,
"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",
"expires_at": null,
"used_by": null,
"used_at": null,
"created_at": 1767139200000
}
],
"total": 1
}
This endpoint requires ADMIN_SECRET.
Admin 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
}
This endpoint requires ADMIN_SECRET. source_id prevents duplicate grants for the same operation.
Signup reward
Signup reward is attached to Better Auth user creation:
user.create.before
↓
generate aff_code
↓
insert user
↓
user.create.after
↓
grant signup credits
Email signup and Google first sign in use the same user creation flow.
Daily checkin deduplication
Checkin writes a credit_transactions record and uses source_type=daily_checkin with source_id=user_id:yyyy-mm-dd as the idempotency key.
A second checkin on the same day does not grant credits again.
Redemption code deduplication
Redemption does not rely on a separate read before write check. Core writes are placed in one D1 batch(), and SQL conditional insert expresses "insert only when the code is usable":
INSERT OR IGNORE INTO credit_entries (...)
SELECT ...
FROM credit_redemption_codes
WHERE code = ?
AND used_by IS NULL
AND (expires_at IS NULL OR expires_at > ?)
The following user balance update, code used marker, and transaction insert all use WHERE EXISTS to check whether this entry was inserted.
D1 does not support traditional transactions. Core credit writes use batch() to keep the D1 writes atomic.
Expiration
Cron runs every 10 minutes:
CRONS=*/10 * * * *
Each run processes 20 expired credit_entries:
find 20 expired entries
↓
deduct user.credit_balance
↓
insert expired transactions
↓
set credit_entries.remaining_amount = 0
credit_entries are not deleted because they preserve idempotency for credit grants. Old credit_transactions records are cleaned up by CREDITS_HISTORY_RETENTION_DAYS.