Credit packs

A credit packis a purchasable bundle of usage that sits next to your plan's limit groups. When a user's plan quota is exhausted (or they have no plan at all), matching credits are spent automatically. Define them once in the dashboard, grant them from your backend after payment, and the SDK handles the rest.

Per-model items inside a pack

A pack contains one or more items. Each item is its own independently-spendable balance bucket with a target (a specific model, a category like “any image”, or everything), a unit (count / tokens / seconds), and a fixed quantity issued every time the pack is granted.

Example - one pack called “Creator Bundle”:

  • 5 images of image.gemini-3-1-flash-image-preview
  • 2 seconds of video.veo-3

When a buyer purchases this pack, calling credits.grant({ userId, packId }) - without a quantity- issues both balances at once. The image item can only be drained by gemini-3.1 calls; the video item only by veo-3 calls. They stay separate in usage reporting so the end-user sees “5 images and 2 video seconds left”.

The dashboard's pack editor is the source of truth for “how much” - your billing system just has to know which pack the user bought.

Per-type vs generic

Both flavors are the same shape - they differ only by how broad their match rules are (same rules engine as limit groups).

  • Per-type - narrow rules, scoped to one event family. Example: a pack of image.gemini-3-1-flash-image-preview credits. Used when the dev wants users to buy more of a specific thing.
  • Generic - broad rules covering multiple event families. Example: a pack matching image.*, or *for app-wide credits. Used when the dev wants a fungible “AI credits” wallet.

Consumption order

For every metered event, the matcher walks through pools in this order:

  1. Plan counters first. Subscription value is never bypassed.
  2. Credit packs second, sorted by:
    • priority DESC - per-type packs default to 100, generic to 0, so narrow packs drain before broad ones. Override per pack.
    • expires_at ASC NULLS LAST - expiring credits are burned before evergreen ones.
    • granted_at ASC - FIFO for ties.

A single event can split its consumption across multiple balance rows (a partial from per-type, the remainder from generic). Each leg writes its own row to the credit ledger so release() refunds land back on the exact originating balance (preserving its expiry).

Expiry

Each pack sets a defaultExpiryDays. nullmeans “never expires by default”. The expiry applies to every balance the grant issues - all items in an item-based pack share the same expiresAt. Individual grants can override on the way in:

// Item-based pack - quantities come from the pack's items, you just trigger the grant
await vevee.credits.grant({ userId, packId: 'cpk_creator_bundle' });

// Force evergreen for this grant (applies to every balance issued)
await vevee.credits.grant({ userId, packId: 'cpk_creator_bundle', expiresAt: null });

// Custom expiry
await vevee.credits.grant({
  userId,
  packId: 'cpk_creator_bundle',
  expiresAt: '2026-12-31T00:00:00Z',
});

// Legacy pack (no items configured) - pass quantity explicitly
await vevee.credits.grant({ userId, packId: 'cpk_legacy_pool', quantity: 10 });

Expired balances are filtered out of usage() and never consumed. Pass includeExpired: true or call credits.history() for the full audit trail.

Credits without a subscription

Credits work even when the end-user has no active subscription. If canUse() / reserve() find no plan but a matching credit balance with funds, they allow the event and consume from credits only.

Best practice: still upsert a free plan for every signup (upsertSubscription({ planId: 'free' })) so analytics see them as an active subscriber. Credits-only is a fallback for users who cancel - it keeps anything they've paid for spendable.

Where credits show up in usage()

The usage() response includes a credits array alongside counters. The same event filter narrows both sides:

const u = await vevee.usage(userId, 'image.*');

// Plan-side
for (const c of u.counters) {
  console.log(`${c.label}: ${c.remaining}/${c.quota}`);
}

// Credits-side - one entry per balance. Item-based packs surface one entry
// per item, each with the item-specific unit + matches.
for (const b of u.credits) {
  console.log(
    `${b.packName} [${b.kind}, ${b.unit}]: ${b.remaining}/${b.initial}` +
    (b.packItemId ? ` (item ${b.packItemId})` : '') +
    (b.expiresAt ? ` - expires ${b.expiresAt}` : ' - never expires'),
  );
}

Defining packs in the dashboard

Credit packs live at the app level, as peers to plans - not nested inside any single plan. You manage them on the same screen as plans (Plans & Credit Packs), in their own section below the plans grid.

Any end-user of the app can buy any pack, regardless of which plan they’re on - or even with no subscription at all. That keeps credits useful as both a bolt-on to paid plans and as a stand-alone purchase path.

Idempotency

credits.grant() takes an externalRef- your billing system's identifier for the transaction (Stripe pi_xxx, an internal order ID). The same externalRef under the same (app, user, pack, item) is a no-op, so webhook retries are safe - even for item-based packs that issue multiple balances per call, all of them get reused together.

Errors

  • limit_reached - both plan and any matching credits are exhausted. The error reasons array distinguishes plan_exhausted vs plan_and_credits_exhaustedso you can show the right CTA (“Upgrade” vs “Buy credits”).
  • credit_pack_not_found- granting against a pack that doesn't exist or is archived.
  • credit_balance_not_found- revoking a balance ID that doesn't exist for this app.
Walking through a full integration with Stripe webhooks + free-plan fallback? See Selling credit packs end-to-end.