Scorsync SCORSYNC

Partner API: Live data

Use ordered REST polling on its own, or subscribe to private Reverb presence channels for quick notifications and recover through the same change feed. Partner data covers T&T events TI, DM, TU and TS.

Polling and recovery

GET /api/partner/v1/meets/{meet}/changes?since=0&limit=100 requires results:read. Meet visibility and key meet locks apply. since is a non-negative sequence (default 0); limit is 1–500 (default 100).

{"data":[{"seq":123,"entity":"score","op":"delete","start_list_id":42,"event":"TI","round":"prelims","routine_num":1,"flight_id":7,"payload":{"...":"score as it was before removal"},"created_at":"2026-11-05T12:00:00.000000Z"}],"meta":{"next_since":null,"latest_seq":123}}

Apply rows in ascending seq. Follow meta.next_since while non-null; after the last page retain meta.latest_seq for your next poll. Sequences are global, so other meets can cause numeric gaps; a gap is a reason to check the feed, not proof of missing meet data.

Delivery is at least once. Clients must deduplicate on seq, including replay after a reconnect. Keep a cursor per meet and serialize recovery so an older request cannot overwrite newer state. A push notification is not an acknowledgement that you have read all earlier changes.

History is retained for 30 days by default. HTTP 410 cursor_expired means reload the snapshot. It occurs when since is below that meet’s pruning watermark, including when no retained rows remain. Restarting at zero can still return 410.

For initial load or recovery, first capture meta.latest_seq with ?since=9223372036854775807&limit=1 (the maximum accepted cursor, yielding no rows). Then reload all pages of the snapshot endpoints you display, and poll from the captured sequence. Writes during the reload are replayed as idempotent upserts/deletes. The example below reloads routine scores; overlays showing start lists, standings or flight state must reload those snapshots too.

Events and payloads

Every socket event has the envelope {v:1, seq, entity, op, start_list_id, event, round, routine_num, flight_id, payload, created_at}. Polling returns the same fields without v. Nullable identity fields remain null when not applicable. The slot identity is (start_list_id, event, round, routine_num); score IDs alone are not unique across event tables.

EventMeaning
score.postedentity: score, op: upsert; first score for a slot, or a re-score after withdrawal. payload is a competition-tier score object.
score.updatedAnother upsert for an existing slot, including component corrections. Replace that slot’s score.
score.deletedop: delete; payload is the score as it was before removal. Remove the slot identified by the envelope.
startlist.updatedentity: start_list; payload {flight_id, start_list_ids} lists ordered unscratched entries. Reload the flight start-list snapshot for full entry details.
flight.stateentity: flight_state; payload is the flight state object.

Score payloads contain competition identity and decimal-string components, not profile enrichment or flashed_at. An athlete’s profile scope does not expand the event payload. See the API reference for score fields.

Presence subscriptions

Subscribe to presence-partner.meet.{id} using pusher-js. It POSTs socket_id and channel_name as form fields to /api/partner/v1/live/auth with your bearer header. Auth requires live:subscribe and results:read, meet visibility and the key’s meet lock. There is no separate sign-in endpoint. Presence member events can be ignored; member IDs are opaque per socket and user_info is empty.

A full socket cap returns HTTP 429 socket_limit. Reuse one Pusher instance for multiple meet subscriptions, close unused sockets and retry with backoff. Pending reservations may count for 120 seconds. Existing sockets are closed on key revocation, project suspension/revocation, a lapsed subscription, or when the meet becomes private or hidden; expired keys and entitlement expiry are also checked before publication. The kill switch blocks auth with 503 partner_api_disabled and stops publishing. Do not repeatedly reconnect on denied access.

OBS browser source example

Save this HTML as an overlay page and set your numeric meet ID and publishable key. Grant results:read and live:subscribe. Public app key, host, port and TLS below come from this installation’s Reverb configuration. The example displays routine scores as JSON; replace the rendering with your overlay design and keep attribution visible.

<!doctype html>
<meta charset="utf-8">
<pre id="scores">Loading…</pre>
<p id="status"></p>
<a href="https://scorsync.com">Powered by Scorsync</a>
<script src="https://js.pusher.com/8.4.0/pusher.min.js"></script>
<script>
const base = 'https://scorsync.com/index.php/api/partner/v1';
const meetId = 123;
const headers = { Authorization: 'Bearer ss_pk_REPLACE_ME', Accept: 'application/json' };
const appKey = 'NIrIqFvCOpQCOEvx2vH3';
const pusher = new Pusher(appKey, {
  wsHost: 'ws-a0c729e2-13e6-415b-a52c-ad2b7c53e34a-reverb.laravel.cloud',
  wsPort: 443,
  wssPort: 443,
  forceTLS: true,
  enabledTransports: ['ws', 'wss'],
  cluster: '',
  channelAuthorization: { endpoint: base + '/live/auth', headers }
});
const scores = new Map();
let since = null;
let recovery = Promise.resolve();
const slot = row => JSON.stringify([row.start_list_id, row.event, row.round, row.routine_num]);
const render = () => {
  document.getElementById('scores').textContent = JSON.stringify([...scores.values()], null, 2);
};
async function get(path) {
  const response = await fetch(base + path, { headers, credentials: 'omit' });
  const body = await response.json();
  if (!response.ok) throw Object.assign(new Error(body.error.message), { code: body.error.code });
  return body;
}
async function reloadSnapshot() {
  const head = await get(`/meets/${meetId}/changes?since=9223372036854775807&limit=1`);
  const fresh = new Map();
  let cursor = null;
  do {
    const page = await get(`/meets/${meetId}/scores?limit=100` +
      (cursor ? '&cursor=' + encodeURIComponent(cursor) : ''));
    page.data.forEach(row => fresh.set(slot(row), row));
    cursor = page.meta.next_cursor;
  } while (cursor);
  scores.clear();
  fresh.forEach((row, key) => scores.set(key, row));
  since = head.meta.latest_seq;
  render();
}
async function catchUp() {
  if (since === null) await reloadSnapshot();
  let page;
  do {
    try {
      page = await get(`/meets/${meetId}/changes?since=${since}&limit=500`);
    } catch (error) {
      if (error.code !== 'cursor_expired') throw error;
      await reloadSnapshot();
      continue;
    }
    for (const change of page.data) {
      if (change.seq <= since) continue; // Deduplicate at-least-once replay.
      if (change.entity === 'score') {
        if (change.op === 'delete') scores.delete(slot(change));
        else scores.set(slot(change), change.payload);
      }
      since = change.seq;
    }
    render();
    since = page.meta.next_since ?? page.meta.latest_seq;
  } while (!page || page.meta.next_since !== null);
}
function recover() {
  recovery = recovery.then(catchUp).catch(error => {
    document.getElementById('status').textContent = error.message;
  });
}
const channel = pusher.subscribe(`presence-partner.meet.${meetId}`);
channel.bind('pusher:subscription_succeeded', recover); // Also catches reconnect gaps.
channel.bind('pusher:subscription_error', error => {
  document.getElementById('status').textContent = `Subscription denied (${error.status}); check key, scopes and socket cap.`;
});
['score.posted', 'score.updated', 'score.deleted', 'startlist.updated', 'flight.state']
  .forEach(name => channel.bind(name, change => {
    // Read the ordered feed before advancing; this fills sequence jumps too.
    if (change.v === 1 && (since === null || change.seq > since)) recover();
  }));
recover();
setInterval(recover, 15000); // Poll fallback also covers a missed final notification.
window.addEventListener('pagehide', () => pusher.disconnect());
</script>

This conservative example reads the feed for each notification rather than applying socket messages ahead of replay. Poll-only clients can omit Pusher and use the same recovery loop. Honor rate-limit Retry-After and use backoff in production; pause on revoked, suspended or subscription-inactive access. Start-list and flight-state notifications are bound here but do not alter this score-only display.

Attribution (required)

Display “Powered by Scorsync”, visible and legible wherever Scorsync data is shown. For overlays, keep it on screen while data is shown. Link to https://scorsync.com where the medium allows.