Putting a roster on your website

Everything you need to render a team page from RosterShots. If you’d rather hand this to an AI assistant, one copy grabs the whole guide as markdown.

What you get

A roster is a team: its name, the fields the team decided to show (title, pronouns, whatever they set up), and the people on it with their headshots. You read it as JSON and render it however your site already renders things. There's nothing to install.

The roster's owner can find their roster ID on the Connect screen in RosterShots. It's the same ID in both URLs below.

Two ways to read it

Both return exactly the same JSON. Pick based on where your site does its rendering.

The feed - a file on our CDN
https://cdn.rostershots.app/rosters/<ROSTER_ID>/feed.json

Use this one for a static site, or when you're fetching from the browser. It's a plain file behind a CDN, it's CORS-open, and it costs us nothing to serve, so hammer it as much as you like. It gets rewritten and the CDN gets purged every time the team changes something, so it's never more than a few seconds behind.

The API - read it live from us
https://www.rostershots.app/api/v1/rosters/<ROSTER_ID>

Use this one when your site renders on a server. It reads the database directly, so it's current to the second, and it sends a weak ETag. From a server, send it back as If-None-Match and you'll get a 304 with no body when nothing has changed.

From a browser you don't need to do any of that - we send Cache-Control: must-revalidate, so the browser handles revalidation itself and you get the same 304 for free. Don't set If-None-Match by hand in browser code: it's not a CORS-safelisted header, so it turns your request into a preflighted one, and we can't answer preflights.

There's no API key

You don't authenticate, and there's nothing to put in a secrets manager. A roster is public data - it's the team page on somebody's website - and the CDN feed is already world-readable, so a key on the API would protect nothing while giving you one more thing to rotate.

Roster IDs are random UUIDs, so they're not guessable, but treat one as public rather than secret.

The payload

Example response
{
  "version": 1,
  "generated_at": "2026-08-06T14:22:09.418Z",
  "roster": {
    "id": "6f1a0b3e-6a2a-4c0e-9e3a-1b2c3d4e5f60",
    "name": "Acme Dental",
    "fields": [{ "key": "title", "label": "Title" }]
  },
  "members": [
    {
      "id": "0b9c8d7e-6f5a-4b3c-8d2e-1f0a9b8c7d6e",
      "display_name": "Dana Whitlock",
      "photo_url": "https://cdn.rostershots.app/rosters/6f1a.../headshots/0b9c-4a17f2.webp",
      "sort_order": 0,
      "fields": { "title": "Hygienist" }
    }
  ]
}
FieldTypeMeaning
version1Contract version. Additive changes do not bump it.
generated_atstringISO 8601 UTC timestamp of the moment this payload was built.
rosterobjectThe team itself, as distinct from the people on it.
roster.idstringStable public id of the roster.
roster.namestringThe team's name.
roster.fieldsarrayThe roster's custom field definitions - the key set that `members[].fields` draws from.
roster.fields[].keystringStable machine key for the field. It never changes once published.
roster.fields[].labelstringHuman label to render as the field's display name.
membersarrayThe team, already in display order. Hidden members are omitted entirely.
members[].idstringStable public id for this teammate. Never reused, even after they're removed.
members[].display_namestringThe teammate's name, exactly as it should appear on your page.
members[].photo_urlstring or nullAbsolute CDN URL of the live headshot, or null when this teammate hasn't picked one yet (render your own placeholder avatar). The URL changes whenever the selection changes, so it is safe to cache forever - key your cache on the URL itself. Every headshot in a roster is cut to the same shape, so a grid of them lines up.
members[].sort_orderintegerDisplay order. Members already arrive sorted by this; it's carried for consumers that re-merge.
members[].fieldsobjectValues for the roster's custom fields, keyed by field key. Always strings.

members[].fields is keyed by the key values in roster.fields. The team decides what those are, so read the labels from roster.fields rather than hardcoding them - a team that renames "Title" to "Role" keeps the same key and only changes the label.

Rendering it

Nothing clever here, and that's sort of the point:

Vanilla JS
const res = await fetch("https://www.rostershots.app/api/v1/rosters/<ROSTER_ID>");
if (!res.ok) throw new Error(`Roster fetch failed: ${res.status}`);
const payload = await res.json();

// Names and field values are text, not markup - escape them before innerHTML.
const esc = (s) => s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]);

document.querySelector("#team").innerHTML = payload.members
  .map(
    (m) => `
      <figure>
        <img src="${m.photo_url ?? "/placeholder-avatar.svg"}" alt="${esc(m.display_name)}" loading="lazy">
        <figcaption>
          <strong>${esc(m.display_name)}</strong>
          ${payload.roster.fields.map((f) => `<span>${esc(m.fields[f.key] ?? "")}</span>`).join("")}
        </figcaption>
      </figure>`
  )
  .join("");

What we promise won't change

The payload is additive-only. We will never rename a field, remove one, or change its type. When we add something, version stays at 1.

The thing we need from you in return: ignore keys you don't recognise. If your code errors on an unexpected field, it will break the first time we ship a new one. The JSON Schema below is deliberately open for the same reason - validating against it won't start failing when the payload grows.

JSON Schema
https://www.rostershots.app/api/v1/schema.json

Types for TypeScript

If your project is TypeScript, the payload's types are one plain file:

TypeScript types - vendor this file
https://www.rostershots.app/api/v1/types.d.ts

Save it into your project and import from it. There's no npm package to install or keep updated, and that's on purpose - because the payload is additive-only, a copy you vendored a year ago still typechecks. The worst it can be is missing a field we added since, and re-fetching the file fixes that.

The types can't tell you a roster's custom field keys, so members[].fields is typed as an open map of strings - the actual keys are in roster.fields.

When something goes wrong

The API answers with JSON on failures too, so you never have to parse an HTML error page:

404
{
  "error": {
    "code": "roster_not_found",
    "message": "No roster with that id. Check the id in your integration settings."
  }
}

The CDN feed is a bucket object rather than one of our routes, so a roster that has never published answers with an S3 error in XML instead. If you hit that, the roster's owner needs to open the Connect screen in RosterShots and press Publish. It's worth handling anyway: fall back to your placeholder markup rather than letting a team page fail to render.