EasyStarter logoEasyStarter

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

LayerLocation
Configpackages/app-config/src/app-config.ts
Server serviceapps/server/src/credits/*
API routesapps/server/src/routers/common/credits.ts
App UIapps/native/app/(tabs)/(profile)/credits*.tsx, apps/native/hooks/use-credits.ts
Purchasesapps/native/lib/payments/revenuecat.ts

The credit tables:

TablePurpose
credit_accountCurrent balance and aggregate counters per user
credit_transactionImmutable ledger rows; the (sourceProvider, sourceType, sourceId) tuple is the idempotency key
credit_orderApp purchase order lifecycle
credit_signup_grant_claimSignup grant eligibility and abuse checks

1. Configure credit packages

Everything is configured in packages/app-config/src/app-config.ts.

Enable native credits

packages/app-config/src/app-config.ts
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.

packages/app-config/src/app-config.ts
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

  1. Monetization → In-App Purchases → +.
  2. Select Consumable.
  3. Set a Reference Name (e.g. 100 Credits) and Product ID (e.g. com.yourapp.credits.starter).
  4. Add a price and localization, then Save.

Android — Google Play Console

  1. Monetize → In-app products → Create product.
  2. Set the Product ID (e.g. credits_starter), name, description, and default price.
  3. Save, then Activate (RevenueCat can't see inactive products).

RevenueCat

  1. Product catalog → Products → Import, select the consumable products, and Import.
  2. 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 the NON_RENEWING_PURCHASE webhook 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.

packages/app-config/src/app-config.ts
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.

packages/i18n/src/messages/native/en.json
"credits": {
  "packages": {
    "starter": {
      "title": "Starter pack",
      "description": "{count} credits for light usage."
    }
  }
}

Configuration rules

  • amount and amountCents must be positive integers.
  • providerProductId is 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 matching amount and status) — see Web · Credits.

2. Set up the server

Run migrations

pnpm db:migrate:local   # local D1
pnpm db:migrate         # remote D1

Configure the RevenueCat webhook secret

Credits reuse your RevenueCat integration, so no extra secret is needed beyond what In-App Purchases already requires:

ProviderSecret
RevenueCatREVENUECAT_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.

apps/server/wrangler.jsonc
"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.

server route
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 },
});

idempotencyKey must 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 throws Insufficient 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.