Credits
Configure app credit packages, RevenueCat purchases, usage, and ledger rules
Credits
EasyStarter ships a credit system backed by a server-side ledger. In the app, users buy credit packages through RevenueCat in-app purchases:
- Sell credit packages through RevenueCat (iOS / Android)
- Grant free credits on signup, with optional expiration
- Abuse protection: signup grants require a verified email and are rate-limited per email, IP, and user agent
- Consume credits per feature with idempotent, race-safe accounting
- Ready-made UI: balance, purchase screen, transaction history
The ledger is the single source of truth. The client never grants credits locally — only the RevenueCat webhook (on purchase) and server-side consumption write to it.
Building the web app too? Credits share one server and config — see Web · Credits for the web setup.
Architecture
| Layer | Location |
|---|---|
| Config | packages/app-config/src/app-config.ts |
| Server service | apps/server/src/credits/* |
| API routes | apps/server/src/routers/common/credits.ts |
| App UI | apps/native/app/(tabs)/(profile)/credits*.tsx, apps/native/hooks/use-credits.ts |
| Purchases | apps/native/lib/payments/revenuecat.ts |
The credit tables:
| Table | Purpose |
|---|---|
credit_account | Current balance and aggregate counters per user |
credit_transaction | Immutable ledger rows; the (sourceProvider, sourceType, sourceId) tuple is the idempotency key |
credit_order | App purchase order lifecycle |
credit_signup_grant_claim | Signup grant eligibility and abuse checks |
1. Configure credit packages
Everything is configured in packages/app-config/src/app-config.ts.
Enable native credits
native: {
credits: {
enabled: true,
signupGrant: creditSignupGrant,
packages: nativeCreditPackages,
},
},Configure the signup grant
Grants free credits to a new user on first balance read. Set expiresInDays: null (or omit) for credits that never expire.
const creditSignupGrant = {
enabled: true,
amount: 100, // credits granted on signup
expiresInDays: 30, // null = never expires
} satisfies NonNullable<AppCreditsConfig["signupGrant"]>;Create the consumable product
Credit packages are consumable in-app products — they can be bought repeatedly and are "used up" by the ledger. This differs from the lifetime unlock, which is Non-Consumable.
iOS — App Store Connect
- Monetization → In-App Purchases → +.
- Select Consumable.
- Set a Reference Name (e.g.
100 Credits) and Product ID (e.g.com.yourapp.credits.starter). - Add a price and localization, then Save.
Android — Google Play Console
- Monetize → In-app products → Create product.
- Set the Product ID (e.g.
credits_starter), name, description, and default price. - Save, then Activate (RevenueCat can't see inactive products).
RevenueCat
- Product catalog → Products → Import, select the consumable products, and Import.
- That's all credits need — no Offering or Entitlement. The app fetches the product directly by ID (
Purchases.getProducts(..., NON_SUBSCRIPTION)), and the server grants credits on theNON_RENEWING_PURCHASEwebhook by matching the product id. Offerings and Entitlements are only for subscriptions and lifetime access.
Full RevenueCat setup (app config, store credentials, webhook) lives in the RevenueCat and Store Products guides.
Configure app packages
Put the product ids from the previous step into native for each platform. providerProductId must exactly match the store product id.
const nativeCreditPackages = [
{
id: "starter", // internal package id
amount: 100, // credits delivered after purchase
native: {
ios: {
provider: "revenuecat",
providerProductId: "easystarter_credits_starter_ios",
currency: "usd",
amountCents: 499,
status: "active",
},
android: {
provider: "revenuecat",
providerProductId: "easystarter_credits_starter_android",
currency: "usd",
amountCents: 499,
status: "active",
},
},
},
] satisfies AppCreditsConfig["packages"];Add package labels
Add a title and description for each package id so the purchase screen can render it.
"credits": {
"packages": {
"starter": {
"title": "Starter pack",
"description": "{count} credits for light usage."
}
}
}Configuration rules
amountandamountCentsmust be positive integers.providerProductIdis required, must be unique, and must match the RevenueCat product id.- Configure credit products as non-subscription (consumable) products in RevenueCat.
- Set
status: "archived"to hide a package without deleting history. - If the same package is also sold on web, reuse the same
id(with matchingamountandstatus) — see Web · Credits.
2. Set up the server
Run migrations
pnpm db:migrate:local # local D1
pnpm db:migrate # remote D1Configure the RevenueCat webhook secret
Credits reuse your RevenueCat integration, so no extra secret is needed beyond what In-App Purchases already requires:
| Provider | Secret |
|---|---|
| RevenueCat | REVENUECAT_WEBHOOK_SECRET |
Confirm the maintenance cron
apps/server/src/index.ts runs runCreditMaintenance on a daily schedule defined in apps/server/wrangler.jsonc. It expires free credits past their window and cancels stale pending orders.
"triggers": {
"crons": ["10 16 * * *"]
}3. Consume credits
Spending credits is the part you wire into your own features. Prefer the server-side service from inside a route, so balance checks can't be bypassed by the client.
await context.credits.consumeCredits({
user: { userId: context.session.user.id },
amount: 1,
idempotencyKey: `image-generate:${recordId}`,
metadata: { feature: "image-generate", recordId },
});From the app, use the useCredits hook:
const credits = useCredits();
await credits.consume({
amount: 1,
idempotencyKey: `image-generate:${recordId}`,
metadata: { feature: "image-generate", recordId },
});
idempotencyKeymust identify one real usage event (8–120 chars). Retrying with the same key returns the current balance without charging twice. Consumption spends soonest-to-expire credits first; an insufficient balance throwsInsufficient credits.
Ledger rules
- Signup credits are granted lazily on first balance / transaction read or consume. They require a verified email and are rate-limited per email, IP, and user agent.
- Purchased credits never expire (
expiresAt = null). - Free grants expire after
expiresInDays; the daily cron sweeps them. - Consumption spends soonest-to-expire credits first, then permanent paid credits.
- Refunds revoke only the unspent remainder of the original purchase.