Skip to main content
The vf.users resource handles user upsert and lifecycle event emission. It wraps POST /api/v1/users (for identify), POST /api/v1/users/bulk (for identifyBulk), and POST /api/v1/events (for every lifecycle helper).

identify(userId, traits)

Upserts a user. Same call works for first-touch and updates — the backend deduplicates by external_user_id.
userId
string
required
Your primary key for the user. Used as external_user_id server-side. Stable across re-identifies.
traits
IdentifyTraits
required
Trait bag. The known fields email, name, plan, signupDate, company, role are stored in dedicated columns; everything else flows into the traits JSON column.
Returns: Promise<void>. Throws on validation / network / auth failure — see Errors.

identifyBulk(records)

Upserts up to 1,000 users in a single POST. The backfill counterpart to identify() — pair with chunk() for larger imports.
records
BulkUserRecord[]
required
Array of user records. Empty arrays short-circuit (no network call); arrays larger than 1,000 throw RangeError.
Returns: Promise<BulkIngestResponse>. Records are validated and queued independently — a single bad row doesn’t sink the batch.
Idempotency comes for free from the (project_id, external_user_id) uniqueness constraint on tracked_users. A chunk that returns 429 or 5xx is safe to retry — duplicate external_user_ids become updates, not new rows.

Backfilling more than 1,000 users

Pair with the exported chunk() helper:
chunk() is a generator — the source array isn’t materialised twice in memory, which matters for very large backfills.

When the queue is saturated

If the project’s ingest queue has more than 50,000 jobs waiting, identifyBulk() throws RateLimitError with the standard Retry-After semantics. Catch and back off:

Lifecycle helpers

All ten return Promise<void> and emit the canonical event name from STANDARD_EVENT_NAMES. Properties are optional unless flagged required.

signedUp(userId, properties?)

Emits user_signed_up.
userId
string
required
External user ID.
properties.plan
string
Plan the user signed up on (trial, pro, enterprise).
properties.source
string
Marketing source (organic, referral, ads, …).

trialStarted(userId, properties?)

Emits trial_started.
properties.trialDays
number
Integer length of the trial in days. Vibefollow derives the trial-end date from it (occurredAt + trialDays), which drives the trial_expiring trigger automatically — no separate expiry event needed.

featureUsed(userId, properties)

Emits feature_used. feature is required.
properties.feature
string
required
Stable feature identifier (snake_case recommended).
properties.count
number
Times used in this interaction. Default 1 server-side.

onboardingStep(userId, properties)

Emits onboarding_step. step is required.
properties.step
string
required
Onboarding step identifier.
properties.completed
boolean
Whether this step was just completed (true) or merely started (false).

subscriptionChanged(userId, properties)

Emits subscription_changed. to is required. interval is denormalised onto the user row for audience filters.
properties.from
string
Previous plan.
properties.to
string
required
New plan.
properties.interval
'monthly' | 'yearly' | 'lifetime'
Billing interval.
properties.mrr
number
Monthly recurring revenue, in cents. 9900 = $99/mo.

subscriptionCancelled(userId, properties?)

Emits subscription_cancelled. Flips the user to the cancelled lifecycle stage (active-but-departing). Distinct from churned (truly gone after grace period).
properties.reason
string
Free-text cancellation reason.
properties.effectiveAt
string
ISO 8601 timestamp at which access actually ends. Defaults to “now” server-side if omitted.

paymentFailed(userId, properties?)

Emits payment_failed.
properties.reason
string
Decline reason from your billing system (card_declined, insufficient_funds, …).

trialExpiring(userId, properties)

Emits trial_expiring. trialDays is required.
You usually don’t need this. The trial_expiring trigger is derived automatically from the most recent trial_started event’s trialDays — call trialStarted once with { trialDays } and Vibefollow fires the follow-up when the trial is within ~48h of ending. Emit this explicitly only to re-assert a dynamically-changed window.
properties.trialDays
number
required
Integer number of days left in the trial.

userInvited(userId, properties)

Emits user_invited. invitedEmail is required.
properties.invitedEmail
string
required
Email address that received the invitation.

usageThresholdReached(userId, properties)

Emits usage_threshold_reached. Drives the plan_limit_upsell trigger — fires when a paying user crosses a usage threshold against a metered plan limit (default ~80%).
userId
string
required
External user ID.
properties.usagePercent
number
required
Current usage as a percentage of the plan limit (85 = 85%).
properties.meter
string
Optional free-form label so audience rules can target a specific meter (seats, api_calls, …).
properties.limit
number
Absolute cap, if known.

Why typed helpers vs events.track()

You can emit the same ten events through vf.events.track('user_signed_up', …). You shouldn’t, because:

Typo protection

signedUp autocompletes; siignedUp is a type error.

Property shapes

Validated by TypeScript at the call site, not just server-side.

Discoverable

vf.users.<dot> lists the canonical set in IDE autocomplete.
For anything outside the canonical 10, fall back to events.track().