Creating and tearing down video rooms for one-to-one calls

Room create takes an empty timeout that handles teardown for you. Naming, max_participants, and the case for creating rooms lazily.

For a support call or a one-to-one consultation, the room only needs to exist while the call does. POST /v1/rtc/room/create on Infrai takes an empty_timeout_s that closes the room automatically once everyone has left, so the common case needs no teardown code at all — and DELETE /v1/rtc/room/delete/{room} is there for when you want it gone now.

Set the timeout and most of your lifecycle problem disappears.

Create with a timeout

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", "tenant_id": "t_northwind"}
  }'
{
  "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", "tenant_id": "t_northwind"}
  }
}

Three fields do real work here. max_participants: 2 makes a one-to-one call structurally one-to-one — a third token can’t turn it into a group call. empty_timeout_s: 120 gives a participant two minutes to reconnect after a dropped call before the room closes. And metadata carries your own identifiers, which is how a room found later in a list is traceable to a ticket.

Two minutes is a reasonable default for the timeout: long enough to survive a lift or a network switch, short enough that a forgotten room doesn’t linger.

Name rooms after the thing, not the people

support-4821 is the ticket. ada-and-tom is two people whose next call needs a different name and whose room you can’t reason about later.

A name derived from the resource means creation is idempotent in practice — the same ticket always maps to the same room — and it means a room in a listing tells you what it was for.

Create lazily

There’s no reason to create a room when a meeting is scheduled. Create it when the first participant asks to join:

import os

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"})


def ensure_room(name: str, ticket_id: str, max_participants: int = 2) -> dict:
    """Create on first join, tolerate already-existing. A scheduled meeting that
    nobody attends should never have created a room at all."""
    existing = SESSION.get(f"{API}/v1/rtc/room/get/{name}", timeout=20)
    if existing.status_code == 200:
        return existing.json()["data"]

    resp = SESSION.post(
        f"{API}/v1/rtc/room/create",
        json={"name": name, "max_participants": max_participants, "empty_timeout_s": 120,
              "metadata": {"ticket_id": ticket_id}},
        timeout=30,
    )
    body = resp.json()
    if body.get("ok"):
        return body["data"]
    # Two joins racing: the loser reads the winner's room rather than failing.
    if body.get("error", {}).get("code") in {"RTC_ROOM_EXISTS", "IDEMPOTENCY_KEY_CONFLICT"}:
        return SESSION.get(f"{API}/v1/rtc/room/get/{name}", timeout=20).json()["data"]
    raise RuntimeError(body["error"]["code"])


def join(name: str, identity: str, display_name: str) -> dict:
    room = ensure_room(name, ticket_id=name.split("-")[-1])
    if (room.get("num_participants") or 0) >= (room.get("max_participants") or 0):
        raise RuntimeError("room is full")

    minted = SESSION.post(
        f"{API}/v1/rtc/token/issue",
        json={"room": name, "identity": identity, "display_name": display_name,
              "ttl_s": 900, "can_publish": True, "can_subscribe": True,
              "can_publish_data": True, "is_admin": False},
        timeout=25,
    )
    minted.raise_for_status()
    data = minted.json()["data"]
    return {"token": data["token"], "ws_url": data["ws_url"], "expires_at": data["expires_at"]}


if __name__ == "__main__":
    print(join("support-4821", os.environ["USER_ID"], "Ada L."))

Two races handled deliberately. Both participants clicking join at once — one creates, the other reads. And the full-room check before minting, so a third person gets a clear message from your API rather than a mysterious client-side failure.

When to delete explicitly

empty_timeout_s covers the normal ending. Explicit deletion is for the abnormal ones:

curl -sS -X DELETE "https://api.infrai.cc/v1/rtc/room/delete/support-4821" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
SituationRely on timeoutDelete explicitly
Call ends normallyyesunnecessary
Agent hangs up, customer lingersyes, after the timeoutonly if you want them out now
Ticket closed while the call runsnoyes
Abuse in the roomnoyes, after kicking
Never-attended scheduled callnever created itnothing to do

The backstop sweep

Timeouts handle the ordinary case; a sweep handles the room created with a timeout you later realised was too generous, or the one whose state never advanced.

curl -sS "https://api.infrai.cc/v1/rtc/room/list" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": { "items": [], "next_cursor": null }
}

An empty list is the healthy steady state for a lazily-created, timeout-closed design — which is the quickest way to tell that your lifecycle is working. Schedule the check on POST /v1/cron/create and alert if the count climbs, rather than inspecting it by hand.

Limitations

There’s no scheduling in this API: no “create this room at 14:00 for 30 minutes”, no calendar integration, no waiting-room state. Rooms exist or they don’t, and anything time-based is your own scheduler calling these endpoints — POST /v1/cron/create can do it, but you’re assembling the behaviour rather than configuring it.

Recording isn’t part of these routes either, so a call you need to keep needs the vendor’s own recording path and somewhere to put the file. And the client side is the vendor’s SDK pointed at the ws_url from the token, not a platform-neutral library — Daily’s prebuilt call UI and LiveKit’s component library are both considerably less work if you want a call interface rather than call primitives, and for a small team that’s a fair reason to pick one.

What’s on the same credential is the workflow around the call: the ticket’s record, the transcript you’d store with PUT /v1/storage/object/put/{bucket}/{key}, the follow-up email through POST /v1/email/send and the schedule on POST /v1/cron/create — one key, one bill, one GET /v1/account/usage. Room management reports billing_class: free in discovery while token issue is billed per call (verified 2026-09-21); those rates drift downward as vendor contracts improve.

References

Browse more rtc developer guides