Skip to main content
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).
eventName
string
required
Event name. Snake_case recommended. The backend validates only that it’s a non-empty string — reserved-name collisions return ValidationError.
userId
string
required
External user ID.
properties
EventProperties
JSON-serialisable property bag. Defaults to {}.
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.
options.maxSize
number
default:"100"
Auto-flush when the queue reaches this size.
options.maxAgeMs
number
default:"5000"
Auto-flush when this many milliseconds have elapsed since the first queued event.
options.suppressTriggers
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.

Seeding historical users

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.

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

bulk(records)

Send up to 1,000 events in a single POST — the one-shot counterpart to batch()’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.
records
BulkEventRecord[]
required
Array of event records. Empty arrays short-circuit; arrays larger than 1,000 throw RangeError.
Returns: Promise<BulkIngestResponse>. Per-record validation errors land in data.errors[] with the original array index; the rest are queued.
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.

Backfilling more than 1,000 events

Same chunk() pattern as users.identifyBulk():

Trade-offs

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

Failure modes

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.
Throws — the queue is already drained at this point. Re-enqueueing on failure would risk duplicates.
Synchronously triggers flush(); the next track() after that just enqueues normally.
In-memory queue is lost. Always await flush() in a shutdown handler.