# RosterShots integration guide > How to read a RosterShots team roster and render it on a website. This is the plain-text twin of https://www.rostershots.app/docs — same content, easier to paste into a coding agent. ## 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//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/ ``` 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: ```json { "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" } } ] } ``` | Field | Type | Meaning | | --- | --- | --- | | `version` | 1 | Contract version. Additive changes do not bump it. | | `generated_at` | string | ISO 8601 UTC timestamp of the moment this payload was built. | | `roster` | object | The team itself, as distinct from the people on it. | | `roster.id` | string | Stable public id of the roster. | | `roster.name` | string | The team's name. | | `roster.fields` | array | The roster's custom field definitions - the key set that `members[].fields` draws from. | | `roster.fields[].key` | string | Stable machine key for the field. It never changes once published. | | `roster.fields[].label` | string | Human label to render as the field's display name. | | `members` | array | The team, already in display order. Hidden members are omitted entirely. | | `members[].id` | string | Stable public id for this teammate. Never reused, even after they're removed. | | `members[].display_name` | string | The teammate's name, exactly as it should appear on your page. | | `members[].photo_url` | string or null | Absolute 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_order` | integer | Display order. Members already arrive sorted by this; it's carried for consumers that re-merge. | | `members[].fields` | object | Values 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: ```js const res = await fetch("https://www.rostershots.app/api/v1/rosters/"); 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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]); document.querySelector("#team").innerHTML = payload.members .map( (m) => `
${esc(m.display_name)}
${esc(m.display_name)} ${payload.roster.fields.map((f) => `${esc(m.fields[f.key] ?? "")}`).join("")}
` ) .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: ```json { "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.