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.
Your primary key for the user. Used as
external_user_id server-side. Stable across re-identifies.Trait bag. The known fields
email, name, plan, signupDate, company, role are stored in dedicated columns; everything else flows into the traits JSON column.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.
Array of user records. Empty arrays short-circuit (no network call); arrays larger than 1,000 throw
RangeError.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 exportedchunk() 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 returnPromise<void> and emit the canonical event name from STANDARD_EVENT_NAMES. Properties are optional unless flagged required.
signedUp(userId, properties?)
Emits user_signed_up.
External user ID.
Plan the user signed up on (
trial, pro, enterprise).Marketing source (
organic, referral, ads, …).trialStarted(userId, properties?)
Emits trial_started.
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.
Stable feature identifier (snake_case recommended).
Times used in this interaction. Default
1 server-side.onboardingStep(userId, properties)
Emits onboarding_step. step is required.
Onboarding step identifier.
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.
Previous plan.
New plan.
Billing interval.
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).
Free-text cancellation reason.
ISO 8601 timestamp at which access actually ends. Defaults to “now” server-side if omitted.
paymentFailed(userId, properties?)
Emits payment_failed.
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.Integer number of days left in the trial.
userInvited(userId, properties)
Emits user_invited. invitedEmail is 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%).
External user ID.
Current usage as a percentage of the plan limit (
85 = 85%).Optional free-form label so audience rules can target a specific meter (
seats, api_calls, …).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.events.track().