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

# events

> Generic event tracking, in-memory batching, and bulk backfills.

The `vf.events` resource handles custom event tracking, in-memory batching for live writes, and one-shot bulk for backfills. Use it for anything outside the canonical 10 lifecycle events.

## `track(eventName, userId, properties?)`

Emit a single event. Idempotent on the server when the auto-generated `Idempotency-Key` is preserved across retries (handled by the client).

```ts theme={null}
await vf.events.track('dashboard_created', 'usr_42', {
  source: 'wizard',
  workspaceId: 'ws_3',
});
```

<ParamField path="eventName" type="string" required>
  Event name. Snake\_case recommended. The backend validates only that it's a non-empty string — reserved-name collisions return `ValidationError`.
</ParamField>

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

<ParamField path="properties" type="EventProperties">
  JSON-serialisable property bag. Defaults to `{}`.

  <Expandable title="EventProperties shape">
    ```ts theme={null}
    type EventProperties = Record<string, unknown>;
    ```
  </Expandable>
</ParamField>

**Returns:** `Promise<void>`.

## `batch(options?)`

Returns an `EventBatch` — an in-memory buffer that flushes in one POST. Useful for backfills and high-volume write paths.

```ts theme={null}
const batch = vf.events.batch({
  maxSize: 100,
  maxAgeMs: 5_000,
});

for (const row of historicalEvents) {
  batch.track(row.name, row.userId, row.properties);
}

await batch.flush();
```

<ParamField path="options.maxSize" type="number" default="100">
  Auto-flush when the queue reaches this size.
</ParamField>

<ParamField path="options.maxAgeMs" type="number" default="5000">
  Auto-flush when this many milliseconds have elapsed since the first queued event.
</ParamField>

<ParamField path="options.suppressTriggers" type="boolean" default="false">
  When `true`, every user touched by the batch is marked as **historical** — the server sets `suppressTriggersUntil` on each `TrackedUser` so the trigger-rules cron skips them. Use it when seeding your existing user base into Vibefollow so customers don't get a "welcome" email months after they actually signed up. The watermark dissolves automatically the first time the user emits a normal (non-suppressed) event, so flipping back to live mode is one config change away.
</ParamField>

### Seeding historical users

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

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

const backfill = vf.events.batch({
  maxSize: 100,
  maxAgeMs: 5_000,
  suppressTriggers: true,
});

for (const row of existingUsers) {
  backfill.track('user_signed_up', row.id, { signedUpAt: row.createdAt });
}

await backfill.flush();

// Live signups go through a normal batch (or events.track) and DO fire
// welcomes as expected — suppression is per-batch, not per-project.
const live = vf.events.batch();
live.track('user_signed_up', 'usr_new', {});
await live.flush();
```

## `EventBatch`

### `batch.track(eventName, userId, properties?)`

Enqueue an event. Same shape as `events.track()`, but synchronous — returns `void`. Auto-flushes when the buffer fills or the age timer fires.

```ts theme={null}
batch.track('dashboard_created', 'usr_42', { source: 'wizard' });
```

### `batch.flush()`

Drain the queue and POST to `/api/v1/events/batch`. No-op when empty. Always `await` — the returned promise resolves only after the network round-trip.

```ts theme={null}
await batch.flush();
```

<Warning>
  Always `await batch.flush()` before process exit. Lambda-style runtimes that freeze the event loop will lose any queue still in memory. Long-running servers should also call `flush()` in a shutdown hook (`SIGTERM` handler).
</Warning>

## `bulk(records)`

Send up to **1,000 events** in a single POST — the one-shot counterpart to [`batch()`](#batch-options)'s in-memory buffer. Use `bulk()` when you already have the full array (CSV row, DB query, data warehouse export) and don't want the timing/flushing semantics of `EventBatch`.

```ts theme={null}
const result = await vf.events.bulk([
  {
    external_user_id: 'usr_42',
    name: 'user_signed_up',
    timestamp: '2024-01-15T10:00:00Z',
    properties: { plan: 'trial' },
  },
  {
    external_user_id: 'usr_42',
    name: 'trial_started',
    timestamp: '2024-01-15T10:00:05Z',
    properties: { trialDays: 14 },
  },
]);

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

<ParamField path="records" type="BulkEventRecord[]" required>
  Array of event records. Empty arrays short-circuit; arrays larger than 1,000 throw `RangeError`.

  <Expandable title="BulkEventRecord shape">
    ```ts theme={null}
    interface BulkEventRecord {
      external_user_id: string; // required
      name: string;             // required
      timestamp?: string;       // ISO 8601 with offset
      properties?: EventProperties;
    }
    ```
  </Expandable>
</ParamField>

**Returns:** `Promise<BulkIngestResponse>`. Per-record validation errors land in `data.errors[]` with the original array `index`; the rest are queued.

<Note>
  Duplicate `(external_user_id, name, timestamp)` tuples are deduped by the server-side `events_dedupe_unique` index, so any chunk that 429s or 5xxs is **safe to retry** — reimporting the same backfill twice is a no-op for matching rows.
</Note>

### Backfilling more than 1,000 events

Same `chunk()` pattern as [`users.identifyBulk()`](/sdk/resources/users#identifybulk-records):

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

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

for (const slice of chunk(historicalEvents, 1000)) {
  await vf.events.bulk(slice);
}
```

## Trade-offs

| Use case                        | Choose                                                     |
| ------------------------------- | ---------------------------------------------------------- |
| Real-time, interactive          | `events.track()` — per-event POST                          |
| Buffer & auto-flush live writes | `events.batch()` — in-memory queue with timing             |
| One-shot backfill / data export | `events.bulk()` — you already have the array               |
| Stream from data warehouse      | `events.bulk()` paired with `chunk()`                      |
| Webhook fan-out / fan-in        | `events.track()` — small enough that batching adds latency |

<Info>
  Single-event POST is cheap (sub-100ms typical). Don't reach for the batch API unless you're moving thousands of events.
</Info>

## Failure modes

<AccordionGroup>
  <Accordion title="Single track() fails (NetworkError, ServerError, RateLimitError)" icon="rotate">
    Auto-retried up to `maxRetries`. Throws on exhaustion. The SDK preserves the `Idempotency-Key` across retries so duplicates are deduped server-side within 24 hours.
  </Accordion>

  <Accordion title="batch.flush() fails" icon="triangle-exclamation">
    Throws — the queue is **already drained** at this point. Re-enqueueing on failure would risk duplicates.
  </Accordion>

  <Accordion title="batch.track() overflows" icon="forward">
    Synchronously triggers `flush()`; the next `track()` after that just enqueues normally.
  </Accordion>

  <Accordion title="Process exits before flush()" icon="power-off">
    In-memory queue is lost. Always `await flush()` in a shutdown handler.
  </Accordion>
</AccordionGroup>
