vevee.analytics.identify()POST /api/v1-13/identifypk_live_

Tell Vevee who the current person is, write profile properties, and - optionally - merge a pre-signup anonymous session into the new account. The merge is a consent-gated operation under GDPR / ePrivacy. At signup it can also store the person's first touch: where they came from, for cookieless attribution.

i
1.14.0. From @vevee/sdk 1.14.0, identify() posts to /api/v1-13/identify and accepts isSignup, firstTouch and context. Older SDKs keep posting to /api/v1/identify with unchanged behavior.
i
Analytics only. To merge usage, subscriptions and credits as well, use customers.alias.

Signature

vevee.analytics.identify(
  distinctId: string,              // the real user id
  properties?: PersonProfile,      // applied as $set (overwrite)
  propertiesOnce?: PersonProfile,  // applied as $set_once (first-write-wins)
  options?: IdentifyOptions,
): Promise<AnalyticsIdentifyResponseData>

interface IdentifyOptions {
  mergeAnonymousId?: string;    // anonymous id to merge from: REQUIRES consent
  consentGiven?: boolean;       // self-declaration that consent was obtained
  isSignup?: boolean;           // this call is the signup (1.14.0+)
  firstTouch?: FirstTouch;      // stored only with isSignup: true (1.14.0+)
  context?: CaptureContext;     // visitor geo and user agent (1.14.0+)
}

interface FirstTouch {
  started_at?: string;
  utm_source?: string;
  utm_medium?: string;
  utm_campaign?: string;
  utm_term?: string;
  utm_content?: string;
  referrer_host?: string;
  landing_path?: string;
  visit_id?: string;            // accepted and discarded, never stored
}

interface CaptureContext {
  geo?: { country?: string; region?: string; city?: string };
  userAgent?: string;
}

Parameters

NameTypeDescription
distinctIdrequiredstringThe real, stable user id you want events attributed to from now on.
propertiesoptionalPersonProfileProfile properties - overwrite semantics (applied as $set).
propertiesOnceoptionalPersonProfileProfile properties written only if the key is not already set (applied as $set_once). Use for first-touch attribution like signup_source.
options.mergeAnonymousIdoptionalstringThe anonymous id used before signup. Linking it requires consent - see With a merge.
options.consentGivenoptionalbooleanSet true after obtaining explicit consent for the merge. Without it, the SDK throws consent_required.
options.isSignupoptionalbooleanMarks this call as the signup. Only then is a first touch stored. Send it once, when the account is created. See First touch at signup.
options.firstTouchoptionalFirstTouchWhere the visit came from, usually attribution.current() from the browser. Ignored unless isSignup is true. String fields are limited to 500 characters.
options.contextoptionalCaptureContextThe visitor's geo and user agent, for calls made from your server. Only the country and the device class, browser and OS are kept, on the first touch. See Context.

Response

interface AnalyticsIdentifyResponseData {
  personId: string;
  merged: boolean;            // true when an anonymous person was actually folded in
  firstTouchStored: boolean;  // true when this call wrote the first touch (v1-13 only)
  firstTouch?: 'ignored';     // firstTouch was sent without isSignup: true
}

Without a merge: the common case

Once the visitor has signed up, this is what you call on every page load. Idempotent - safe to call repeatedly. Profile properties can also be set inline from capture().

await vevee.analytics.identify(user.id, {
  email: user.email,
  plan: 'pro',
}, {
  signup_source: 'twitter',
});

First touch at signup

A first touch is the visit that brought a person to your app: UTM tags, referrer host, landing path and a channel Vevee derives from them. It is stored once per person, when you call identify() with isSignup: true, and it is what the Traffic page and person splits use to credit signups and revenue.

// Server, right after the account was created (secret key):
import { contextFromHeaders, type FirstTouch } from '@vevee/sdk';

const { visit } = (await req.json()) as { visit: FirstTouch | null };
// Drop the visit id before it leaves your server. Vevee discards it anyway.
const firstTouch = visit ? (({ visit_id: _drop, ...rest }) => rest)(visit) : null;

const res = await vevee.analytics.identify(user.id, { plan: 'free' }, undefined, {
  isSignup: true,
  ...(firstTouch ? { firstTouch } : {}),
  context: contextFromHeaders(req.headers),
});
// res.firstTouchStored === true on the first call for this person
  • Only with isSignup: true. Without it, firstTouch is ignored and the response says firstTouch: 'ignored'. People who log in later never get a fake first touch.
  • Stored once. Later calls never change it; firstTouchStored is false for them.
  • isSignup: true alone (no firstTouch) stores a direct first touch for the day of the call.
  • The channel is derived on the server from utm_source, utm_medium and referrer_host. See Channels.
  • visit_id is discarded. It is accepted so you can pass attribution.current() as is, but it never reaches storage. The person is never linked to their anonymous visit.
  • The day, not the time. The first touch keeps a day (YYYY-MM-DD) in the app's time zone: the day of started_at when it is a valid timestamp, not in the future and at most 7 days old, otherwise the day of the call. An invalid started_at never fails the call.
  • Skipped for people who are opted out or pending erasure. The rest of identify() still runs.
  • Fail open. If storing the first touch fails, the profile update still succeeds and firstTouchStored is false.

The first_* person properties

The stored first touch is mirrored into person properties, so you can filter people and split funnels by it:

PropertyValue
first_utm_source, first_utm_medium, first_utm_campaign, first_utm_term, first_utm_contentThe UTM tags of the first touch.
first_referrer_hostReferrer host.
first_landing_pathLanding path, without query string.
first_channelpaid, email, ai, organic_search, social, referral or direct.
first_touch_atThe day, YYYY-MM-DD, app time zone.
first_countryISO 3166-1 alpha-2 country, from context.
first_deviceDevice class: mobile, desktop or tablet.

When two people are merged (for example with alias()), the earlier first touch wins.

Context

A call made with a secret key comes from your server, so its IP and user agent describe your server, not the visitor. Vevee never reads them for a secret key: forward the visitor's details in context. contextFromHeaders(headers), exported from @vevee/sdk, builds it from the incoming request: it reads the Vercel geo headers (x-vercel-ip-country, x-vercel-ip-country-region, x-vercel-ip-city), falls back to Cloudflare's cf-ipcountry, ignores the unknown values XX and T1, and adds the user-agent. It never reads the IP.

  • For a first touch, Vevee keeps the country and the device class, browser and OS. Region and city are accepted but never stored.
  • A country that is not two letters is dropped; the call still succeeds.
  • A bot user agent keeps the country and drops the device fields.
  • With a pk_* key from the browser, the edge country header and the request's user agent are used when context is absent.

With a merge: REQUIRES CONSENT

Linking a previously anonymous browsing session to a real account requires explicit end-user consent under GDPR / ePrivacy. Acquire consent through your cookie banner first, then pass consentGiven: true. The SDK throws consent_required (status 400) otherwise.

await vevee.analytics.identify(
  user.id,
  { email: user.email },
  undefined,
  { mergeAnonymousId: anonId, consentGiven: true },
);

The server records the consent in consent_audit_logas a 5-year responsibility-transfer record. APL cannot verify your declaration - as the data controller, that’s your responsibility.

When the merge runs, every analytics_events row, every analytics_distinct_idsmapping, and the anonymous person’s profile are rewritten onto the new identified person. merged comes back true only when there was a real anonymous person to fold in.

!
In hybrid mode (the default), there is no APL-minted anonymous id to merge. The SDK never writes to localStorage, and pre-login events live in analytics_anonymous_events with no person link. Use mergeAnonymousId only when you opt into identified mode (and accept the cookie-banner requirement), or when you mint your own anonymous id with consent already in hand.

Aggregate mode

identify() is disabled in aggregate mode - that universe never identifies individuals. The SDK throws invalid_request. Switch to hybrid (default) or identified if you need user-level analytics.

i
identify is idempotent - safe to call on every page load. After signup, drop mergeAnonymousId and keep just distinctId + profile properties.

Errors

  • consent_required (400) - mergeAnonymousId was passed without consentGiven: true. Acquire consent first.
  • invalid_request (400) - called in aggregate mode, or malformed field (for example a firstTouch string over 500 characters).
  • analytics_quota_exceeded (429) - the workspace is over its monthly analytics quota.
  • test_quota_exceeded (429) - the sandbox analytics quota is exhausted.
  • invalid_key (401) - bad or revoked API key.