@vevee/sdk
v1.2.1
Published
Usage metering and limits SDK for AI-powered apps. Track LLM tokens, image generations, video seconds, and any other AI consumption - provider-agnostic.
Maintainers
Readme
@vevee/sdk
The official TypeScript SDK for Vevee - drop-in usage metering, limits, and plan enforcement for AI-powered apps.
Stop building your own meter for LLM tokens, image generations, video seconds, or any other AI consumption. Define plans in the dashboard, install this SDK, and ship.
- 🌐 Website: https://vevee.org
- 📚 Docs: https://vevee.org/docs
- 🐛 Issues: https://vevee.org/support
Features
- Provider-agnostic - works with OpenAI, Anthropic, Replicate, Fal, your own models, anything you bill against.
- Atomic reservations -
reserve/commit/releaseprevents parallel requests from blowing past limits. - Behavioral analytics - PostHog-style
capture/identify/aliasfor paywalls, onboarding, and conversion funnels. Browser-safe with a public key. - Prompt + artefact capture - optionally attach the prompt/response strings and the generated binary (image, video, audio) to each event for inspection in the dashboard. Strictly observational - no impact on quotas.
- Zero runtime dependencies - uses the platform
fetch. Tiny bundle, works in Node, Bun, Deno, Edge, and the browser. - Typed errors - every failure mode is a typed
VeveeErrorwith a stablecode. - Public + secret keys - read a user's own usage from the browser with
pk_live_..., do everything else from the backend withsk_live_....
New here? Read the tracking guide
- a top-to-bottom walk-through of subscribing a user, picking a metering pattern, capturing prompts and media, and handling the unhappy paths.
Install
npm install @vevee/sdk
# or
pnpm add @vevee/sdk
# or
yarn add @vevee/sdkRequires Node 18+ (or any runtime with global fetch).
Quick start
Grab a secret key from your app's API keys tab at https://vevee.org, then:
import { createClient } from '@vevee/sdk';
const vevee = createClient({ apiKey: process.env.VEVEE_KEY! });
// Just record consumption - fire-and-forget.
await vevee.track('user_abc123', 'image.flux-pro', 1);That's it. The event is now visible in your Vevee dashboard, counted against any matching plan limits.
Event naming convention
The plan builder generates match rules in the form <category>.<modelId>, where category is one of image, llm, video, audio. Use the same shape in your track / canUse / reserve calls so events count toward plan rows automatically:
| Category | Example event name |
|---|---|
| Image generation | image.flux-pro, image.gemini-3.1-image |
| Language model | llm.gpt-4o, llm.claude-sonnet-4-6 |
| Video generation | video.runway-gen3, video.veo-2 |
| Audio generation | audio.elevenlabs-v2, audio.whisper |
A plan can also have a category total (e.g. "30 images of any kind per month"). Those use the wildcard pattern <category>.*, which matches any event that starts with that prefix - you don't need to emit anything special.
You're free to use any other event names too - they just won't match the structural plan rows; they'll only fire if you've added a matching custom group.
Reserve / commit / release (recommended)
For strict enforcement, use the three-step pattern. It atomically increments the counter before the AI call so two parallel requests can't both squeeze through under the limit:
import { createClient, VeveeError } from '@vevee/sdk';
const vevee = createClient({ apiKey: process.env.VEVEE_KEY! });
async function generateImage(userId: string, prompt: string) {
// 1. Reserve quota before the AI call.
const reservation = await vevee.reserve(userId, 'image.flux-pro', 1);
if (!reservation.allowed) {
throw new Error(`Limit reached: ${reservation.reasons?.join(', ')}`);
}
try {
const image = await callFluxPro(prompt);
// 2. Commit on success - the charge is final.
await vevee.commit(reservation.reservationId!);
return image;
} catch (err) {
// 3. Release on failure - the user is not charged.
// Pass an errorCode + reason to record *why* the quota was refunded.
await vevee.release(reservation.reservationId!, {
errorCode: 'provider_error',
reason: err instanceof Error ? err.message : String(err),
});
throw err;
}
}Reservations auto-expire after 60 seconds if you forget to commit or release, so a crashed server won't permanently lock quota.
Both errorCode (≤100 chars, stable identifier you'll group on) and reason (≤500 chars, human-readable detail) are optional. They're stored on the released reservation so you can audit upstream failures, surface error rates per model, or settle disputed charges.
Track (fire-and-forget)
Simpler, no atomicity guarantee. Use it when slight over-counting is acceptable.
quantity semantics
quantity is the number of units the event consumed in whatever unit the matching plan limit uses:
- For an
imageplan with unitcount→quantity = 1per generation. - For an
llmplan with unittokens→quantity = totalInputTokens + totalOutputTokens. - For an
llmplan with unitcents→quantity = costInCents(integer cents). - For a
videoplan with unitseconds→quantity = clipLengthInSeconds.
// LLM call - pass total tokens consumed
await vevee.track('user_abc123', 'llm.gpt-4o', 1842, {
inputTokens: '920',
outputTokens: '922',
});
// Video clip - pass duration in seconds
await vevee.track('user_abc123', 'video.runway-gen3', 8);Check without charging
const { allowed, matched, reasons } = await vevee.canUse('user_abc123', 'image.flux-pro');
if (!allowed) {
return showUpgradePrompt(reasons);
}Or the boolean shorthand:
if (await vevee.can('user_abc123', 'image.flux-pro')) {
// proceed
}Fail-closed by default
canUse and reserve return { allowed: false, matched: false } when:
- The
eventstring doesn't match any limit group on the user's plan →reasons: ['unmatched_event'](typo, missing limit group, or mistyped event name). - The user has no active subscription on file →
reasons: ['no_subscription']. Either you forgot to callupsertSubscription, or the user was canceled viacancelSubscriptionandendsAthas now passed.
This is intentional - silent fail-open is a footgun. To make typos and missing setup obvious during development, the SDK also console.warns once per call when matched === false and process.env.NODE_ENV !== 'production'. Production stays silent.
Every event sent to track() is recorded regardless (even unmatched / no-subscription) so you can inspect them in the dashboard's Events activity feed, which shows status badges (Counted / Not matched / Limit reached / No plan) and "did you mean?" suggestions for typos.
Prompt and response logging
Both track and reserve / commit accept optional prompt and response
strings on a trailing options bag. When the app has prompt logging enabled
in the dashboard, they're persisted to event_logs keyed to the event row.
When the toggle is off, the strings are silently dropped server-side, so you
can pass them unconditionally and flip the dashboard switch later.
// Track flow
await vevee.track('user_abc123', 'image.flux-pro', 1, undefined, {
prompt: 'a cat in a tiny hat',
response: 'https://cdn.example/img.png',
});
// Reserve/commit flow - prompt is captured before the AI call,
// response after.
const { reservationId } = await vevee.reserve(
'user_abc123', 'image.flux-pro', 1, undefined,
{ prompt: 'a cat in a tiny hat' },
);
try {
const url = await callFluxPro(prompt);
await vevee.commit(reservationId!, { response: url });
} catch (err) {
await vevee.release(reservationId!, {
errorCode: 'provider_error',
reason: err instanceof Error ? err.message : String(err),
});
}Per-field cap: 32 KB. Longer values are server-side truncated with
…[truncated].
Media attachments
Both track and commit accept an optional media array - the binary
artefact the AI produced (image, video clip, audio file). Stored alongside
the event row for later inspection in the dashboard. Purely observational:
no quota, no credit charge, no impact on canUse / reserve decisions.
const image = await callFluxPro(prompt);
await vevee.track('user_abc123', 'image.flux-pro', 1, undefined, {
prompt: 'a cat in a tiny hat',
media: [{
kind: 'image',
mime: 'image/png',
filename: 'out.png',
data: image.bytes, // Uint8Array | Blob | ReadableStream
sizeBytes: image.bytes.byteLength,
}],
});reserve does not accept media - the AI call hasn't run yet. Pass
media on commit instead:
await vevee.commit(reservationId!, {
response: 'flux_image_42',
media: [{ kind: 'image', mime: 'image/png', data: bytes, sizeBytes: bytes.byteLength }],
});The response carries a media: MediaResult[] aligned with the input array.
Each entry has a status of uploaded / skipped / failed / pending,
plus a skipReason or failReason when applicable.
Media handling never throws. If the app toggle is off, the eligibility
gate rejects the file, or the upload network call fails, the metering event
still succeeds and media[i].status reflects what happened. Wire an
onMediaError callback on createClient to surface failures to your
observability stack:
const vevee = createClient({
apiKey: process.env.VEVEE_KEY!,
onMediaError: (err, input) => logger.warn({ err, input }, 'apl media upload failed'),
});Per-file cap 100 MB, per-request 10 files / 200 MB aggregate. Full design
and storage layout: see the project's docs/media-uploads.md.
Variant-gated plan rows
Some plans gate a model by output tier - e.g. "100 standard images, 10 4K images". Pass the variant key in metadata so the right bucket is incremented:
await vevee.track('user_abc123', 'image.gemini-3.1-image', 1, { variant: '4k' });variant is the only reserved metadata key - everything else is yours.
Metadata is string-only
Metadata values must be strings (Record<string, string>). The API rejects numbers and booleans, so coerce them on your side:
await vevee.track('user_abc123', 'llm.gpt-4o', 1842, {
inputTokens: String(920), // ✅
cached: String(true), // ✅
// outputTokens: 922, // ❌ rejected
});Read a user's own usage (browser-safe)
Use a pk_live_... key in client-side code. Public keys can only read usage; they cannot track, reserve, or modify subscriptions. The SDK auto-routes public keys to the public-safe endpoint:
const vevee = createClient({ apiKey: 'pk_live_...' });
const usage = await vevee.usage(currentUserId);
// → { userId, period: { start, end } | null, counters: [{ groupId, count, costCents }, ...] }period is null when the user has no active subscription on the app.
Manage subscriptions
Move a user onto a plan from your backend:
await vevee.upsertSubscription({
userId: 'user_abc123',
planId: 'plan_pro_monthly',
});Calling upsertSubscription with the same planId the user is already on is a no-op: counters keep ticking, started_at is preserved, periods don't reset. Safe to call on every login or webhook retry.
Plan-change semantics
When you move a user onto a different plan mid-period, each limit group on the new plan picks one of three behaviors (configured per-plan in the dashboard's Advanced section):
| Mode | What happens to counters at switch time |
|---|---|
| carry (default) | Existing counters with the same limit-group ID continue. Brand-new groups start at 0. |
| reset | Counters for the new plan's groups are wiped - the user starts the period from 0. |
| block | Counters are pre-filled to quota; canUse / reserve return limit_reached until the next period rollover. |
This lets you choose the right downgrade/upgrade UX - generous (carry), clean-slate (reset), or anti-abuse (block, which closes the "free→pro→cancel→fresh free quota" cycling exploit).
Cancellation
There are two cancellation patterns depending on your product. Pick one - don't mix them for the same user.
Pattern A - your app has a free plan (most consumer apps)
Don't call cancelSubscription. Just move the user to the free plan:
await vevee.upsertSubscription({
userId: 'user_abc123',
planId: 'free',
});The user keeps using your app under free-tier limits. The transition is recorded in the subscription history as plan_changed: pro → free (queryable later for churn analytics). Period and counter behavior follow whichever onPlanChange mode the new plan's limit groups are configured with.
Pattern B - your app requires a paid subscription (no free tier)
Call cancelSubscription when the user churns out:
await vevee.cancelSubscription({
userId: 'user_abc123',
// optional: schedule cancellation at period end instead of immediately
endsAt: '2026-06-30T23:59:59Z',
reason: 'user_cancel',
});Effect once endsAt is reached (immediately if omitted):
canUse(...)returns{ allowed: false, matched: false, reasons: ['no_subscription'] }- the user is blocked.reserve(...)returns the same shape and creates no reservation - the user is blocked.track(...)still records the event for visibility (you'll see the attempt in the dashboard) but increments no counters and bills no cost. The event row is markedmatch_status: 'no_subscription'.
Your code should already be guarded with if (!result.allowed) return ..., so cancellation flows through without needing any client-side change beyond a "subscribe to continue" UI.
endsAt accepts a future ISO timestamp to schedule the cancellation (e.g. "let them use it until the billing period ends"). The user keeps their current plan and limits until that moment, then is blocked.
To reactivate a canceled user, call upsertSubscription({ userId, planId }) - that clears endsAt and logs a fresh transition in the history.
Subscription history
Every created / plan_changed / canceled transition is appended to a server-side audit log (subscription_events) - never overwritten. This powers churn / downgrade analytics, "which plan was this user on last Tuesday?" lookups, and dispute resolution. A no-op upsert (same plan, no change) writes no history row; only real state changes are recorded.
Credit packs
Plans set recurring quotas; credit packs are one-shot top-ups that don't reset on period rollover. Use them for paid bolt-ons ("buy 100 extra images"), promo grants, or refunds. Credits are consumed automatically by track / reserve when the user is out of plan quota - there's nothing to wire on the consumption side.
All credit ops live under client.credits.*.
// Grant 100 image credits to a user after a Stripe webhook.
// `externalRef` makes this idempotent - replaying the same Stripe event
// is a no-op, no double-grant.
await vevee.credits.grant({
userId: 'user_abc123',
packId: 'pack_image_topup',
quantity: 100,
externalRef: stripeEvent.id,
source: 'purchase',
});// Browser-safe: list a user's own balances. Public keys (`pk_*`) are accepted
// when `userId` matches the end-user that key represents.
const vevee = createClient({ apiKey: 'pk_live_...' });
const { credits } = await vevee.credits.list({
userId: currentUserId,
event: 'image.flux-pro', // optional: only packs that cover this event
});// Read counters and credits together - both come back from usage().
const { counters, credits } = await vevee.usage(currentUserId, 'image.*');
// ^ same filter applies to both// Audit trail / ledger - paginated, newest first. Requires a secret key.
const { entries, nextCursor } = await vevee.credits.history({ userId: 'user_abc123', limit: 50 });// Refund / clawback - sets remaining to 0 and records an admin_adjust ledger row.
await vevee.credits.revoke({ balanceId: 'cb_...', reason: 'chargeback' });| Method | Purpose |
|---|---|
| credits.grant({ userId, packId, quantity, externalRef?, expiresAt?, source?, notes? }) | Issue a credit balance. Idempotent on externalRef. Secret key required. |
| credits.revoke({ balanceId, reason? }) | Zero out a balance. Secret key required. |
| credits.list({ userId, event?, includeExpired? }) | List a user's live balances. Public key OK when reading the key's own user. |
| credits.history({ userId, limit?, cursor? }) | Paginated ledger history. Secret key required. |
Behavioral analytics
Metering answers "how much did this user consume?". Analytics answers
"what did this user do?" - paywall views, onboarding steps, checkout funnels,
feature engagement. It's a separate surface from track / reserve, writes to
its own tables, and is browser-safe with a pk_* key.
The default mode is hybrid: anonymous aggregate pre-login, identified
post-login. No localStorage, no cookie, no cookie banner needed in the EU
out of the box. Configure once at createClient if you need a different
posture:
import { createClient } from '@vevee/sdk';
const vevee = createClient({
apiKey: process.env.NEXT_PUBLIC_VEVEE_KEY!,
// analytics: { mode: 'hybrid', requireConsent: true }, // ← defaults
});capture - omit distinctId while anonymous
// Pre-login: anonymous aggregate event. No person profile, no stored IP.
vevee.analytics.capture({ event: 'paywall_shown' });
// Post-login: identified event linked to the user's profile.
vevee.analytics.capture({
distinctId: user.id,
event: 'paywall_shown',
properties: { placement: 'after_3rd_image', plan: 'pro' },
});One event = one capture() call. You choose the event names; the dashboard
builds funnels and reports from them. A curated set of reserved event names
(paywall_shown, checkout_completed, onboarding_step, …) get first-class
treatment - badges, preset funnels, property hints. Import the catalogue for
compile-time-safe names:
import { RESERVED_EVENTS, isReservedEvent } from '@vevee/sdk';identify - link an identity
await vevee.analytics.identify(user.id, {
email: user.email, plan: 'pro',
});identify is idempotent - safe to call on every page load.
Merging a pre-signup anonymous session into the new account is
consent-gated under GDPR / ePrivacy. Acquire user consent through your
cookie banner first, then pass it explicitly - the SDK throws
consent_required otherwise:
await vevee.analytics.identify(user.id, { email }, undefined, {
mergeAnonymousId: anonId,
consentGiven: true,
});alias
await vevee.analytics.alias('user_uuid_old', '[email protected]');The same consent gate kicks in when either id is an APL anonymous session
(an id minted by getAnonymousId(), recognised by its anon_ prefix) -
pass { consentGiven: true }.
Person profiles
Profile properties live on the person, not the event. Update them via
identify, or inline from capture with the reserved $set / $set_once
keys (identified events only):
vevee.analytics.capture({
distinctId: user.id,
event: 'subscription_started',
properties: {
plan: 'pro',
$set: { plan: 'pro' },
$set_once: { first_paid_at: new Date().toISOString() },
},
});Privacy & GDPR - secret-key only
Backend-only privileged operations. All six refuse a pk_* key with
requires_secret_key.
await vevee.analytics.optOut('user_12345'); // Art. 21 right to object
await vevee.analytics.optIn('user_12345');
await vevee.analytics.isOptedOut('user_12345');
const { jobId } = await vevee.analytics.deletePerson('user_12345'); // Art. 17
await vevee.analytics.getDeletionStatus(jobId);
const { downloadUrl, expiresAt } = await vevee.analytics.exportPerson('user_12345');
// Art. 15 / 20 - share `downloadUrl` with the data subject (valid 24h)After optOut, subsequent capture() calls for that user are silently
dropped - the response shape is normal so the caller cannot tell. After
deletePerson, an async worker cascades the delete across analytics +
metering surfaces; new captures drop in the meantime via pending_deletion.
getAnonymousId (deprecated in hybrid mode)
getAnonymousId() is still exported for identified-mode use cases, but
emits a deprecation warning on first call. In hybrid mode (the default),
you don't need it - omit distinctId from capture() instead.
Batching
Send up to 100 events in a single request:
await vevee.analytics.captureBatch([
{ distinctId: 'u1', event: 'onboarding_step', properties: { step: '1' } },
{ distinctId: 'u1', event: 'onboarding_step', properties: { step: '2' } },
]);Analytics events count against a separate per-workspace quota; exceeding it
throws analytics_quota_exceeded. Full guide:
docs/analytics.md
· dev-facing privacy guide:
docs/analytics-privacy.md.
Error handling
Every method throws a typed VeveeError on failure:
import { VeveeError } from '@vevee/sdk';
try {
await vevee.track('user_abc123', 'image.flux-pro');
} catch (err) {
if (err instanceof VeveeError) {
console.error(err.code, err.status, err.message);
// err.code: 'limit_reached' | 'invalid_key' | 'not_found' | 'workspace_limit_reached' | ...
}
}API reference
| Method | Purpose |
|---|---|
| track(userId, event, quantity?, metadata?, { prompt?, response?, media? }?) | Record consumption. Optional prompt/response are stored when prompt logging is on; optional media is uploaded out-of-band when the app toggle is on. |
| canUse(userId, event, quantity?, metadata?) | Check if allowed; returns { allowed, matched, reasons, details }. |
| can(userId, event, quantity?, metadata?) | Boolean shorthand for canUse. |
| reserve(userId, event, quantity?, metadata?, { prompt? }?) | Atomically hold quota; returns a reservationId. Optional prompt is captured before the AI call. |
| commit(reservationId, { response?, media? }?) | Confirm a reservation. Optional response/media are persisted same as on track. |
| release(reservationId, { reason?, errorCode?, response? }?) | Cancel a reservation; quota returned. Optional fields are persisted for auditing. |
| usage(userId, event?) | Read a user's current counters. |
| availablePlans() | Fetch the app's public plan catalogue. Browser-safe with a pk_* key. |
| upsertSubscription({ userId, planId, customLimits?, endsAt?, cycleStart? }) | Assign or change a user's plan. Use this for downgrades when you have a free plan. |
| cancelSubscription({ userId, endsAt?, reason? }) | Cancel a user's subscription. Use when you have no free plan and want to block the user post-cancellation. |
| credits.grant(...) / credits.revoke(...) / credits.list(...) / credits.history(...) | One-shot credit packs that survive period rollover. See Credit packs above. |
| analytics.capture({ distinctId, event, properties?, timestamp? }) | Record one behavioral event. Browser-safe with a pk_* key. |
| analytics.captureBatch(events) | Send up to 100 behavioral events in one request. |
| analytics.identify(distinctId, properties?, propertiesOnce?, anonymousId?) | Identify a user; pass anonymousId to merge a pre-signup session. |
| analytics.alias(distinctId, alias) | Bridge two ids for the same person, no profile change. |
| getAnonymousId(storageKey?) | Stable per-browser anonymous id for pre-signup events. |
Full request/response types are exported from the package - your editor will pick them up.
Configuration
createClient({
apiKey: 'sk_live_...', // required
baseUrl: 'https://vevee.org', // optional
analytics: {
mode: 'hybrid', // 'hybrid' (default) | 'identified' | 'aggregate'
requireConsent: true, // default
},
});baseUrl - only set this if you're running a self-hosted instance.
analytics.mode - see the behavioral-analytics section.
The default hybrid mode is EU-friendly out of the box and writes nothing to
the browser. identified requires a cookie consent banner in the EU. aggregate
disables identify() / alias() entirely.
analytics.requireConsent - a posture flag that you (the developer) won't
write a persistent browser identifier without consent. The SDK writes
nothing to the browser in hybrid mode regardless; the flag matters when
you opt into identified-mode features that call getAnonymousId().
License
MIT © Vevee
