# AIPricingLab - LLM-readable reference > AIPricingLab is the drop-in backend for AI usage metering, plan limits, and > analytics. Devs call `vevee.track(userId, event, qty, metadata)` after any AI > action; we count it against the user's plan, enforce quotas atomically, and > expose real-time dashboards. Provider-agnostic - works with OpenAI, Anthropic, > Gemini, Mistral, Replicate, fal, or anything you call. Think of us as > "PostHog for AI usage" - the metering and limits layer between your AI > provider and your billing system. Free up to 1M events/month. > > This file is the canonical, AI-agent-readable summary of the SDK surface. > Human docs: https://www.vevee.org/docs ## Install ``` pnpm add @vevee/sdk ``` Zero runtime deps. Dual ESM/CJS. Full .d.ts types. ## Initialize ```ts import { createClient } from '@vevee/sdk'; const vevee = createClient({ apiKey: process.env.VEVEE_KEY!, // 'sk_live_…' (server) or 'pk_live_…' (client, read-only) baseUrl: 'https://www.vevee.org', // optional, this is the default }); ``` ## Identity - userId: arbitrary string ID for YOUR end-user. SDK does not authenticate them. - App: scoped by API key. - Workspace: owns apps, plans, keys. ## Attributes (Customer-declared semantic layer) Typed, named facts on a person. Declared once in the dashboard (key, displayName, type, options, llmHint). Every attribute supports TWO independent population paths - use either, or both at the same time: 1. Auto-promote from a captured event. The New Attribute form has a "Where does this come from?" section with a single "Auto-populate from a captured event" toggle (on by default). Turning it on creates an attribute_source in the same POST as the definition (event name + optional filters + property to extract). Once saved, matching capture() events write the value with no extra SDK call. 2. Direct SDK write. Always available, regardless of whether the toggle above is on. Use for backfills, third-party webhooks, signup-time writes before any capture, or to override an auto-promoted value: - vevee.analytics.setAttribute(...) - one value, secret key by default - vevee.analytics.setAttributes(...) - bulk, secret-only First-class in dashboard: filterable on funnels (personFilters[]), filterable on the people list, and auto-injected into future LLM compose calls (useInLlm: true). GDPR: included in exportPerson, deleted on deletePerson (audit log scrubbed to [REDACTED]). Full doc: https://www.vevee.org/docs/guides/attributes ## API key types - sk_live_…: backend only. Calls every endpoint. - pk_live_…: safe in client code. Only reads the caller's own usage(). ## Methods (all async, all on the VeveeClient instance) ### track(userId, event, quantity?, metadata?) POST /api/v1/track. Records consumption. Increments every matching limit group. The event row is ALWAYS persisted, even if no limit group matches or the user has no subscription, so devs can audit unmetered events in the dashboard. Args: userId: string (required) event: string (required, e.g. 'image.render') quantity: number (optional, default 1, must be positive) metadata: Record (optional, string-only - coerce on your side) Returns: { eventId: string; matchStatus: 'matched' | 'unmatched' | 'blocked' | 'no_subscription'; matchedGroupIds: string[]; // empty when matchStatus !== 'matched' counters: { groupId, label, unit, quota, count, remaining, costCents, filters }[]; // filters: Record - metadata gates from the group's // match rules; {} for "overall" buckets, e.g. { source: ['text'] } for splits. } Throws: VeveeError with code 'limit_reached' (429) if user is at quota. ### canUse(userId, event, quantity?, metadata?) POST /api/v1/can-use. Read-only check. Does NOT increment. FAIL-CLOSED by default. Returns: { allowed: boolean; matched: boolean; // false → unmatched_event or no_subscription reasons: string[]; // 'limit_reached' | 'unmatched_event' | 'no_subscription' details: { groupId, current, quota, resetsAt: string | null }[]; } - If the event_type doesn't match any limit group on the user's plan: { allowed: false, matched: false, reasons: ['unmatched_event'] } - If the user has no subscription on file: { allowed: false, matched: false, reasons: ['no_subscription'] } The SDK console.warn's once when matched===false and NODE_ENV !== 'production' so typos and missing setup surface during development. Silent in prod. NOTE: canUse is NOT atomic. For enforcement under concurrency use reserve/commit. ### can(userId, event, quantity?, metadata?) Shorthand for canUse. Returns boolean. ### reserve(userId, event, quantity?, metadata?) POST /api/v1/reserve. Atomically holds quota with 60s TTL. FAIL-CLOSED by default (same unmatched_event / no_subscription rules as canUse - no reservation created). Returns: { allowed: boolean; matched: boolean; reservationId?: string; // 'rsv_…', present iff allowed=true expiresAt?: string; // ISO 8601 reasons?: string[]; } ### commit(reservationId) POST /api/v1/commit. Confirms a reservation. Counter stays incremented. ### release(reservationId, options?) POST /api/v1/release. Cancels a reservation. Counter decremented. Optional options: - reason?: string // free-form note, max 500 chars - errorCode?: string // short stable identifier, max 100 chars Stored on the released reservation so you can audit why quota was refunded (e.g. errorCode: 'provider_error', reason: upstream message). Example: await vevee.release(rid, { errorCode: 'provider_error', reason: e.message }); ### usage(userId, event?) GET /api/v1/usage. Returns user's current counters. Works with pk_live_ keys. Returns: { userId: string; period: { start: string; end: string | null }; counters: { groupId, label, unit, quota, count, remaining, costCents, filters }[]; // Includes every group on the user's plan (zero-filled). 'filters' // exposes the metadata gate, so per-source / per-variant splits like // { source: ['text'] } are distinguishable from "overall" buckets ({}). } ### upsertSubscription(params) POST /api/v1/subscriptions. Idempotent assign/update plan for a user. Calling with the SAME planId is a no-op: counters keep ticking, started_at is preserved, periods don't reset. Safe on every login or webhook retry. Args: userId: string planId: string // 'plan_…' customLimits?: PlanLimits // per-user overrides endsAt?: string // ISO 8601 expiry Returns: { subscriptionId, userId, planId, startedAt } When planId CHANGES, each limit group on the new plan picks a behavior (per-plan onPlanChange in the dashboard's Advanced section): - 'carry' (default) - existing counters with same limit_group_id continue. New groups start at 0. - 'reset' - counters for new plan's groups are wiped to 0. - 'block' - counters pre-filled to quota; canUse/reserve return limit_reached until next period rollover. Use this to close the free→pro→cancel→fresh-quota cycling exploit. ## Analytics (vevee.analytics.* - behavioral events, separate from metering) Browser-safe: accepts pk_* keys. Writes to analytics tables, not metering. Powers the dashboard funnel builder. Mirrors PostHog's capture/identify model. ### vevee.analytics.capture({ distinctId, event, properties?, timestamp? }) POST /api/v1/capture. Record one behavioral event. distinctId: string (required) - real user id, or getAnonymousId() pre-signup event: string (required) - a reserved name or any custom string properties: object (optional) - string|number|boolean|null values; reserved keys $set / $set_once write to the person profile timestamp: ISO 8601 string (optional, defaults to server time) Returns: { eventId, personId, isReserved } ### vevee.analytics.captureBatch(events) POST /api/v1/capture/batch. Up to 100 capture requests in one call. Returns: { accepted: number, rejected: { index, reason }[] } ### vevee.analytics.identify(distinctId, properties?, propertiesOnce?, anonymousId?) POST /api/v1/identify. Identify a user. Pass anonymousId ONCE at signup to merge the pre-signup anonymous session onto the real person. Idempotent. Returns: { personId, merged } ### vevee.analytics.alias(distinctId, alias) POST /api/v1/alias. Bridge two ids for one person; no profile change. Returns: { personId } ### getAnonymousId(storageKey?) Browser helper, no network call. Stable per-browser id stored in localStorage (in-memory fallback for SSR). Use as distinctId before the user signs up. ### Reserved events RESERVED_EVENTS (typed catalogue) and isReservedEvent(name). AI-app lifecycle names - signed_up, onboarding_started/step/completed, paywall_shown/clicked, checkout_started/completed, trial_started, subscription_started, feature_used … Reserved events get dashboard badges and preset funnels. ### Funnels Built in the dashboard from captured events - there is no funnel SDK call. Steps are events (OR-able, individually optional); the engine reports how many people reached and dropped at each step. ### vevee.analytics.setAttribute({ distinctId, attribute, value }) POST /api/v1/attributes/values. Set one attribute value on a person. The attribute must be declared in the dashboard first (Attributes nav). Server enforces type + config (options, max_length, min/max). Auth: - sk_live_*: always allowed - pk_live_*: only when workspace flag client_attribute_writes_enabled === 1 (default 0) Args: distinctId: string (required, 1-200) attribute: string (required, the declared key) value: string | number | boolean | string[] Returns: { personId: string; attribute: string; value: AttributeValue } Throws: attribute_not_defined (400) - key not declared in dashboard attribute_value_invalid (400) - value rejected by type/config attribute_archived (400) - attribute soft-deleted client_attribute_writes_disabled (403) - public-key write with workspace flag off ### vevee.analytics.setAttributes({ distinctId, attributes }) POST /api/v1/attributes/values/batch. Secret-only (no public-key surface). Bulk write, partial-success semantics - invalid rows go to rejected[], not the whole request. Args: distinctId: string attributes: Record Returns: { personId: string; applied: number; rejected: { key: string; reason: string }[] } Throws: requires_secret_key (403), invalid_request (400) ### vevee.analytics.getAttributes({ distinctId, keys? }) GET /api/v1/attributes/values?distinctId=...[&keys=k1,k2]. Returns only attributes that have been set on this person. Unset attributes are absent from the response (not null). Archived attributes are filtered out. Returns: { personId: string; attributes: Record } ### vevee.analytics.clearAttribute({ distinctId, attribute }) DELETE /api/v1/attributes/values. Idempotent - clearing an unset attribute returns cleared: false without error. The attribute_audit_log retains a redacted entry. Gated like setAttribute. Returns: { personId: string; attribute: string; cleared: boolean } Analytics errors: analytics_quota_exceeded (429), test_quota_exceeded (429), invalid_key (401), invalid_request (400). ## Reserve / commit pattern (REQUIRED for safe enforcement) ```ts const r = await vevee.reserve(userId, 'image.render', 1, { model: 'flux-pro' }); if (!r.allowed) throw new LimitError(r.reasons); try { const image = await callFluxPro(prompt); await vevee.commit(r.reservationId!); return image; } catch (err) { await vevee.release(r.reservationId!); throw err; } ``` - Reservations auto-release after 60 seconds if neither commit nor release is called. - This is the ONLY safe pattern under concurrency. Naive canUse → call → track is broken. ## Errors SDK throws VeveeError { code, status, message } for non-2xx responses. Codes: - not_found (404) - invalid_key (401) - requires_secret_key (403) - pk_live_ used for write endpoint - limit_reached (429) - end-user at plan quota - workspace_limit_reached (429) - your Vevee workspace at quota - invalid_request (400) - reservation_expired (400) - reservation_not_pending (400) - already committed/released - internal_error (500) - retry with backoff - not_implemented (501) NOTE: 'unmatched_event' and 'no_subscription' are NOT thrown - they appear inside the response `reasons` array of canUse/reserve when matched=false. Wire format: { "ok": true, "data": } { "ok": false, "error": { "code": "", "message": "" } } ## Limit groups (concept) A limit group has: id, label, unit (count|tokens|seconds|cents), quota, matches[], and an optional onPlanChange ('carry' | 'reset' | 'block', default 'carry'). A match rule has: event, optional metadata (string key/value). On track/reserve, ALL matching groups increment. canUse/reserve fail if ANY matching group is over quota. Zero matches → fail-closed (see canUse fail-closed rules). ## Periods (concept) - period: 'daily' | 'weekly' | 'monthly' | 'lifetime' - anchor: 'subscription_start' (relative) | 'calendar' (absolute, e.g. 1st of month UTC) - lifetime counters have period.end = null. ## ID prefixes ws_ workspace, app_ app, plan_ plan, lg_ limit group, sub_ subscription, evt_ event, cnt_ counter, rsv_ reservation. ## Dashboard event log Every event tracked is visible at /apps//events with a status badge: - 'Counted' (matched), 'Not matched' (unmatched), 'Limit reached' (blocked), 'No plan' (no_subscription). Unmatched rows expose a "did you mean?" panel powered by Levenshtein distance against the patterns on the user's current plan - surfaces typos automatically. ## Common recipes ### Streaming LLM with real token count Reserve an upper bound, commit on success, optionally track a 'refund' event for the difference. ### Stripe webhook → upsertSubscription Map Stripe product → planId, then call vevee.upsertSubscription on checkout.session.completed and on customer.subscription.deleted (downgrade to plan_free). ### Express middleware ```ts const requireQuota = (event: string) => async (req, res, next) => { if (!(await vevee.can(req.user.id, event))) return res.status(429).end(); next(); }; ``` ## More - Methods reference: https://www.vevee.org/docs/methods/track - Concepts: https://www.vevee.org/docs/concepts - Errors: https://www.vevee.org/docs/errors - Recipes: https://www.vevee.org/docs/recipes - Guides: https://www.vevee.org/docs/guides - Guide - Freemium image generator: https://www.vevee.org/docs/guides/freemium-image-generator