Stop paying for idle video rooms: lifecycle and cleanup
empty_timeout_s does most of the work. The sweep for what it misses, the naming convention that makes the sweep safe, and where the cost actually sits.
An Infrai video room that nobody closed is a resource sitting on a vendor’s infrastructure, and the cheapest fix is a field you set at creation: empty_timeout_s on POST /v1/rtc/room/create closes the room once the last participant leaves. Set it and the ordinary case handles itself; GET /v1/rtc/room/list plus DELETE /v1/rtc/room/delete/{room} catch what it doesn’t.
Most teams set no timeout, create rooms eagerly, and find out later.
Set the timeout at creation
curl -sS -X POST "https://api.infrai.cc/v1/rtc/room/create" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"name": "support-4821",
"max_participants": 2,
"empty_timeout_s": 120,
"metadata": {"ticket_id": "4821", "created_by": "escalation-worker"}
}'
{
"ok": true,
"data": {
"room_id": "rtcr_2fVc8nRqLmT4xBzY",
"name": "support-4821",
"vendor": "tencent_rtc",
"state": "active",
"max_participants": 2,
"num_participants": 0,
"created_at": "2026-09-21T03:25:19Z",
"metadata": {"ticket_id": "4821", "created_by": "escalation-worker"}
}
}
Two minutes covers a dropped connection and a reconnect. Thirty minutes covers a coffee break you probably shouldn’t be paying to hold a room open for.
The created_by in metadata is worth the keystrokes. When a sweep finds rooms nobody can account for, the field that names the service that made them is how you stop it happening again.
Create lazily, not on schedule
The largest saving isn’t cleanup — it’s not creating the room. A meeting scheduled for Thursday that nobody attends should never have had a room, so create on the first join request rather than when the calendar entry is made.
That single change removes the entire category of never-used rooms, which in a product with optional calls is most of them.
The sweep for what’s left
import os
from datetime import datetime, timezone
import requests
API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})
# Only names this sweep understands. An allowlist fails safe; a denylist fails
# open the first time somebody introduces a new prefix.
SWEEPABLE = ("support-", "consult-", "demo-")
MAX_AGE_HOURS = 6
def rooms() -> list[dict]:
out, cursor = [], None
while True:
params = {"cursor": cursor} if cursor else None
resp = SESSION.get(f"{API}/v1/rtc/room/list", params=params, timeout=25)
resp.raise_for_status()
data = resp.json()["data"]
out += data.get("items", [])
cursor = data.get("next_cursor")
if not cursor:
return out
def age_hours(iso: str | None) -> float:
if not iso:
return 0.0
created = datetime.fromisoformat(iso.replace("Z", "+00:00"))
return (datetime.now(timezone.utc) - created).total_seconds() / 3600
def sweep(apply: bool = False) -> dict:
deleted, spared, ignored = [], [], []
for room in rooms():
name = room.get("name") or ""
if not name.startswith(SWEEPABLE):
ignored.append(name)
continue
# Never delete an occupied room, and re-read right before deleting rather
# than trusting a listing that may be minutes old.
fresh = SESSION.get(f"{API}/v1/rtc/room/get/{name}", timeout=20)
current = fresh.json()["data"] if fresh.ok else room
if (current.get("num_participants") or 0) > 0:
spared.append(f"{name} ({current['num_participants']} in call)")
continue
if age_hours(current.get("created_at")) < MAX_AGE_HOURS:
spared.append(f"{name} (too young)")
continue
if apply:
resp = SESSION.delete(f"{API}/v1/rtc/room/delete/{name}", timeout=25)
if resp.status_code not in (200, 404):
resp.raise_for_status()
deleted.append(name)
return {"deleted": deleted, "spared": spared, "ignored": len(ignored), "applied": apply}
if __name__ == "__main__":
print(sweep(apply=os.environ.get("APPLY") == "1"))
The occupancy re-read is not optional. A sweep that deletes a room from a stale listing drops a live call, and the person on it has no idea why.
Where the cost actually is
This is worth being precise about, because “idle rooms cost money” is a vague claim and the shape matters.
Room management calls report billing_class: free in discovery — creating, listing, reading and deleting a room isn’t billed per call. POST /v1/rtc/token/issue is the billable endpoint, with the live rate in its billing block, and the underlying vendor minutes flow through your account as usage. So an idle room with nobody connected is not accumulating per-minute charges the way an idle database accumulates storage rent.
curl -sS "https://api.infrai.cc/v1/account/usage" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
That breakdown (free, verified 2026-09-21) is what tells you whether RTC is a real line item for you. If it isn’t, cleanup is hygiene rather than cost control — and you should still do it, because a listing full of stale rooms is a listing nobody can use during an incident.
| Habit | Cost effect | Operational effect |
|---|---|---|
| No timeout, eager creation | small | room list becomes unusable |
empty_timeout_s set, lazy creation | minimal | list reflects reality |
| Sweep as backstop | minimal | catches the odd stuck room |
| Re-minting tokens in a reconnect loop | real — token issue is billed | worth a client-side floor |
That last row is where money actually leaks. A client that re-mints on every reconnect attempt, with no backoff, turns a flaky network into a billing line — put a floor in your mint endpoint, per identity per room.
Schedule the sweep, and check it ran
curl -sS -X POST "https://api.infrai.cc/v1/cron/create" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"name": "rtc-room-sweep",
"cron_expr": "0 5 * * *",
"task": "https://ops.example.com/hooks/rtc-sweep",
"timeout_seconds": 300,
"on_failure_webhook": "https://ops.example.com/hooks/cron-failed"
}'
GET /v1/cron/runs/list/{id} confirms it fires. A sweep that quietly stopped looks exactly like a tidy system.
Limitations
There’s no per-room usage figure: GET /v1/account/usage breaks spend down by capability, not by room, so “what did that call cost” needs you to record metadata.cost_usd from the token response yourself. And state on a room record doesn’t tell you how long participants were connected, so duration accounting is also yours to capture at join and leave time.
LiveKit and Agora both report participant-minutes per room, which is genuinely better if per-call billing is something you pass on to customers — if you’re reselling calls, that reporting gap is a good reason to use one of them instead.
What the shared credential gives you is that the sweep, its schedule, its log line via POST /v1/logs/ingest and the alert if room counts climb via POST /v1/email/send are one key and one invoice — no second vendor to log into, and the whole cost of the cleanup system visible in the same usage read as the thing it cleans. Platform rates drift downward as vendor contracts improve, so read them live.