> ## Documentation Index
> Fetch the complete documentation index at: https://docs.vibefollow.com/llms.txt
> Use this file to discover all available pages before exploring further.

# users

> `identify()`, `identifyBulk()` for backfills, plus typed helpers for the 10 canonical lifecycle events.

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`.

```ts theme={null}
await vf.users.identify('usr_42', {
  email: 'jane@acme.io',
  name: 'Jane Doe',
  plan: 'pro',
  signupDate: '2026-05-17T14:00:00Z',
  company: 'Acme Inc.',
  role: 'engineering_lead',
});
```

<ParamField path="userId" type="string" required>
  Your primary key for the user. Used as `external_user_id` server-side. Stable across re-identifies.
</ParamField>

<ParamField path="traits" type="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.

  <Expandable title="IdentifyTraits shape">
    ```ts theme={null}
    interface IdentifyTraits {
      email?: string;
      name?: string;
      plan?: string;
      signupDate?: string;
      company?: string;
      role?: string;
      [k: string]: unknown;
    }
    ```
  </Expandable>
</ParamField>

**Returns:** `Promise<void>`. Throws on validation / network / auth failure — see [Errors](/sdk/errors).

## `identifyBulk(records)`

Upserts up to **1,000 users** in a single POST. The backfill counterpart to `identify()` — pair with [`chunk()`](#backfilling-more-than-1-000-users) for larger imports.

```ts theme={null}
const result = await vf.users.identifyBulk([
  { external_user_id: 'usr_42', email: 'jane@acme.io', traits: { plan: 'pro' } },
  { external_user_id: 'usr_43', email: 'noel@acme.io', traits: { plan: 'trial' } },
]);

console.log(result.data.accepted); // 2
console.log(result.data.errors); // []
```

<ParamField path="records" type="BulkUserRecord[]" required>
  Array of user records. Empty arrays short-circuit (no network call); arrays larger than 1,000 throw `RangeError`.

  <Expandable title="BulkUserRecord shape">
    ```ts theme={null}
    interface BulkUserRecord {
      external_user_id: string;   // required
      email?: string;
      timezone?: string;
      traits?: IdentifyTraits;
    }
    ```
  </Expandable>
</ParamField>

**Returns:** `Promise<BulkIngestResponse>`. Records are validated and queued **independently** — a single bad row doesn't sink the batch.

<Expandable title="BulkIngestResponse shape">
  ```ts theme={null}
  interface BulkIngestResponse {
    data: {
      accepted: number;            // records queued
      rejected: number;            // records that failed validation
      errors: BulkIngestError[];   // per-index validation failures
    };
    meta: { received: number };    // total records in the request
  }

  interface BulkIngestError {
    index: number;     // position in the input array
    code: string;      // e.g. "validation"
    message: string;
    field?: string;    // e.g. "email"
  }
  ```
</Expandable>

<Note>
  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_id`s become updates, not new rows.
</Note>

### Backfilling more than 1,000 users

Pair with the exported `chunk()` helper:

```ts theme={null}
import { VibeFollow, chunk } from '@vibefollow/sdk';

const vf = new VibeFollow({ apiKey: process.env.VIBEFOLLOW_API_KEY! });

for (const slice of chunk(allUsers, 1000)) {
  const result = await vf.users.identifyBulk(slice);
  if (result.data.errors.length > 0) {
    console.warn('skipped', result.data.errors);
  }
}
```

`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:

```ts theme={null}
import { RateLimitError } from '@vibefollow/sdk';

for (const slice of chunk(allUsers, 1000)) {
  try {
    await vf.users.identifyBulk(slice);
  } catch (err) {
    if (err instanceof RateLimitError) {
      await new Promise((r) => setTimeout(r, err.retryAfterMs ?? 5_000));
      await vf.users.identifyBulk(slice); // safe — idempotent
      continue;
    }
    throw err;
  }
}
```

## 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`.

```ts theme={null}
await vf.users.signedUp('usr_42', {
  plan: 'trial',
  source: 'organic',
});
```

<ParamField path="userId" type="string" required>
  External user ID.
</ParamField>

<ParamField path="properties.plan" type="string">
  Plan the user signed up on (`trial`, `pro`, `enterprise`).
</ParamField>

<ParamField path="properties.source" type="string">
  Marketing source (`organic`, `referral`, `ads`, …).
</ParamField>

### `trialStarted(userId, properties?)`

Emits `trial_started`.

```ts theme={null}
await vf.users.trialStarted('usr_42', { trialDays: 14 });
```

<ParamField path="properties.trialDays" type="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.
</ParamField>

### `featureUsed(userId, properties)`

Emits `feature_used`. `feature` is required.

```ts theme={null}
await vf.users.featureUsed('usr_42', {
  feature: 'dashboard_export',
  count: 1,
});
```

<ParamField path="properties.feature" type="string" required>
  Stable feature identifier (snake\_case recommended).
</ParamField>

<ParamField path="properties.count" type="number">
  Times used in this interaction. Default `1` server-side.
</ParamField>

### `onboardingStep(userId, properties)`

Emits `onboarding_step`. `step` is required.

```ts theme={null}
await vf.users.onboardingStep('usr_42', {
  step: 'connect_integration',
  completed: true,
});
```

<ParamField path="properties.step" type="string" required>
  Onboarding step identifier.
</ParamField>

<ParamField path="properties.completed" type="boolean">
  Whether this step was just completed (`true`) or merely started (`false`).
</ParamField>

### `subscriptionChanged(userId, properties)`

Emits `subscription_changed`. `to` is required. `interval` is denormalised onto the user row for audience filters.

```ts theme={null}
await vf.users.subscriptionChanged('usr_42', {
  from: 'trial',
  to: 'pro',
  interval: 'yearly',
  mrr: 9900,
});
```

<ParamField path="properties.from" type="string">
  Previous plan.
</ParamField>

<ParamField path="properties.to" type="string" required>
  New plan.
</ParamField>

<ParamField path="properties.interval" type="'monthly' | 'yearly' | 'lifetime'">
  Billing interval.
</ParamField>

<ParamField path="properties.mrr" type="number">
  Monthly recurring revenue, **in cents**. `9900` = `$99/mo`.
</ParamField>

### `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).

```ts theme={null}
await vf.users.subscriptionCancelled('usr_42', {
  reason: 'too_expensive',
  effectiveAt: '2026-06-01T00:00:00Z',
});
```

<ParamField path="properties.reason" type="string">
  Free-text cancellation reason.
</ParamField>

<ParamField path="properties.effectiveAt" type="string">
  ISO 8601 timestamp at which access actually ends. Defaults to "now" server-side if omitted.
</ParamField>

### `paymentFailed(userId, properties?)`

Emits `payment_failed`.

```ts theme={null}
await vf.users.paymentFailed('usr_42', { reason: 'card_declined' });
```

<ParamField path="properties.reason" type="string">
  Decline reason from your billing system (`card_declined`, `insufficient_funds`, …).
</ParamField>

### `trialExpiring(userId, properties)`

Emits `trial_expiring`. `trialDays` is required.

<Note>
  You usually don't need this. The `trial_expiring` trigger is **derived automatically** from the most recent `trial_started` event's `trialDays` — call [`trialStarted`](#trialstarted-userid-properties) 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.
</Note>

```ts theme={null}
await vf.users.trialExpiring('usr_42', { trialDays: 3 });
```

<ParamField path="properties.trialDays" type="number" required>
  Integer number of days left in the trial.
</ParamField>

### `userInvited(userId, properties)`

Emits `user_invited`. `invitedEmail` is required.

```ts theme={null}
await vf.users.userInvited('usr_42', {
  invitedEmail: 'colleague@acme.io',
});
```

<ParamField path="properties.invitedEmail" type="string" required>
  Email address that received the invitation.
</ParamField>

### `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%).

```ts theme={null}
await vf.users.usageThresholdReached('usr_42', {
  usagePercent: 85,
  meter: 'seats',
  limit: 10,
});
```

<ParamField path="userId" type="string" required>
  External user ID.
</ParamField>

<ParamField path="properties.usagePercent" type="number" required>
  Current usage as a percentage of the plan limit (`85` = 85%).
</ParamField>

<ParamField path="properties.meter" type="string">
  Optional free-form label so audience rules can target a specific meter (`seats`, `api_calls`, …).
</ParamField>

<ParamField path="properties.limit" type="number">
  Absolute cap, if known.
</ParamField>

## Why typed helpers vs `events.track()`

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

<CardGroup cols={3}>
  <Card title="Typo protection" icon="spell-check">
    `signedUp` autocompletes; `siignedUp` is a type error.
  </Card>

  <Card title="Property shapes" icon="brackets-curly">
    Validated by TypeScript at the call site, not just server-side.
  </Card>

  <Card title="Discoverable" icon="magnifying-glass">
    `vf.users.<dot>` lists the canonical set in IDE autocomplete.
  </Card>
</CardGroup>

For anything outside the canonical 10, fall back to [`events.track()`](/sdk/resources/events#track).
