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 / pack | What you get | Price |
|---|---|---|
| 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-Up | One item: +50 image.gemini-3-1-flash-image-preview, never expires | $4 one-time |
| 📦 Creator Bundle | Two 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-preview→50count. Default expiry: never, price:$4. - Creator Bundle - add two items:
image.dall-e-3→10count, andvideo.veo-3→5seconds. 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'andexpiresAt30 days out to give signup bonuses without touching your billing system. Works the same - callgrant()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 withupsertSubscription({ planId: 'free' })set to a 0-quota free plan so analytics still see them as a subscriber.
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
- Credit packs concepts - model details, precedence, expiry
- credits.* methods reference
- usage() with the credits array
- Downgrade vs cancel - pairs with this pattern