vevee.credits.*POST /api/v1/credits/grantPOST /api/v1/credits/revokeGET /api/v1/creditsGET /api/v1/credits/history

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;
}>
i
Packs with items (the per-model amounts you set in the dashboard) drive the quantity themselves. A pack defined as “5 images of gemini-3.1 + 2 seconds of veo3” produces two balance rows on every grant, regardless of any 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 }>
i
Revoking a balance that has already been partially consumed only zeros the remainder. The events that already spent from it stay accounted for - they're real usage.

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 a pk_* key for write methods
  • credit_pack_not_found (404) - grant against unknown / archived pack
  • credit_balance_not_found (404) - revoke / history with unknown balanceId
  • invalid_quantity (400) - non-positive grant quantity, or no quantityprovided to a legacy pack (item-based packs don't need one)
  • test_quota_exceeded (429) - too many test-mode credit balances
Building the buy-credits flow from scratch? See Selling credit packs end-to-end.