osrs-data-hub

Build your own

Read the hub from a script or a Discord bot - connection test, snapshot polling and the events cursor.

Anything that can send an HTTP request can read the hub. This page builds a small watcher that prints who's online and every drop worth over 1M: the core of a Discord drop-feed bot.

Try it without a hub

Every endpoint is in the API reference, with example responses and a playground you can point at your own hub.

1. Get a key

Create a personal key on API keys with the categories activity and events (see API keys). For a bot the whole guild uses, ask an admin for an integration key instead.

2. Test the connection

export HUB_URL=https://hub.example.com
export HUB_API_KEY=ohub_NM5kHo1WTn_…

curl -s -H "Authorization: Bearer $HUB_API_KEY" "$HUB_URL/api/v1/me"
{"data":{"key":{"id":"01a0ed84-…","kind":"user","name":"Drop bot","prefix":"NM5kHo1WTn",
 "categories":["events","activity"],"account_scope":"all_visible","rate_limit_per_minute":120,"expires_at":null},
 "user":{"name":"Owner"},"visible_accounts":2},"meta":{"generated_at":"2026-09-29T14:14:12.330Z"}}

A 401 unauthorized means the key is missing, wrong, revoked or expired, or its creator left the guild. The hub gives the same answer for all of these on purpose.

3. Poll the snapshot

/snapshot returns every account the key can see in one response. It is built for polling every 2–10 seconds, and allows 1 request per second per key.

Send the previous response's ETag back as If-None-Match. When nothing changed, the hub answers 304 Not Modified with no body.

curl -si -H "Authorization: Bearer $HUB_API_KEY" "$HUB_URL/api/v1/snapshot" | grep -i etag
# ETag: W/"iJSsXZ1wy4SjrMLJMHceEMf2oLc"

curl -si -H "Authorization: Bearer $HUB_API_KEY" -H 'If-None-Match: W/"iJSsXZ1wy4SjrMLJMHceEMf2oLc"'   "$HUB_URL/api/v1/snapshot" | head -1
# HTTP/1.1 304 Not Modified

With many accounts, pass the previous meta.last_modified as since to get only the accounts that changed after it, and merge them by id. Fetch without since now and then, so accounts that left the key's reach disappear.

4. Follow the events cursor

/events is a cursor feed: you never miss an event and never see one twice.

  1. Start with cursor=now. You get no events, just meta.next_cursor, meaning "everything from now on".
  2. Always pass the previous meta.next_cursor. You get the events stored after it, oldest first, and a new cursor.
  3. Fewer than limit events (default 100) means you're caught up. Poll again later with the same cursor.

Filter with types, accounts and min_value. The cursor still moves past events your filters leave out. Events arrive 10–15 seconds after the hub received them: the feed holds back events whose write may still be committing, so it can never skip one.

5. Put it together

A complete watcher for Node 22 or newer, with no dependencies:

hub-watch.mjs
// hub-watch.mjs: print who's online and every big drop. Node 22+, no dependencies.
//   HUB_URL=https://hub.example.com HUB_API_KEY=ohub_... node hub-watch.mjs
const HUB = `${process.env.HUB_URL}/api/v1`;
const HEADERS = { Authorization: `Bearer ${process.env.HUB_API_KEY}`, Accept: 'application/json' };
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

/** GET a path, waiting out 429 and 503 as the hub asks (Retry-After is in seconds). */
async function get(path, headers = {}) {
  for (;;) {
    const res = await fetch(`${HUB}${path}`, { headers: { ...HEADERS, ...headers } });
    if (res.status !== 429 && res.status !== 503) return res;
    await sleep(Number(res.headers.get('Retry-After') ?? 5) * 1000);
  }
}

// 1. Connection test: what can this key read?
const me = await (await get('/me')).json();
if (!me.data) throw new Error(`/me failed: ${JSON.stringify(me.error)}`);
console.log(`Key "${me.data.key.name}" sees ${me.data.visible_accounts} accounts`);

// 2. Poll /snapshot every 5 s. A 304 means nothing changed since the ETag we sent.
let etag;
async function pollSnapshot() {
  const res = await get('/snapshot', etag ? { 'If-None-Match': etag } : {});
  if (res.status === 304) return;
  etag = res.headers.get('ETag') ?? undefined;
  const { data } = await res.json();
  const online = data.filter((a) => a.online).map((a) => `${a.name} (w${a.world})`);
  console.log(`Online: ${online.join(', ') || 'nobody'}`);
}

// 3. Follow the events cursor from "now" on. Store the cursor if you want to resume after a restart.
let cursor = 'now';
async function pollEvents() {
  const res = await get(`/events?cursor=${cursor}&types=loot,pk_loot&min_value=1000000`);
  const { data, meta } = await res.json();
  for (const event of data) console.log(`Drop: ${event.line}`); // post to Discord here
  cursor = meta.next_cursor;
}

for (;;) {
  await pollSnapshot();
  await pollEvents();
  await sleep(5000);
}
HUB_URL=https://hub.example.com HUB_API_KEY=ohub_… node hub-watch.mjs
Key "Drop bot" sees 2 accounts
Online: Alpha Main (w302)
Drop: Alpha Main received Armadyl chestplate (35.2M) from Kree'arra

To turn it into a Discord bot, post event.line to a channel instead of printing it, and store the cursor so a restart picks up where it left off.

Good manners

  • Respect Retry-After. A 429 rate_limited or 503 unavailable carries it, in whole seconds.
  • Watch your budget. Every response carries X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (seconds until a request frees up). A personal key gets 120 requests a minute.
  • Ignore fields you don't know. v1 only changes additively; new fields and endpoints may appear.
  • Browsers can call the API directly. Every response allows any origin (CORS), but a key in a web page is visible to everyone who opens it. Keep keys on a server unless the page is only for you.

See API overview for the conventions, errors and limits in full.

On this page