Four methods for managing purchasable credit packs. grant() and revoke() require an sk_live_ key (backend only). list() accepts pk_live_so you can render a user's remaining credits in the browser. Read Credit packs first for the model.
credits.grant()
Add credits to an end-user's balance for a specific pack. Call this from your backend after a successful purchase (Stripe webhook, manual grant, refund credit).
grant(args: {
userId: string;
packId: string; // cpk_xxx OR the pack's name (case-insensitive).
// Mirrors upsertSubscription's planId resolution -
// useful when the name doubles as an App Store /
// RevenueCat product id.
quantity?: number; // OMIT for packs with items - each item's configured
// amount is issued automatically. Required for legacy
// packs (zero items) - uses the pack's unit.
externalRef?: string; // idempotency key (Stripe pi_xxx, order id, etc.)
expiresAt?: string | null; // ISO 8601. Omit = pack default. null = evergreen.
source?: 'purchase' | 'grant' | 'refund' | 'manual'; // defaults to 'purchase'
notes?: string;
}): Promise<{
// One entry per balance issued. Single entry for legacy packs; one per
// configured item for item-based packs (e.g. "5 images + 2 videos" → 2 balances).
balances: {
balanceId: string;
packItemId: string | null; // null for legacy single-bucket packs
remaining: number;
expiresAt: string | null;
}[];
// Convenience aliases pointing at balances[0] - existing code that read
// balanceId / remaining / expiresAt directly keeps working unchanged.
balanceId: string;
remaining: number;
expiresAt: string | null;
}>quantityargument. The dashboard's pack editor is the source of truth for “how much” - your billing system just has to know which pack was purchased.Idempotency
When externalRef is provided, the server enforces a unique constraint on (app, user, pack, packItemId, externalRef). A retry of the same Stripe webhook returns the existing balance rows instead of creating duplicates - item-based packs map one ref to N balances cleanly. Without externalRef, every call creates new rows (intentional for manual support grants).
Example: Stripe webhook (item-based pack)
// app/api/webhooks/stripe/route.ts
import { createClient } from '@vevee/sdk';
const vevee = createClient({ apiKey: process.env.VEVEE_SECRET_KEY! });
export async function POST(req: Request) {
const event = await verifyStripeSignature(req);
if (event.type !== 'checkout.session.completed') return Response.json({ ok: true });
const session = event.data.object;
const userId = session.metadata!.userId;
const packId = session.metadata!.packId;
// No quantity needed - the pack's items in the dashboard define the
// per-model amounts. One grant call issues all balances at once.
const result = await vevee.credits.grant({
userId,
packId,
externalRef: session.id, // safe to retry - cs_xxx is unique per checkout
source: 'purchase',
notes: `stripe checkout ${session.id}`,
});
// result.balances has one entry per item in the pack.
return Response.json({ ok: true, balances: result.balances });
}Example: legacy single-bucket pack
If your pack has no items (one bucket, one match-rule list), you still pass quantityexplicitly - that's the only mode that needs it.
await vevee.credits.grant({
userId,
packId: 'cpk_legacy_image_credits',
quantity: 100, // required: no items configured on this pack
externalRef: session.id,
source: 'purchase',
});credits.revoke()
Zero out a specific balance row. Used for refunds, chargebacks, or admin corrections. The revoke writes a ledger row so the history shows the reversal - the balance is not deleted, just brought to 0.
revoke(args: {
balanceId: string; // crb_xxx - get from credits.list() or grant()
reason?: string; // free-form, persisted on the ledger row
}): Promise<{ balanceId: string; remaining: 0 }>credits.list()
List a user's live credit balances. Same matcher semantics as usage(): pass an optional event filter to narrow to balances whose pack rules cover that event.
list(args: {
userId: string;
event?: string; // concrete event name or glob ("image.*", "*")
includeExpired?: boolean; // default false
}): Promise<{
credits: {
balanceId: string;
packId: string;
packName: string;
packItemId: string | null; // identifies the item this balance came from
// (null for legacy single-bucket packs)
kind: 'per_type' | 'generic';
unit: 'count' | 'tokens' | 'seconds' | 'cents';
remaining: number;
initial: number;
grantedAt: string;
expiresAt: string | null;
status: 'active' | 'expired' | 'depleted';
matches: unknown; // same shape as limit_group match rules,
// narrowed to this balance's item when set
priority: number;
}[];
}>For item-based packs, each item produces its own balance row - so a single pack purchase can show up as multiple entries in credits, each with the model-specific matches and unit for that item.
Safe to call from the browser with a pk_live_ key - the server enforces that userId matches the requesting end-user (via your auth integration).
Example: “You have N image credits left” badge
'use client';
import { useEffect, useState } from 'react';
import { createClient } from '@vevee/sdk';
const vevee = createClient({ apiKey: process.env.NEXT_PUBLIC_VEVEE_KEY! });
export function CreditsBadge({ userId }: { userId: string }) {
const [total, setTotal] = useState<number | null>(null);
useEffect(() => {
vevee.credits.list({ userId, event: 'image.*' }).then((r) => {
const sum = r.credits.reduce((acc, b) => acc + b.remaining, 0);
setTotal(sum);
});
}, [userId]);
return <span>{total ?? '…'} image credits left</span>;
}credits.history()
Append-only ledger of every credit motion: grants, debits per event, refunds on release, admin adjustments. Use for support, audit, or analytics.
history(args: {
userId: string;
limit?: number; // default 50, max 500
cursor?: string; // for pagination
}): Promise<{
entries: {
id: string;
balanceId: string;
packId: string;
delta: number; // negative = consumed, positive = refund / grant
reason: 'event_committed' | 'reservation_held' | 'reservation_released'
| 'reservation_expired' | 'grant' | 'admin_adjust';
eventId: string | null;
reservationId: string | null;
occurredAt: string;
}[];
nextCursor: string | null;
}>Errors
invalid_key(401)forbidden(403) - using apk_*key for write methodscredit_pack_not_found(404) - grant against unknown / archived packcredit_balance_not_found(404) - revoke / history with unknown balanceIdinvalid_quantity(400) - non-positive grant quantity, or noquantityprovided to a legacy pack (item-based packs don't need one)test_quota_exceeded(429) - too many test-mode credit balances