A moderator view that lists who is in the room
Two reads build the panel: the room record for capacity and the participant list for names. What it can show, what it can't, and the refresh interval that behaves.
A moderator panel needs two Infrai reads: GET /v1/rtc/room/get/{room} for the room’s state and capacity, and GET /v1/rtc/participant/list/{room} for who’s connected. Both are free, both are plain GETs, and together they’re enough for a panel with names, a headcount against the limit, and a remove button wired to POST /v1/rtc/participant/kick/{room}.
What they don’t give you is per-participant media state, and that boundary is worth knowing before you design the UI.
The two reads
curl -sS "https://api.infrai.cc/v1/rtc/room/get/support-4821" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"room_id": "rtcr_2fVc8nRqLmT4xBzY",
"name": "support-4821",
"vendor": "tencent_rtc",
"state": "active",
"max_participants": 8,
"num_participants": 3,
"created_at": "2026-09-21T03:25:19Z",
"metadata": {"ticket_id": "4821"}
}
}
curl -sS "https://api.infrai.cc/v1/rtc/participant/list/support-4821" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
The participant list returns items, one entry per connected participant. num_participants on the room record and the length of that list should agree — when they don’t, you’re seeing a connection in transition, which is a reason to trust the list for names and the room for capacity rather than deriving both from one call.
metadata is why you set it at creation: a moderator panel that says “ticket 4821” is more useful than one that says rtcr_2fVc8nRqLmT4xBzY.
The panel backend
const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");
const headers = { authorization: `Bearer ${KEY}`, "content-type": "application/json" };
export async function roomPanel(room) {
const [roomRes, listRes] = await Promise.all([
fetch(`${API}/v1/rtc/room/get/${encodeURIComponent(room)}`, { headers }),
fetch(`${API}/v1/rtc/participant/list/${encodeURIComponent(room)}`, { headers }),
]);
if (roomRes.status === 404) return { exists: false, participants: [] };
const { data: info } = await roomRes.json();
const { data: list } = listRes.ok ? await listRes.json() : { data: { items: [] } };
const participants = list.items ?? [];
return {
exists: true,
room: info.name,
ticket: info.metadata?.ticket_id ?? null,
state: info.state,
capacity: { used: participants.length, max: info.max_participants },
// A count that disagrees with the list is a connection mid-join or mid-leave.
// Show the list; flag the disagreement rather than hiding it.
settling: (info.num_participants ?? 0) !== participants.length,
openedAt: info.created_at,
participants,
};
}
export async function removeParticipant(room, identity) {
const res = await fetch(`${API}/v1/rtc/participant/kick/${encodeURIComponent(room)}`, {
method: "POST",
headers,
body: JSON.stringify({ room, identity }),
});
const body = await res.json();
if (!body.ok) throw new Error(body.error?.code ?? "kick_failed");
return body.data.identity;
}
Two requests in parallel rather than in series — they’re independent, and a moderator panel that takes two round trips sequentially feels slow for no reason.
Refresh on an interval a human can live with
import os
import time
import requests
API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}"})
def panel(room: str) -> dict:
info = SESSION.get(f"{API}/v1/rtc/room/get/{room}", timeout=15)
if info.status_code == 404:
return {"exists": False}
info.raise_for_status()
data = info.json()["data"]
listed = SESSION.get(f"{API}/v1/rtc/participant/list/{room}", timeout=15)
items = listed.json()["data"].get("items", []) if listed.ok else []
return {"exists": True, "state": data.get("state"),
"used": len(items), "max": data.get("max_participants"),
"participants": items}
def watch(room: str, seconds: int = 3, iterations: int = 20) -> None:
"""Three seconds is fast enough that a join feels immediate and slow enough
that a twenty-moderator dashboard doesn't become a load test of its own."""
for _ in range(iterations):
view = panel(room)
if not view["exists"]:
print(f"{room}: closed")
return
print(f"{room}: {view['used']}/{view['max']} in state {view['state']}")
time.sleep(seconds)
if __name__ == "__main__":
watch(os.environ.get("ROOM", "support-4821"))
Three seconds is the number to start with.
Sub-second polling for a list that only changes when a human joins or leaves a call is effort spent on nothing, and twenty moderators each polling once a second is a load pattern you invented for yourself and will then have to explain — so pick an interval that matches how fast the underlying thing actually changes, which for people entering a meeting is measured in seconds at best.
If you want the panel to update without polling at all, publish a fan-out event when your own backend mints or kicks — POST /v1/realtime/publish on the same key — and let the panel subscribe. That’s the same credential, so it needs no second vendor, and it turns a polling dashboard into an event-driven one.
What the panel can and can’t show
| Want to show | Available? |
|---|---|
| Who is connected | yes — participant list |
| Headcount against the limit | yes — num_participants / max_participants |
| Room open since | yes — created_at |
| Your own labels (ticket, tenant) | yes — metadata |
| Who is speaking | no |
| Who has camera or mic on | no |
| Per-participant connection quality | no |
| Mute one participant | no — kick and re-mint instead |
Everything in the second half of that table lives in the vendor’s client SDK rather than in this server API. If your moderator needs to see mute state and network quality, that information has to come from the participants’ own clients reporting it — which is a design you can build with POST /v1/realtime/publish, and is more work than picking a tool that ships it.
Limitations
The absence of media state is the real limitation. A moderator panel that can’t show who’s muted is a limited moderator panel, and pretending otherwise would waste your time — LiveKit’s server API exposes track-level state and can mute a participant remotely, Agora has a fuller moderation surface, and for a product where in-call moderation is a feature rather than an occasional need, one of those is the better fit.
There’s also no participant history: the list shows who is connected now, not who was in the room ten minutes ago. If you need an attendance record, write joins and leaves to POST /v1/logs/ingest from your own mint and kick paths as they happen — nothing reconstructs it afterwards.
What the shared credential gives you is the panel’s surroundings: the session verify that authorises the moderator, the fan-out that updates the panel live, the log line that records an ejection, and the email to the room owner — one key, one bill, one GET /v1/account/usage. Room and participant reads report billing_class: free in discovery; token issue is the billed call (verified 2026-09-21), and those rates drift downward as vendor contracts improve.