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

# Custom events

> Track anything outside the canonical 10 with `events.track()` and `events.batch()`.

For events that aren't part of the [canonical lifecycle set](/events/lifecycle-events), use `events.track()`. The name is accepted as-is — the backend validates only that it's a non-empty string.

## Single events

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

The first argument is the event name. The second is the external user ID. The third is an optional properties bag — anything JSON-serialisable.

<Note>
  The 10 canonical lifecycle names work through `track()` as well, but prefer the typed helpers in `vf.users.*` for autocomplete and type safety.
</Note>

### Naming conventions

<AccordionGroup>
  <Accordion title="Event names: snake_case" icon="font">
    Use `dashboard_created`, not `DashboardCreated`. Stable across audience filters and triggers.
  </Accordion>

  <Accordion title="Use stable identifiers, not labels" icon="anchor">
    Renaming an event breaks any audience filter that already references the old name. Treat event names like database column names — pick once, live with it.
  </Accordion>

  <Accordion title="Properties: camelCase keys" icon="braces">
    Use `workspaceId`, not `workspace_id`. The backend normalises both to the same column, but consistency in your own code is worth holding the line on.
  </Accordion>
</AccordionGroup>

## Batch tracking

For high-volume work — backfilling historical events, syncing from a data warehouse, replaying a Segment export — use `events.batch()` to amortise HTTP overhead:

```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);
}

// Drain on shutdown.
await batch.flush();
```

The batch auto-flushes when:

* The queue reaches `maxSize` events (default `100`), or
* `maxAgeMs` has elapsed since the first queued event (default `5000`), or
* You call `flush()` explicitly.

<Warning>
  Always `await batch.flush()` before process exit. Lambda-style runtimes that freeze the event loop will lose any queue still in memory.
</Warning>

## Trade-offs

|              | `events.track()`           | `events.batch()`                        |
| ------------ | -------------------------- | --------------------------------------- |
| Latency      | Per-event POST             | Buffered, drains in one POST            |
| Failure mode | Single event fails         | Full batch retried                      |
| Use when     | Real-time interactive flow | Backfills, sync jobs, write-heavy paths |

<Info>
  Batches are not idempotent across drains — if the POST fails midway through retries, the batch may be partially accepted server-side. Re-emit conservatively or set a retention-policy filter on your end to dedupe by event ID.
</Info>

## Reserved names

<Note>
  Reserved-name routing is an API-side concern, not the SDK's. Custom names that happen to collide with a future reserved name will be rejected at validation with `ValidationError` — you'll know immediately.
</Note>

The current reserved set is the 10 canonical lifecycle names. Treat anything Vibefollow ships with as reserved.
