Selling credit packs end-to-end

Real walkthrough: we’re shipping PixelForge, an AI image and video generator. We sell a monthly Proplan, but heavy users always want more. We’ll add two purchasable packs - one focused on a specific image model, one mixed bundle of images + video - wire up a Stripe webhook, and make sure cancelled users can still spend what they bought.

The pricing we’re modelling

Tier / packWhat you getPrice
Free (plan)2 images / lifetime, gemini-3-1-flash-image-preview only$0
Pro (plan)40 images / month, any model$9 / mo
📦 Gemini 3.1 Top-UpOne item: +50 image.gemini-3-1-flash-image-preview, never expires$4 one-time
📦 Creator BundleTwo items: +10 image.dall-e-3 + 5 seconds of video.veo-3, expires 90 days$15 one-time

1. Create the packs in the dashboard

From the app’s Plans & Credit Packs page, scroll to the Credit packs section below the plans grid and click New credit pack. Each pack holds one or more items; each item picks a model (or category) and the exact credit amount that will be granted for it when the pack is purchased.

  • Gemini 3.1 Top-Up - add a single item: image.gemini-3-1-flash-image-preview50 count. Default expiry: never, price: $4.
  • Creator Bundle - add two items: image.dall-e-310 count, and video.veo-35 seconds. Default expiry: 90 days, price: $15.

Each item becomes its own balance bucket at grant time. A buyer of the Creator Bundle gets two independently-spendable balances - the 10 DALL-E images can’t be drained by Veo calls, and vice versa.

Credit packs are app-level peersto plans - not nested under any particular plan. Any user of your app can buy them, whether they’re on Free, Pro, or have no subscription at all.

2. Add a buy button

'use client';
import { createClient } from '@vevee/sdk';

const vevee = createClient({ apiKey: process.env.NEXT_PUBLIC_VEVEE_KEY! });

export function BuyCreditsCTA({ userId }: { userId: string }) {
  async function buy(packId: string) {
    // 1. Tell your backend to create a Stripe Checkout Session, passing packId + userId
    //    in metadata. Your route returns { url }.
    const res = await fetch('/api/billing/checkout', {
      method: 'POST',
      body: JSON.stringify({ packId, userId }),
    });
    const { url } = await res.json();
    window.location.href = url;
  }

  return (
    <div className="cta-row">
      <button onClick={() => buy('cpk_gemini_topup')}>+50 Gemini 3.1 - $4</button>
      <button onClick={() => buy('cpk_creator_bundle')}>Creator Bundle - $15</button>
    </div>
  );
}

3. Grant credits from the Stripe webhook

This is the only place that calls credits.grant(). Notice there's no quantity map anywhere - the dashboard's pack items are the source of truth, so the webhook just needs to know which pack was bought. Using session.id as externalRef makes it safe to retry the webhook indefinitely.

// app/api/billing/stripe-webhook/route.ts
import Stripe from 'stripe';
import { createClient } from '@vevee/sdk';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const vevee = createClient({ apiKey: process.env.VEVEE_SECRET_KEY! });

export async function POST(req: Request) {
  const sig = req.headers.get('stripe-signature')!;
  const event = stripe.webhooks.constructEvent(
    await req.text(),
    sig,
    process.env.STRIPE_WEBHOOK_SECRET!,
  );

  if (event.type !== 'checkout.session.completed') {
    return Response.json({ ok: true });
  }

  const session = event.data.object as Stripe.Checkout.Session;
  const userId = session.metadata!.userId;
  const packId = session.metadata!.packId;

  // No quantity - each item in the pack carries its own configured amount.
  // The Creator Bundle issues two balances; Gemini Top-Up issues one.
  const result = await vevee.credits.grant({
    userId,
    packId,
    externalRef: session.id,        // <- idempotency: safe to retry
    source: 'purchase',
    notes: `stripe checkout ${session.id}`,
  });

  // result.balances has one entry per item the pack defines.
  return Response.json({ ok: true, balances: result.balances });
}

Stripe retries failed webhooks up to 3 days. Our UNIQUE constraint on (app, user, pack, packItemId, externalRef) means the second delivery returns the existing rows instead of double-granting - multi-item packs map one externalRef to all their balances cleanly.

4. Generate an image (nothing changes in your SDK calls)

The matcher does the rest. When the user’s Pro plan still has quota, the plan counter is decremented. When Pro is exhausted but a matching pack has balance, credits are spent instead. A single generation can even split between them if Pro has 1 left and the call needs 2.

const { reservationId, allowed, reasons } = await vevee.reserve(
  userId,
  'image.gemini-3-1-flash-image-preview',
  1,
);

if (!allowed) {
  // reasons distinguishes 'plan_exhausted' (offer Upgrade)
  // vs 'plan_and_credits_exhausted' (offer Buy credits)
  return showOutOfQuotaUI(reasons);
}

try {
  const url = await callGeminiAPI(prompt);
  await vevee.commit(reservationId, { response: url });
} catch (e) {
  await vevee.release(reservationId, { errorCode: 'provider_error' });
  // credits are refunded automatically to the exact balance row they came from
}

5. Show remaining credits in the UI

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

// Plan side
const plan = u.counters[0]; // your "Images" limit group
const planLeft = plan?.remaining ?? 0;

// Credits side
const creditsLeft = u.credits.reduce((acc, b) => acc + b.remaining, 0);

return (
  <div>
    <span>{planLeft} on plan</span>
    {creditsLeft > 0 && <span>+ {creditsLeft} credits</span>}
  </div>
);

6. Free-plan fallback when subscriptions cancel

A heavy user buys 200 credits, then cancels their Pro plan a week later. They still have 80 credits left - they should keep spending them.

Recommended pattern: on cancel, upsert to the free plan instead of fully cancelling. The user keeps a subscription row (counters reset to free quota), and their credits remain spendable on top.

// In your cancellation flow
await vevee.upsertSubscription({ userId, planId: 'free' });

// Credits keep working - the matcher checks them after the (now smaller) free quota.

If you genuinely have no free plan (the app is paid-only), use cancelSubscription. The matcher then skips plan checks entirely and runs the event against credits alone. Once credits are also exhausted, canUse() returns { allowed: false, reasons: ['no_subscription', 'credits_exhausted'] }.

7. Refunds & admin adjustments

When you process a Stripe refund, revoke the corresponding balance:

// Look up the balance from the original grant - store balanceId on your order row
await vevee.credits.revoke({
  balanceId: order.aplBalanceId,
  reason: 'stripe refund re_xxx',
});

The ledger keeps the original grant and the revoke as separate rows, so support can reconstruct what happened.

Common variations

  • Per-model mixed bundles. Put as many items as you want into a single pack - e.g. 10 images of an expensive model + 50 images of a cheap one, sold for one price. Each item stays its own balance bucket at runtime.
  • Promotional grants. Use source: 'grant' and expiresAt 30 days out to give signup bonuses without touching your billing system. Works the same - call grant() against the pack and every item is issued at its configured amount.
  • Bulk discounts.Create three packs (“Starter” / “Pro” / “Studio”) at different price points; each holds the same set of items, scaled up. Same Stripe → webhook → grant flow, no code change between tiers.
  • Pure pay-as-you-go. Skip plans entirely. Every signup gets a small free grant via credits.grant(); further usage requires purchase. Combine with upsertSubscription({ planId: 'free' }) set to a 0-quota free plan so analytics still see them as a subscriber.
i
Test the whole flow with sk_test_ keys. Test-mode credit balances live in test_credit_balancesand never touch live data - including a separate sandbox quota so a runaway loop in dev can't flood your production wallet.

What to read next