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).
Event name. Snake_case recommended. The backend validates only that it’s a non-empty string — reserved-name collisions return
ValidationError.External user ID.
JSON-serialisable property bag. Defaults to
{}.Promise<void>.
batch(options?)
Returns an EventBatch — an in-memory buffer that flushes in one POST. Useful for backfills and high-volume write paths.
Auto-flush when the queue reaches this size.
Auto-flush when this many milliseconds have elapsed since the first queued event.
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.
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.
Array of event records. Empty arrays short-circuit; arrays larger than 1,000 throw
RangeError.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
Samechunk() 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
Single track() fails (NetworkError, ServerError, RateLimitError)
Single track() fails (NetworkError, ServerError, RateLimitError)
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.batch.flush() fails
batch.flush() fails
Throws — the queue is already drained at this point. Re-enqueueing on failure would risk duplicates.
batch.track() overflows
batch.track() overflows
Synchronously triggers
flush(); the next track() after that just enqueues normally.Process exits before flush()
Process exits before flush()
In-memory queue is lost. Always
await flush() in a shutdown handler.