npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

Readme

@vevee/sdk

npm version

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.


Features

  • Provider-agnostic - works with OpenAI, Anthropic, Replicate, Fal, your own models, anything you bill against.
  • Atomic reservations - reserve / commit / release prevents parallel requests from blowing past limits.
  • Behavioral analytics - PostHog-style capture / identify / alias for 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 VeveeError with a stable code.
  • Public + secret keys - read a user's own usage from the browser with pk_live_..., do everything else from the backend with sk_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/sdk

Requires 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 image plan with unit countquantity = 1 per generation.
  • For an llm plan with unit tokensquantity = totalInputTokens + totalOutputTokens.
  • For an llm plan with unit centsquantity = costInCents (integer cents).
  • For a video plan with unit secondsquantity = 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 event string 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 call upsertSubscription, or the user was canceled via cancelSubscription and endsAt has 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 marked match_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