Record one behavioral event - a paywall view, an onboarding step, a checkout. The most-used analytics call. Browser-safe with a public key, and in the default hybrid mode it writes nothing to the browser - no cookies, no localStorage.
track / reserveanswer “how much did this user consume?” and require a secret key. The vevee.analytics.*surface answers “what did this user do?”, writes to its own tables, and accepts a public pk_* key - call it directly from browser and mobile code, like PostHog.Signature
vevee.analytics.capture(args: {
distinctId?: string; // OMIT for anonymous aggregate (pre-login)
event: string; // what they did
properties?: AnalyticsProperties;
timestamp?: string; // ISO 8601; defaults to server time
}): Promise<AnalyticsCaptureResponseData>The hybrid model - anonymous pre-login, identified post-login
The SDK runs in hybrid mode by default. Whether you pass distinctId decides what kind of event APL records.
distinctId | What APL writes | Browser identifier |
|---|---|---|
| omitted | One row in analytics_anonymous_events - no person link, no stored IP, no full User-Agent. | None. No cookie, no localStorage. |
| present | One row in analytics_events linked to a person profile. | None - your app passes the id (typically a real user id post-login). |
'use client';
import { createClient } from '@vevee/sdk';
// pk_live_* / pk_test_* - safe in the browser bundle.
const vevee = createClient({ apiKey: process.env.NEXT_PUBLIC_VEVEE_KEY! });
// Pre-login: anonymous aggregate event. No cookie banner needed in the EU.
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' },
});Anonymous capture deduplicates visitors within a single UTC day via a salted hash of ip + user-agent class + per-app daily salt. The salt rotates every 24h and is pruned after 48h, so cross-day correlation is impossible by design. The raw IP and full User-Agent are discarded before any row is written - only country_code (ISO 3166-1) and device_type ( 'mobile' | 'desktop' | 'tablet') land in the table.
Parameters
| Name | Type | Description | |
|---|---|---|---|
distinctId | optional | string | Omit for anonymous aggregate tracking (pre-login). Pass a real user id post-login to attribute the event to a person profile. Never pass a generated UUID just to “have one” - that needs browser storage and a cookie banner. See The hybrid model. |
event | required | string | Event name. Use a reserved name for first-class dashboard treatment, or any custom string. |
properties | optional | AnalyticsProperties | A flat object carried with the event. Most keys are event properties; the reserved keys $set / $set_once instead update the person profile (identified events only - anonymous capture drops them). |
timestamp | optional | string | ISO 8601 event time. Defaults to server receipt time. Pass it when replaying or backfilling. |
Response
interface AnalyticsCaptureResponseData {
eventId: string; // 'anv_…' for anonymous, 'aev_…' for identified
isAnonymous: boolean; // true → routed to analytics_anonymous_events
personId?: string; // identified events only
isReserved?: boolean; // identified events only - true if 'event' is a reserved name
}Narrow on isAnonymous if you need the person id: it is only present on identified events.
The properties object
properties is typed AnalyticsProperties - a single flat object that carries two different kinds of data at once. The full type:
type AnalyticsPropertyValue = string | number | boolean | null;
// A flat bag of facts about a person. Values are scalars only.
type PersonProfile = Record<string, AnalyticsPropertyValue>;
interface AnalyticsProperties {
// Reserved keys - these update the PERSON, not the event:
$set?: PersonProfile; // overwrite profile fields
$set_once?: PersonProfile; // write profile fields only if missing
// Every other key is an EVENT property (scalar values only):
[key: string]: AnalyticsPropertyValue | PersonProfile | undefined;
}- Normal keys (e.g.
placement,plan) are event properties. Values must be scalars - no nested objects, no arrays. Stored on this one event row; what you later break a funnel down by. $set/$set_onceupdate the long-lived person profile (see below). The server strips them before storing the event - they never appear as event properties.
$set / $set_once to - they are silently ignored. Profile writes only land on identified events.UTM & referrer handling (anonymous events)
For anonymous events, three properties keys are lifted into dedicated columns and stripped from the stored JSON for privacy: utm_source, utm_medium, utm_campaign. utm_term / utm_content and any referrer / $referrer field are scrubbed entirely - the referrer is reduced to its host before storage.
Person profiles: $set & $set_once
A person profile is a small bag of always-current facts about a person - their plan, email, signup source, last-seen device. It lives on the person, separate from any single event, and every event that person fires is attributed to it. Update it from any identified capture() call, or via identify().
$set- overwrite. The value you pass wins, every time. Use for facts that should always reflect the latest state:plan,last_seen_at,locale.$set_once- first write wins. The value is recorded only if that key is not already on the profile; later writes for the same key are ignored. Use for first-touch facts:first_paid_at,signup_source,initial_referrer.
vevee.analytics.capture({
distinctId: user.id,
event: 'subscription_started',
properties: {
// Event property - lives on THIS event only:
plan: 'pro',
// Profile updates - live on the PERSON, not the event:
$set: { plan: 'pro', last_seen_at: new Date().toISOString() },
$set_once: { first_paid_at: new Date().toISOString() },
},
});Privacy gates
capture() for a distinctId whose person has been opted out or is pending erasure returns a normal-looking response but writes nothing. The caller cannot tell a recorded event apart from a dropped one - that’s by design.
Tracking modes
Set once at createClient:
const vevee = createClient({
apiKey: process.env['VEVEE_KEY']!,
analytics: {
mode: 'hybrid', // 'hybrid' (default) | 'identified' | 'aggregate'
requireConsent: true, // default
},
});| Mode | Behaviour | EU cookie banner? |
|---|---|---|
hybrid | Default. Anonymous when distinctId omitted, identified when present. No browser storage. | No (unless you also do an anonymous→identified merge - see identify()). |
identified | Every visitor gets a persistent anonymous id via getAnonymousId() → localStorage. | Yes. |
aggregate | Always anonymous. capture() silently drops any distinctId. identify() / alias() disabled. | No. |
See the Privacy & GDPR guide for the full story.
captureBatch()
Send up to 100 events in a single request - useful for flushing a client-side queue.
vevee.analytics.captureBatch(
events: AnalyticsCaptureRequest[], // max 100
): Promise<AnalyticsCaptureBatchResponseData>
interface AnalyticsCaptureBatchResponseData {
accepted: number;
rejected: { index: number; reason: string }[]; // per-event failures
}Each event is processed independently, so a batch can partially succeed. Anonymous and identified events can be mixed in the same batch - just omit distinctId on the anonymous ones.
await vevee.analytics.captureBatch([
{ event: 'paywall_shown' }, // anonymous
{ distinctId: user.id, event: 'onboarding_completed' }, // identified
]);getAnonymousId() deprecated default
Returns a stable per-browser id, persisted in localStorage with an in-memory fallback. Only intended for identified mode. In the default hybrid mode, pass no distinctId instead - no browser storage, no cookie banner. Calling getAnonymousId() in EU contexts requires a cookie consent banner; the SDK logs a one-time warning to remind you.
import { getAnonymousId } from '@vevee/sdk';
const anonId = getAnonymousId(); // 'anon_…' - needs a cookie banner in EU
getAnonymousId('my_custom_storage_key'); // optional custom storage keyReserved events
Any event name works, but a curated catalogue of reserved names gets first-class dashboard treatment - badges, preset funnels, and conventional-property hints. They span seven categories: identity, onboarding, paywall, checkout, subscription, feature, and trial - e.g. signed_up, onboarding_step, paywall_shown, checkout_completed, trial_started.
import { RESERVED_EVENTS, isReservedEvent } from '@vevee/sdk';
isReservedEvent('paywall_shown'); // true
isReservedEvent('my_custom_event'); // falseErrors
Like every SDK method, capture() throws an VeveeError on a failed request - it does not fail silently. In browser code, wrap analytics calls in a try/catch or a .catch() if a failed capture must never surface to the user.
analytics_quota_exceeded(429) - the workspace is over its monthly analytics-event quota.test_quota_exceeded(429) - the sandbox (test-mode) analytics quota is exhausted.invalid_key(401) - bad or revoked API key.invalid_request(400) - missing or malformed field.