Adding audio and video calling without a media server to run
Your backend creates a room and mints tokens; the media never touches your servers. What that means for scaling, and the three pieces you still have to build.
Adding calls to an existing product doesn’t mean running media infrastructure. On Infrai your backend makes two calls — POST /v1/rtc/room/create and POST /v1/rtc/token/issue — and the audio and video flow directly between the clients and the vendor’s network. Your servers see two HTTP requests per call and no packets.
That’s the useful part: adding calling doesn’t change your capacity planning at all.
The two calls your backend makes
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": "consult-9142", "max_participants": 4, "empty_timeout_s": 180}'
{
"ok": true,
"data": {
"room_id": "rtcr_2fVc8nRqLmT4xBzY",
"name": "consult-9142",
"vendor": "tencent_rtc",
"state": "active",
"max_participants": 4,
"num_participants": 0,
"created_at": "2026-09-21T03:25:19Z",
"metadata": {}
}
}
curl -sS -X POST "https://api.infrai.cc/v1/rtc/token/issue" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"room": "consult-9142",
"identity": "au_usr_lMmXGySJeGVM1xicqHGJaJbB",
"display_name": "Ada L.",
"ttl_s": 300,
"can_publish": true,
"can_subscribe": true,
"can_publish_data": false,
"is_admin": false
}'
The response’s token and ws_url go to the client. Everything after that happens between the browser and the vendor.
What your capacity planning doesn’t have to absorb
No TURN servers. No SFU to scale. No bandwidth on your egress bill for media. No CPU spent transcoding. A hundred concurrent calls costs your infrastructure two hundred HTTP requests, spread over however long the calls take to start.
Contrast that with the self-hosted version, which is where this comparison usually starts: a media server, a TURN relay for the participants behind restrictive NATs, capacity headroom for peak concurrency, and someone who understands why one call in fifty has one-way audio.
The three pieces you still build
Be clear-eyed about the work that remains, because “no media server” doesn’t mean “no work”.
The join endpoint. Your own authorisation, then a mint. The room name must come from your data, not the request body.
import os
from fastapi import FastAPI, Header, HTTPException
import httpx
API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
client = httpx.Client(base_url=API, headers={"Authorization": f"Bearer {KEY}"},
timeout=httpx.Timeout(5.0, connect=2.0))
app = FastAPI()
def room_for(user_id: str) -> tuple[str, str] | None:
"""Your assignment logic: which consultation is this person part of, and as
what. Never read the room name from the client."""
return ("consult-9142", "participant")
@app.post("/calls/join")
def join(x_session_id: str = Header(...)) -> dict:
session = client.get(f"/v1/auth/session/verify/{x_session_id}")
if session.status_code == 404:
raise HTTPException(status_code=401, detail="no session")
session.raise_for_status()
user_id = session.json()["data"]["user_id"]
assignment = room_for(user_id)
if assignment is None:
raise HTTPException(status_code=403, detail="no call assigned")
room, role = assignment
# Create-if-absent, then mint. A 404 on the room read is the normal first-join
# path rather than an error worth logging.
if client.get(f"/v1/rtc/room/get/{room}").status_code == 404:
client.post("/v1/rtc/room/create",
json={"name": room, "max_participants": 4, "empty_timeout_s": 180})
minted = client.post("/v1/rtc/token/issue", json={
"room": room, "identity": user_id, "display_name": user_id[:12],
"ttl_s": 300, "can_publish": role != "viewer", "can_subscribe": True,
"can_publish_data": False, "is_admin": False,
})
minted.raise_for_status()
data = minted.json()["data"]
return {"token": data["token"], "wsUrl": data["ws_url"], "expiresAt": data["expires_at"]}
The call UI. Camera and microphone permissions, device selection, a mute button, a layout for two to four video tiles, a “reconnecting” state, and an end-call flow. This is the bulk of the work and there’s no way around it with these endpoints — the vendor’s client library gives you the connection, not the interface.
The lifecycle glue. Create lazily, set empty_timeout_s, and sweep with GET /v1/rtc/room/list for the strays.
What it’s suited to, and what it isn’t
| Shape | Good fit? |
|---|---|
| Support escalation from chat to a call | yes — occasional, two people |
| Telehealth consultation, scheduled | yes, with your own scheduling |
| Interview or advisory session, 2-4 people | yes |
| Webinar to 500 viewers | no — that’s a streaming problem |
| Video as the core product experience | no — buy a specialist |
| Recording every call for compliance | no — not in these routes |
Rows four to six are genuine limitations rather than hedging. There’s no recording endpoint here, no streaming egress, and no prebuilt UI — so a product where calling is the product should use LiveKit, whose component library and server-side recording exist precisely for that, or Daily if an embeddable call UI is what you want to buy.
The part that’s actually easier
A call is never just a call. It’s a call plus the transcript you keep, the follow-up email, the ticket update and the cost attribution — and those are the pieces that multiply vendors.
Here they’re the same key: PUT /v1/storage/object/put/{bucket}/{key} for the artefact, POST /v1/email/send for the follow-up, POST /v1/queue/publish for the post-call processing, and one GET /v1/account/usage that prices the whole feature rather than one slice of it. Adding calling to an existing product this way means one new integration, not four.
Room management reports billing_class: free in discovery; token issue is the billed call, with the live figure in GET /v1/discovery/rtc.token.issue (verified 2026-09-21). Read it from your own account rather than a table, and expect platform rates to drift downward as vendor contracts improve.