Credits
Configure web credit packages, Stripe / Creem checkout, usage, and ledger rules
Credits
EasyStarter ships a credit system backed by a server-side ledger. On the web app, users buy credit packages through your web payment provider — Stripe or Creem:
- Sell credit packages through Stripe / Creem checkout
- 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 indicator, purchase page, transaction history
The ledger is the single source of truth. The client never changes a balance directly — only the payment webhook (on purchase) and server-side consumption write to it.
Building the mobile app too? Credits share one server and config — see Mobile · Credits for the app 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, apps/server/src/routers/web/credits.ts |
| Web UI | apps/web/src/routes/_authed/(dashboard)/credits/*, apps/web/src/hooks/use-credits.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 | Web 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 web credits
web: {
credits: {
enabled: true,
signupGrant: creditSignupGrant,
packages: webCreditPackages,
},
},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 one-time product
Credit packages are one-time payments — never a subscription. Create the product in your provider's dashboard, then copy its price / product ID for the next step.
Stripe
- Dashboard → Products → Add product, name it (e.g.
100 Credits). - Under Pricing, choose One time (not Recurring), set the amount and currency, then save.
- Open the price and copy its Price ID (
price_xxx). - Toggle Test mode on/off to create a price in each environment — you need one ID for
testand one forprod.
Creem
- Dashboard → Products → Create product, name it (e.g.
100 Credits). - Set the billing type to One time and set the amount.
- Save, then copy the Product ID (
prod_xxx) — Creem uses the Product ID as the price id. - Repeat in both the test and live environments.
Full provider setup (API keys, webhooks) lives in the Stripe and Creem guides.
Configure web packages
Put the price / product IDs from the previous step into web for each package. The active environment is picked from NODE_ENV.
const webCreditPackages = [
{
id: "starter", // internal package id
amount: 100, // credits delivered after purchase
web: {
provider: "stripe", // "stripe" | "creem"
test: { providerPriceId: "price_xxx" },
prod: { providerPriceId: "price_xxx" },
currency: "usd",
amountCents: 499, // $4.99
status: "active",
},
},
] satisfies AppCreditsConfig["packages"];Add package labels
Add a title and description for each package id so the purchase UI can render it.
"credits": {
"packages": {
"starter": {
"title": "Starter pack",
"description": "{count} credits for light usage."
}
}
}Configuration rules
amountandamountCentsmust be positive integers.- Both
testandprodprovider price IDs are required and must be unique. - Set
status: "archived"to hide a package without deleting history. - If the same package is also sold in the app, reuse the same
id(with matchingamountandstatus) — see Mobile · Credits.
2. Set up the server
Run migrations
pnpm db:migrate:local # local D1
pnpm db:migrate # remote D1Configure payment secrets
Credits reuse your web payment provider, so no extra secrets are needed beyond what Stripe / Creem already require:
| Provider | Secrets |
|---|---|
| Stripe | STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET |
| Creem | CREEM_API_KEY, CREEM_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 browser, call the API directly:
await orpc.credits.consume.call({
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.