Build a Live Visitor Feed
Poll the live visitor count and the events endpoint to build a real-time activity stream — the foundation for a live dashboard, an internal "who's online" widget, or an alert when a key page gets traffic.
Rybbit doesn't push a websocket stream, but the events endpoint is built for polling: pass a since_cursor and you get the events that arrived after it, plus the cursor to send next time. This guide builds a self-advancing feed loop you can wire into a dashboard, a terminal ticker, or an alerting rule.
Endpoints used:
GET /live-user-count— how many visitors are active right nowGET /events— the event stream, polled incrementally
How incremental polling works
Every event in the response has an event_cursor, its exact place in the event log. Cursors are what you page and poll with:
Fetch the initial batch with page_size. Polling starts 5 seconds before the response's cursor.newestCursor, so the first poll gets the same overlap as the ones after it (cursor.oldestCursor is for scrolling back with before_cursor). A cursor is <epoch milliseconds>_<16 hex digits>, so subtract 5000 from the first part and use sixteen zeros for the second.
On an interval, request events with since_cursor set to the cursor you are holding. The response carries cursor.nextCursor and cursor.hasMore.
Process the new events, skipping any whose event_cursor you have already seen, and keep nextCursor for the next poll. If hasMore is true, poll again straight away.
A since_cursor poll reads up to 500 events, starting with the oldest after the cursor, so a backlog bigger than that is read over several polls instead of being skipped (hasMore tells you more are waiting). The events in data are still sorted newest first.
Events are written in small batches, so one can land slightly after a later one. To avoid missing those, a poll never starts later than 5 seconds before the request, and nextCursor points at that overlap. The next poll can therefore return events you already have: drop them by event_cursor.
since_timestamp and before_timestamp still work but are legacy whole-second bounds. They skip events that share the boundary second, and since_timestamp returns the newest 500 events after it, so a larger backlog loses its oldest events. Use cursors for anything that must not miss events.
Full script
A Node script that prints a live count plus a rolling feed of activity. The same loop drives a browser dashboard — just replace console.log with DOM updates.
const API = 'https://app.rybbit.io';
const SITE = '123';
const API_KEY = process.env.RYBBIT_API_KEY;
const POLL_MS = 10000; // every 10 seconds
async function get(path, params = {}) {
const url = new URL(`${API}/api/sites/${SITE}${path}`);
Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
const res = await fetch(url, { headers: { Authorization: `Bearer ${API_KEY}` } });
if (!res.ok) throw new Error(`${path} failed: ${res.status}`);
return res.json();
}
async function liveCount() {
const { count } = await get('/live-user-count', { minutes: 5 });
return count;
}
function describe(e) {
const where = e.city ? `${e.city}, ${e.country}` : e.country || 'Unknown';
if (e.type === 'pageview') return `👀 viewed ${e.pathname} — ${where}`;
if (e.type === 'custom_event') return `⚡ ${e.event_name} (${e.pathname}) — ${where}`;
if (e.type === 'outbound') return `↗️ left via ${e.pathname} — ${where}`;
return `• ${e.type} — ${where}`;
}
let cursor = null; // where the next poll starts
const seen = new Set(); // event_cursor values already shown (polls overlap by a few seconds)
const SEEN_MAX = 5000;
// Timestamps are UTC, formatted "YYYY-MM-DD HH:MM:SS" with no zone marker
const toDate = (ts) => new Date(ts.replace(' ', 'T') + 'Z');
async function poll() {
try {
const polling = cursor !== null; // false for the initial load
const params = polling ? { since_cursor: cursor } : { page_size: 20 };
const [res, count] = await Promise.all([get('/events', params), liveCount()]);
// Events come back newest-first; show oldest-first so the feed reads top-to-bottom
for (const e of [...res.data].reverse()) {
if (seen.has(e.event_cursor)) continue;
seen.add(e.event_cursor);
console.log(`[${toDate(e.timestamp).toLocaleTimeString()}] ${describe(e)}`);
}
// Keep memory bounded by forgetting the oldest cursors; the recent ones cover the overlap
for (const old of seen) {
if (seen.size <= SEEN_MAX) break;
seen.delete(old);
}
if (polling) {
cursor = res.cursor.nextCursor;
} else if (res.cursor.newestCursor) {
// Initial load: start 5 seconds before the newest event, the same overlap
// later polls get (newestCursor is null on a site with no events yet)
const [ms] = res.cursor.newestCursor.split('_');
cursor = `${Number(ms) - 5000}_0000000000000000`;
}
process.stdout.write(`\n🟢 ${count} visitors online\n`);
if (polling && res.cursor.hasMore) return poll(); // backlog: read the next 500 now
} catch (err) {
console.error('poll error:', err.message);
}
}
console.log('Starting live feed… (Ctrl+C to stop)');
poll();
setInterval(poll, POLL_MS);import os, time
from datetime import datetime, timezone
import requests
API = "https://app.rybbit.io"
SITE = "123"
API_KEY = os.environ["RYBBIT_API_KEY"]
POLL_SECONDS = 10
session = requests.Session()
session.headers["Authorization"] = f"Bearer {API_KEY}"
def get(path, **params):
res = session.get(f"{API}/api/sites/{SITE}{path}", params=params)
res.raise_for_status()
return res.json()
def live_count():
return get("/live-user-count", minutes=5)["count"]
def describe(e):
where = f'{e["city"]}, {e["country"]}' if e.get("city") else e.get("country") or "Unknown"
if e["type"] == "pageview":
return f'👀 viewed {e["pathname"]} — {where}'
if e["type"] == "custom_event":
return f'⚡ {e["event_name"]} ({e["pathname"]}) — {where}'
if e["type"] == "outbound":
return f'↗️ left via {e["pathname"]} — {where}'
return f'• {e["type"]} — {where}'
cursor = None # where the next poll starts
seen = {} # event_cursor values already shown, oldest first (polls overlap by a few seconds)
SEEN_MAX = 5000
print("Starting live feed… (Ctrl+C to stop)")
while True:
has_more = False
try:
res = get("/events", **({"since_cursor": cursor} if cursor else {"page_size": 20}))
# Events come back newest-first; show oldest-first so the feed reads top-to-bottom
for e in reversed(res["data"]):
if e["event_cursor"] in seen:
continue
seen[e["event_cursor"]] = True
# Timestamps are UTC, formatted "YYYY-MM-DD HH:MM:SS" with no zone marker
ts = datetime.strptime(e["timestamp"][:19], "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc)
print(f"[{ts.astimezone().strftime('%H:%M:%S')}] {describe(e)}")
# Keep memory bounded by forgetting the oldest cursors; the recent ones cover the overlap
while len(seen) > SEEN_MAX:
del seen[next(iter(seen))]
if cursor:
cursor = res["cursor"]["nextCursor"]
has_more = res["cursor"]["hasMore"]
elif res["cursor"]["newestCursor"]:
# Initial load: start 5 seconds before the newest event, the same overlap
# later polls get (newestCursor is None on a site with no events yet)
ms = int(res["cursor"]["newestCursor"].split("_")[0])
cursor = f"{ms - 5000}_0000000000000000"
print(f"\n🟢 {live_count()} visitors online\n")
except Exception as err:
print(f"poll error: {err}")
if not has_more: # with a backlog, read the next 500 straight away
time.sleep(POLL_SECONDS)Example output
Starting live feed… (Ctrl+C to stop)
[14:22:01] 👀 viewed /pricing — San Francisco, US
[14:22:03] ⚡ signup (/signup) — London, GB
[14:22:05] ↗️ left via /docs — Berlin, DE
🟢 42 visitors onlineTurn it into an alert
Because each poll hands you the raw events, you can act on them instead of just printing. For example, ping a channel whenever someone hits your enterprise pricing page:
// Inside poll(), before the loop that prints new events and adds them to `seen`:
for (const e of res.data) {
if (seen.has(e.event_cursor)) continue; // already handled in an earlier poll
if (e.type === 'pageview' && e.pathname === '/enterprise') {
await fetch(process.env.SLACK_WEBHOOK, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
text: `🏢 Enterprise page visit from ${e.city || e.country} (${e.referrer || 'direct'})`,
}),
});
}
}Mind the daily quota. On Rybbit Cloud, a continuous poll is measured against your plan's daily quota (see Rate Limits), not just its per-second rate. A 10-second poll running around the clock is 8,640 requests/day — over the Standard quota of 5,000, and 17,280 with the live-count call added. Poll every 20-30 seconds, or only while someone is actually watching the page, and check X-RateLimit-Daily-Remaining to see where you stand. Self-hosted instances are not rate limited.
Next steps
- Events API reference — full event object fields, plus
before_cursorfor scrolling backwards through history.
Compare Channel Performance
Rank your acquisition channels by how well they convert — not just how much traffic they send — by combining the metric, overview, and goals endpoints with channel filters.
Sending EventsPOST
Track pageviews, custom events, and other events from any platform using the HTTP API.