Docs/CS2 API/Watch live: embed the CS2 stream

Watch live: embed the CS2 stream

Every broadcast of a match with a ready-to-use iframe URL, the tournament organiser's official stream first: Twitch, YouTube and Kick, with the language and platform for a switcher, like the player on an HLTV match page. Live matches carry the official stream's embed too.

1 endpoint in this section. Authenticate with the x-api-key header; every response is { success, data, meta }. Other sections: Live, Player props, Odds, Positions & heatmaps, Pro skins & 3D, Matches, Maps & rounds, Rankings, Teams, Players, Events, Stats & leaderboards, Skins, Transfers, Search & reference.

Watch live: embed the stream

Put the match broadcast next to your scoreboard, like a match page on HLTV. GET /cs2/matches/{matchId}/streams returns every broadcast of the match with a ready iframe embedUrl, the tournament organiser's official stream first. /cs2/live and /cs2/live/{matchId} carry a short streams block on every match (count, languages, the official stream's embed and a link to the full list), so a live page can show a player without an extra call.

IEM Beijing 2026 · Bo3

Legacy0
The MongolZ0

Scoreboard from /cs2/live, refreshed by polling.

Language

Stream

Real stream list for this match. If the channel is offline right now its player says so; the embed itself is the same during the match.

Twitch needs to know your domain (parent)

Twitch only plays its player inside a page whose hostname is written into the player URL as parent, and only when that page is served over HTTPS. If the hostname is missing or wrong, Twitch shows an error instead of the stream. So tell us where you will embed it: add ?parent=www.example.com to the streams request (just the hostname, no https://, port or path). If your page is reachable on more than one hostname (example.com and www.example.com), list each one, repeated or comma-separated, up to 10.

Without parent, a Twitch stream has embedUrl: null, never a URL that would show an error, plus an embedUrlTemplate containing {parent}. Replace it in the browser with window.location.hostname. YouTube and Kick need no parent: their embedUrl is always set and embedUrlTemplate is the same URL, so embedUrlTemplate.replace("{parent}", location.hostname) works for every stream. The live streams block never has a parent, so it always carries the template.

HTTP
GET /api/v1/cs2/matches/cs2-match-2399855/streams

{
  "platform": "twitch",
  "channel": "eslcs",
  "official": true,
  "embedUrl": null,
  "embedUrlTemplate": "https://player.twitch.tv/?channel=eslcs&parent={parent}",
  "requiresParent": true,
  "embedNote": "Twitch only plays inside pages it can verify: request this endpoint with ?parent=<your page's hostname> ..."
}

GET /api/v1/cs2/matches/cs2-match-2399855/streams?parent=www.example.com

{
  "platform": "twitch",
  "channel": "eslcs",
  "official": true,
  "embedUrl": "https://player.twitch.tv/?channel=eslcs&parent=www.example.com",
  "chatEmbedUrl": "https://www.twitch.tv/embed/eslcs/chat?parent=www.example.com"
}

What each stream carries

  • platform (twitch, youtube, kick, or another site), language (ISO code, or null), label (the broadcast name, like ESL TV) and url (the channel or video page).
  • channel for Twitch and Kick, videoId for YouTube and Twitch videos, clipId for clips; kind is live, video or clip.
  • official: the organiser's own broadcast (ESL, BLAST, PGL, CCT, StarLadder, Thunderpick and others, and channels named after the event's organiser).
  • embedUrl, embedUrlTemplate, requiresParent, and embedNote saying why there is no embed when one is missing.
  • chatEmbedUrl / chatEmbedUrlTemplate: Twitch chat (needs parent), and YouTube chat (embed_domain, shown only while the video is live). Kick has no official chat embed, so it is null.

Order: official broadcasts first, then streams that can be embedded, then English, then other languages A-Z, and a main stream before its "B" or "2" side stream. data.languages lists the languages for a switcher. Viewer counts are not included.

Copy-paste: HTML and JavaScript

Your server calls /cs2/live, picks a match, then calls its streams with your parent. Keep the API key on the server. The page only talks to your endpoint.

JavaScript
// server.js: keep your API key on the server; the browser calls /api/watch.
import express from "express";

const BASE = "https://api.citoapi.com/api/v1";
const HEADERS = { "x-api-key": process.env.CITO_API_KEY };
// Every hostname your watch page is served from (Twitch checks each one).
const PARENT = "www.example.com,example.com";

const app = express();
app.get("/api/watch", async (req, res) => {
  const live = await fetch(`${BASE}/cs2/live`, { headers: HEADERS }).then((r) => r.json());
  const match = live.data.find((m) => m.id === req.query.match) ?? live.data[0];
  if (!match) return res.json({ match: null, streams: [] });
  const list = await fetch(
    `${BASE}/cs2/matches/${match.id}/streams?parent=${encodeURIComponent(PARENT)}`,
    { headers: HEADERS },
  ).then((r) => r.json());
  res.json({ match, streams: list.data.streams });
});
app.listen(3000);
HTML
<div class="watch">
  <aside id="scoreboard"></aside>
  <section>
    <div id="switcher"></div>
    <iframe id="player" allow="autoplay; fullscreen" allowfullscreen
            style="width: 100%; aspect-ratio: 16 / 9; border: 0"></iframe>
  </section>
</div>

<script type="module">
  const { match, streams } = await fetch("/api/watch").then((r) => r.json());
  const player = document.getElementById("player");
  // Streams arrive official first. embedUrl is ready because the server passed ?parent=.
  const playable = streams.filter((s) => s.embedUrl);

  document.getElementById("scoreboard").textContent = match
    ? `${match.team1Name} ${match.team1Score} - ${match.team2Score} ${match.team2Name} · ${match.currentMap ?? ""}`
    : "No match live right now";

  const switcher = document.getElementById("switcher");
  for (const s of playable) {
    const button = document.createElement("button");
    button.textContent = `${s.label ?? s.channel} · ${s.platform} · ${s.language ?? "?"}${s.official ? " (official)" : ""}`;
    button.onclick = () => (player.src = s.embedUrl);
    switcher.append(button);
  }
  if (playable[0]) player.src = playable[0].embedUrl; // the official broadcast when there is one
</script>

Copy-paste: React

TypeScript
// WatchMatch.tsx: same /api/watch endpoint as above (or a Next.js route handler).
import { useEffect, useMemo, useState } from "react";

type Stream = {
  platform: string; language: string | null; label: string | null; channel: string | null;
  official: boolean; embedUrl: string | null; embedUrlTemplate: string | null; url: string;
};

export function WatchMatch() {
  const [data, setData] = useState<{ match: any; streams: Stream[] } | null>(null);
  const [language, setLanguage] = useState<string | null>(null);
  const [current, setCurrent] = useState<Stream | null>(null);

  useEffect(() => {
    fetch("/api/watch").then((r) => r.json()).then((d) => {
      setData(d);
      setCurrent(d.streams.find((s: Stream) => s.embedUrl) ?? null); // official first
    });
  }, []);

  // Works with or without ?parent= on the server: fill {parent} with this page's hostname.
  const src = (s: Stream) => s.embedUrl ?? s.embedUrlTemplate?.replace("{parent}", window.location.hostname) ?? null;
  const languages = useMemo(() => [...new Set((data?.streams ?? []).map((s) => s.language ?? "other"))], [data]);
  if (!data?.match) return <p>No match live right now.</p>;
  const { match, streams } = data;
  const shown = streams.filter((s) => src(s) && (!language || (s.language ?? "other") === language));

  return (
    <div style={{ display: "grid", gridTemplateColumns: "240px 1fr", gap: 16 }}>
      <aside>
        <strong>{match.team1Name} {match.team1Score} - {match.team2Score} {match.team2Name}</strong>
        <p>{match.currentMap} · round {match.currentRound}</p>
        {languages.map((l) => <button key={l} onClick={() => setLanguage(l)}>{l}</button>)}
        {shown.map((s) => (
          <button key={s.url} onClick={() => setCurrent(s)}>
            {s.label ?? s.channel} · {s.platform}{s.official ? " · official" : ""}
          </button>
        ))}
      </aside>
      {current && src(current) ? (
        <iframe key={src(current)!} src={src(current)!} allow="autoplay; fullscreen" allowFullScreen
                style={{ width: "100%", aspectRatio: "16 / 9", border: 0 }} />
      ) : <p>No embeddable stream for this match.</p>}
    </div>
  );
}

If the player does not show

  • Twitch "refused to connect" or an error frame: the page's hostname is not in parent, or the page is not HTTPS. Twitch accepts parent=localhost while you develop (tested over plain http).
  • YouTube error 153 / "video player configuration error": the page sends no Referer. Do not set referrerpolicy="no-referrer" on the iframe or the page.
  • Muted autoplay: browsers block autoplay with sound. To start a player by itself, add &muted=true (Twitch, which autoplays by default) or ?autoplay=1&mute=1 (YouTube); Kick takes ?autoplay=true.
  • Twitch's player must be at least 400x300 pixels.

Match streams (embeddable)

GET/api/v1/cs2/matches/{matchId}/streams

Every broadcast of a match with ready-to-use iframe sources, the tournament organiser's official broadcast first. Guide with HTML and React examples: https://citoapi.com/docs/api/cs2/streams Each stream: `platform` (`twitch`, `youtube`, `kick`, or another site with no embed), `language` (ISO 639-1, or null), `url` (the channel or video page), `label` (the broadcast name, e.g. `ESL TV`), `channel`, `videoId`, `clipId`, `kind` (`live` channel, `video`, `clip`), `official`, `embedUrl`, `embedUrlTemplate`, `requiresParent`, `embedNote`, `chatEmbedUrl` and `chatEmbedUrlTemplate`. **Twitch needs `parent`.** Twitch only plays inside a page whose hostname is named in the player URL, and that page must be served over HTTPS. Pass `?parent=<your page's hostname>` (for example `parent=www.example.com`; repeat it or comma-separate for several domains, up to 10) and Twitch streams come back with a complete `embedUrl` and `chatEmbedUrl`. Without it a Twitch `embedUrl` is `null` (a URL without parent shows an error frame) and `embedUrlTemplate` has a literal `{parent}` to replace with `location.hostname`. A hostname only: no `https://`, port or path (those return 400 `INVALID_PARENT`). **YouTube and Kick** need no parent: their `embedUrl` is always set and `embedUrlTemplate` is the same URL, so `embedUrlTemplate.replace('{parent}', location.hostname)` works for every stream. YouTube requires the embedding page to send a Referer header (do not set `referrerpolicy="no-referrer"`). YouTube chat (`live_chat?v=...&embed_domain=`) shows only while the video is live; Kick has no official chat, clip or VOD embed, so those are `null` with an `embedNote`. **Order and `official`.** Official broadcasts first (the organiser's own channels: ESL, BLAST, PGL, CCT, StarLadder, Thunderpick, FISSURE, NODWIN and others, plus channels named after the event's organiser, e.g. `NSTLGA` on NSTLGA Texas Open), then streams that can be embedded, then English, then other languages A-Z, a main stream before its B/2 side stream. `data.languages` lists the languages for a switcher. Viewer counts are not included.

Query parameters

ParameterTypeDescription
matchId (path)*stringMatch id, e.g. cs2-match-2398421.
parentstringHostname of the page that will embed the player (www.example.com): Twitch player and chat URLs are built for it. Repeat the parameter or comma-separate for several domains (up to 10). No scheme, port or path. Without it Twitch embedUrl is null and embedUrlTemplate carries {parent}.

Example

bash
curl "https://api.citoapi.com/api/v1/cs2/matches/MATCH_ID/streams" \
  -H "x-api-key: YOUR_API_KEY"

Response

json
{
  "success": true,
  "data": {
    "entityType": "cs2_match_streams",
    "matchId": "cs2-match-2399991",
    "parent": [
      "www.example.com"
    ],
    "languages": [
      "en",
      "es"
    ],
    "streams": [
      {
        "platform": "twitch",
        "language": "en",
        "url": "https://www.twitch.tv/eslcsb",
        "label": "ESL TV B",
        "channel": "eslcsb",
        "videoId": null,
        "clipId": null,
        "kind": "live",
        "official": true,
        "embedUrl": "https://player.twitch.tv/?channel=eslcsb&parent=www.example.com",
        "embedUrlTemplate": "https://player.twitch.tv/?channel=eslcsb&parent={parent}",
        "requiresParent": true,
        "embedNote": null,
        "chatEmbedUrl": "https://www.twitch.tv/embed/eslcsb/chat?parent=www.example.com",
        "chatEmbedUrlTemplate": "https://www.twitch.tv/embed/eslcsb/chat?parent={parent}"
      },
      {
        "platform": "youtube",
        "language": "en",
        "url": "https://www.youtube.com/watch?v=NbNK2IYGK2A",
        "label": "ESL TV B",
        "channel": null,
        "videoId": "NbNK2IYGK2A",
        "clipId": null,
        "kind": "video",
        "official": true,
        "embedUrl": "https://www.youtube.com/embed/NbNK2IYGK2A",
        "embedUrlTemplate": "https://www.youtube.com/embed/NbNK2IYGK2A",
        "requiresParent": false,
        "embedNote": null,
        "chatEmbedUrl": "https://www.youtube.com/live_chat?v=NbNK2IYGK2A&embed_domain=www.example.com",
        "chatEmbedUrlTemplate": "https://www.youtube.com/live_chat?v=NbNK2IYGK2A&embed_domain={parent}"
      }
    ]
  },
  "meta": {
    "count": 30,
    "officialCount": 3,
    "limit": 30,
    "total": 30,
    "page": 1,
    "totalPages": 1,
    "hasNext": false,
    "hasPrev": false
  }
}

Try the Watch live: embed the CS2 stream

Run a real request with your key, then wire the same call into your app. The free plan includes 500 requests a month.

bash
curl "https://api.citoapi.com/api/v1/cs2/matches/cs2-match-2399855/streams?parent=www.example.com" \
  -H "x-api-key: YOUR_API_KEY"
Was this page helpful?