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.
@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.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
| Name | Type | Description | |
|---|---|---|---|
distinctId | required | string | The real, stable user id you want events attributed to from now on. |
properties | optional | PersonProfile | Profile properties - overwrite semantics (applied as $set). |
propertiesOnce | optional | PersonProfile | Profile properties written only if the key is not already set (applied as $set_once). Use for first-touch attribution like signup_source. |
options.mergeAnonymousId | optional | string | The anonymous id used before signup. Linking it requires consent - see With a merge. |
options.consentGiven | optional | boolean | Set true after obtaining explicit consent for the merge. Without it, the SDK throws consent_required. |
options.isSignup | optional | boolean | Marks 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.firstTouch | optional | FirstTouch | Where the visit came from, usually attribution.current() from the browser. Ignored unless isSignup is true. String fields are limited to 500 characters. |
options.context | optional | CaptureContext | The 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,firstTouchis ignored and the response saysfirstTouch: 'ignored'. People who log in later never get a fake first touch. - Stored once. Later calls never change it;
firstTouchStoredisfalsefor them. isSignup: truealone (nofirstTouch) stores adirectfirst touch for the day of the call.- The channel is derived on the server from
utm_source,utm_mediumandreferrer_host. See Channels. visit_idis discarded. It is accepted so you can passattribution.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 ofstarted_atwhen it is a valid timestamp, not in the future and at most 7 days old, otherwise the day of the call. An invalidstarted_atnever 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
firstTouchStoredisfalse.
The first_* person properties
The stored first touch is mirrored into person properties, so you can filter people and split funnels by it:
| Property | Value |
|---|---|
first_utm_source, first_utm_medium, first_utm_campaign, first_utm_term, first_utm_content | The UTM tags of the first touch. |
first_referrer_host | Referrer host. |
first_landing_path | Landing path, without query string. |
first_channel | paid, email, ai, organic_search, social, referral or direct. |
first_touch_at | The day, YYYY-MM-DD, app time zone. |
first_country | ISO 3166-1 alpha-2 country, from context. |
first_device | Device 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 whencontextis 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.
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.
identify is idempotent - safe to call on every page load. After signup, drop mergeAnonymousId and keep just distinctId + profile properties.Errors
consent_required(400) -mergeAnonymousIdwas passed withoutconsentGiven: true. Acquire consent first.invalid_request(400) - called inaggregatemode, or malformed field (for example afirstTouchstring 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.
$set), @vevee/sdk/browser, Cookieless traffic attribution, Behavioral analytics & funnels and Privacy & GDPR guides.