Core concepts
Six concepts cover the entire SDK surface: identity, apps, API keys, events, limit groups, and periods. Plus one pattern: reserve / commit.
Identity model
Three identifiers, never confuse them:
- User - a person logged into Vevee itself (you, the developer). Managed by Better Auth.
- Workspace - your developer organization. Apps, plans, and keys belong to a workspace.
- End-user (
userId) - the user of your product. We do NOT authenticate them. You choose the string ID and pass it everywhere as theuserIdargument.
Choosing an end-user identifier
The userId is the join key for everything we store about an end-user - their usage counters, subscription, and history. Pass a stable, opaque identifier: your internal user ID, a UUID, or a hash. Do notpass an email address, name, or any other directly identifying value. Metering only needs a stable key to attribute usage against; an opaque ID keeps personal data off Vevee's servers and shrinks your own data-protection surface. The one hard requirement is stability - the same end-user must always map to the same userId, because that ID is how their usage and subscription history stay connected over time.
| Do | Don't |
|---|---|
user_abc123 (your internal ID) | jane.doe@example.com (email) |
9f8b2c1e-4d7a-… (UUID) | Jane Doe (name) |
Apps
Each metered product is an app (e.g. one for VisualNote iOS, one for the web version). Apps own their own keys, plans, and counters. Events are scoped to an app via the API key you use.
API keys
Every app has two key types:
| Prefix | Use | Can call |
|---|---|---|
sk_live_… | Backend only. Never expose. | Every endpoint. |
pk_live_… | Safe in client code (mobile, browser). | Read-only. Only usage() for the caller's own userId. |
Keys are stored hashed (SHA-256). The dashboard only ever shows the prefix (sk_live_a8f3****) after creation. Lost a key? Revoke it and create a new one - you can have multiple active keys per app for zero-downtime rotation.
Events
The atomic unit is the event:
{
userId: 'user_abc123',
event: 'image.render',
quantity: 1,
metadata: { model: 'flux-pro', resolution: '1024x1024' },
}event- a string you choose. Conventional dot-notation (image.render,llm.completion,video.generate).quantity- how much was consumed.1for an image,1842for tokens,30for seconds.metadata- flat record of strings (Record<string, string>). Coerce numbers/booleans on your side. Used by limit-group match rules and shown in analytics.
Limit groups
A limit group is a quota plus a list of match rules. When youtrack or reserve, every group whose rules match the event increments. When you canUse, every matching group must allow the event for the request to be allowed.
Example: a plan with two groups -
premium_images: 50 / month, matchesevent = image.renderANDmetadata.model = flux-pro.total_images: 500 / month, matchesevent = image.render.
One flux-pro render hits both. One flux-schnell render hits only the second.
Units: count, tokens, seconds, cents. Pick whatever matches your billing dimension.
Fail-closed defaults
canUse and reserve are fail-closed. If the event string doesn't match any limit group on the user's plan, or if the user has no subscription on file, both return { allowed: false, matched: false, reasons: [...] }. The reasons tell you which:
unmatched_event- typo or missing limit group.no_subscription- you forgot to callupsertSubscription.
The SDK console.warns once per call when matched === false and NODE_ENV !== 'production' so these issues surface during development. track()still records every event, even when unmatched, so you can audit them in the dashboard's Events tab - each row carries a status badge (Counted / Not matched / Limit reached / No plan) and unmatched rows expose a Levenshtein-based “did you mean?” against your plan's known patterns.
Plan-change semantics
Calling upsertSubscription with the user's current planId is a no-op (counters keep ticking, startedAtpreserved). When the plan actually changes mid-period, each limit group picks one of three behaviors, configured once per plan in the dashboard's Advanced section:
| Mode | Effect |
|---|---|
carry (default) | Existing counters continue; new groups start at 0. |
reset | Wipe counters for the new plan's groups - fresh start. |
block | Pre-fill to quota; user is limit_reached until next rollover. Anti-abuse. |
Periods
Every plan picks a period and an anchor.
| Field | Values | Meaning |
|---|---|---|
period | daily | weekly | monthly | lifetime | How often counters reset. |
anchor | subscription_start | calendar | subscription_start = relative to when the user subscribed. calendar = absolute (1st of month, Monday, midnight UTC). |
Each counter row stores its actual period_start and period_end, so we never have to recompute boundaries on read. lifetime counters have a null period_end.
The reserve / commit pattern
The naive sequence -
if (await vevee.can(userId, 'image.render')) {
await callFluxPro();
await vevee.track(userId, 'image.render');
}- is broken under concurrency. Two parallel requests can both pass the cancheck before either has called track, and the user blows past their quota.
The fix is atomic reservation:
const r = await vevee.reserve(userId, 'image.render');
if (!r.allowed) return; // hard stop
try {
await callFluxPro();
await vevee.commit(r.reservationId!);
} catch {
await vevee.release(r.reservationId!);
}reserve increments the counter atomically and returns a reservation ID with a 60-second TTL. If you forget to commit or release (your server crashes), the reservation auto-releases on TTL - no permanent leak.
Self-metering
Vevee itself is metered on Vevee. The free tier allows 1 app, 10k events / month, and 100 monthly active end-users per workspace. When you exceed it, every metering endpoint starts returning workspace_limit_reached. Upgrade in Settings → Billing.