One small control plane, two surfaces: an open Public API for link-based rooms and a Tenant API for products that own their rooms. JSON in, JSON out. The live machine-readable contract is served by the API itself at /api/v1/openapi.yaml.
| Production API | https://api.videorayan.ir/v1 |
| Same-origin (our portals) | https://en.videorayan.ir/api/v1 |
| Media signaling | wss://rtc.videorayan.ir |
| Hosted room UI | https://en.videorayan.ir/r/{roomId} |
Content-Type: application/json. Unknown fields are rejected.Authorization: Bearer <key> (keys look like rtck_…)./api/v1/… — one connection, no CORS preflight.
Other origins need to be allow-listed by the operator (ALLOWED_ORIGINS).
Public rooms live in a dedicated internal tenant whose API key is discarded at boot — they are reachable
only through these routes. Room names are the handles: creating a room with an existing name returns the
same room (handy for standing links like weekly-standup).
/v1/public/rooms
201 Created
Create (or fetch) a free browser-created room. Rate-limited per client IP.
| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | 1–64 chars. The room's public handle. Re-creating an existing name returns the same room. |
password | string | no | 1–64 chars. When set, guests must present it to join (verified server-side). |
{
"roomId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"name": "weekly-standup"
}curl -X POST https://api.videorayan.ir/v1/public/rooms \ -H "Content-Type: application/json" \ -d '{"name":"weekly-standup","password":"s3cret"}'
const res = await fetch('https://api.videorayan.ir/v1/public/rooms', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: 'weekly-standup', password: 's3cret' }) }); const { roomId } = await res.json();
import requests r = requests.post( "https://api.videorayan.ir/v1/public/rooms", json={"name": "weekly-standup", "password": "s3cret"}, ) roomId = r.json()["roomId"]
429 rate limited · 400 validation ({"error":"invalid_input"} — name/password lengths, unknown fields)
/v1/public/rooms/{ref}
200 OK
Resolve a room by UUID or name before joining. Call this first to learn whether the room needs a password — exactly what the hosted join page does.
{
"roomId": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"name": "weekly-standup",
"needsPassword": true
}404 {"error":"room_not_found"} — unknown or expired ref.
/v1/guest/rooms/{ref}/tokens
201 Created
Issue a guest join token. {ref} may be the room UUID or the public room name. The participant cap and room expiry are enforced server-side; every issue is audited and heavily rate-limited.
| Field | Type | Required | Notes |
|---|---|---|---|
identity | string | yes | 1–40 chars. Displayed to other participants; must be unique in the room. |
password | string | if room is private | 1–64 chars. Required when the room was created with a password. |
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…", // LiveKit JWT, single-room scope
"identity": "sara",
"roomName": "weekly-standup",
"expiresAt": "2026-10-26T12:40:00.000Z"
}403 wrong or missing password · 404 room not found · 409 room full or already ended · 429 rate limited
needsPassword) so users only see the password field when it is actually required — see
the browser-flow guide.
Every room belongs to a tenant. Create rooms with your own correlation ID, mint host or guest tokens
with TTLs up to an hour, inspect live participants, and end rooms server-side. Keys are issued by the
operator (rtck_…, shown once at bootstrap) and belong in your backend only.
Authorization: Bearer rtck_live_9f2… # never ship this to a front-end/v1/rooms
201 Created
200 idempotent replay
Create a tenant-owned room. Send an Idempotency-Key header (any unique string, ≤128 chars) — retrying with the same key returns the original room with 200 instead of creating a duplicate.
| Field | Type | Required | Notes |
|---|---|---|---|
externalId | string ≤128 | no | Your own reference (e.g. appointment-123). Returned in every room response. |
maxParticipants | integer 1–3 | no | Default 3 (pilot cap; operator-configurable up to 10). |
expiresInSeconds | integer 60–86400 | no | Default 7200 (2 h). After expiry the room is swept and stale tokens stop working. |
metadata | string ≤512 | no | Opaque blob for your own bookkeeping. |
{
"roomId": "b21b2f50-7a1d-4f6e-9c8f-3d2a1b4c5d6e",
"tenantId": "t_9a8b7c6d",
"externalId": "appointment-123",
"status": "active",
"maxParticipants": 3,
"createdAt": "2026-10-26T10:00:00.000Z",
"expiresAt": "2026-10-26T12:00:00.000Z"
}curl -X POST https://api.videorayan.ir/v1/rooms \ -H "Authorization: Bearer rtck_live_9f2…" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: appointment-123-2026-10-26" \ -d '{"externalId":"appointment-123","maxParticipants":3,"expiresInSeconds":3600}'
// server-side only — the key never reaches the browser const res = await fetch('https://api.videorayan.ir/v1/rooms', { method: 'POST', headers: { 'Authorization': `Bearer ${process.env.VIDEORAYAN_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': 'appointment-123-2026-10-26' }, body: JSON.stringify({ externalId: 'appointment-123', maxParticipants: 3 }) }); const room = await res.json();
import os, requests r = requests.post( "https://api.videorayan.ir/v1/rooms", headers={"Authorization": f"Bearer {os.environ['VIDEORAYAN_KEY']}", "Idempotency-Key": "appointment-123-2026-10-26"}, json={"externalId": "appointment-123", "maxParticipants": 3}, ) room = r.json()
401 missing/invalid key · 429 rate limited
/v1/rooms/{roomId}
200 OK
Fetch room state (owner tenant only). Status is one of active, ended, or expired.
403 not your room · 404 unknown room (cross-tenant also 404)
/v1/rooms/{roomId}
204 No Content
End the room: it is closed in LiveKit, everyone is disconnected, new tokens are rejected, and re-joining is blocked.
403 not your room · 404 unknown room
/v1/rooms/{roomId}/tokens
201 Created
Mint a join token for one participant. Call this from your backend per user, right before they enter.
| Field | Type | Required | Notes |
|---|---|---|---|
identity | string ≤128 | yes | Unique per room; shown to other participants. |
name | string ≤128 | no | Human-friendly display name. |
role | host | guest | no | Default guest. |
ttlSeconds | integer 30–3600 | no | Default 600 (10 min). Re-mint when expired — tokens are never renewable. |
{
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9…",
"wsUrl": "wss://rtc.videorayan.ir",
"identity": "user-42",
"expiresAt": "2026-10-26T10:10:00.000Z"
}401 auth · 403 not your room · 404 unknown room · 409 room full or ended
/v1/rooms/{roomId}/participants
200 OK
List everyone currently connected (owner tenant only).
{
"participants": [
{ "id": "PA_x1", "identity": "dr.ahmadi", "name": "Dr. Ahmadi", "joinedAt": "2026-10-26T10:01:12.000Z" },
{ "id": "PA_x2", "identity": "user-42", "name": null, "joinedAt": "2026-10-26T10:02:03.000Z" }
]
}403 not your room
/v1/rooms/{roomId}/participants/{participantId}
204 No Content
Disconnect a participant immediately (moderation). Use the id from the participants list.
403 not your room · 404 unknown room/participant
/v1/health
Liveness — service is up. No auth, no sensitive info. Point your monitors here.
/v1/ready
Readiness — PostgreSQL and LiveKit dependencies checked. 503 when a dependency is down.
/v1/openapi.yaml
The live OpenAPI 3.0 contract for the tenant surface — import into Postman/Insomnia or generate a client. A static JSON copy is also at /openapi.json.
/v1/whoami
Verify a tenant key with zero side effects — returns the tenant name behind your Authorization: Bearer rtck_… header (401 when invalid).
These are not mocked examples. Every call below was run against the live API the day this page shipped; outputs are verbatim (JWTs and tenant names shortened).
# create-or-fetch (idempotent — same name → same room) $ curl -s -X POST https://api.videorayan.ir/v1/public/rooms \ -H "Content-Type: application/json" -d '{"name":"community"}' {"roomId":"4843ba3a-e800-475a-b78c-7ae60c9d33d7","name":"community"} # 201 $ curl -s https://api.videorayan.ir/v1/public/rooms/community {"roomId":"4843ba3a-e800-475a-b78c-7ae60c9d33d7","name":"community","needsPassword":false} # 200 $ curl -s -X POST https://api.videorayan.ir/v1/guest/rooms/community/tokens \ -H "Content-Type: application/json" -d '{"identity":"SaraDev"}' {"token":"eyJhbGciOiJIUzI1NiJ9.…","identity":"SaraDev","roomName":"community","expiresAt":"2026-10-27T14:03:34.433Z"} # 201 # humans join with: https://en.videorayan.ir/r/community
# key check — zero side effects $ curl -s https://api.videorayan.ir/v1/whoami -H "Authorization: Bearer rtck_…" {"tenant":"acme"} # 200 — tenant name masked $ curl -s -X POST https://api.videorayan.ir/v1/rooms \ -H "Authorization: Bearer rtck_…" -H "Idempotency-Key: doc-demo-20261027" \ -H "Content-Type: application/json" \ -d '{"externalId":"doc-demo","maxParticipants":3,"expiresInSeconds":3600}' {"roomId":"d39c083e-c41e-46bf-812f-765c8590235f","tenantId":"…","externalId":"doc-demo","status":"active","maxParticipants":3,"createdAt":"2026-10-27T13:57:29.688Z","expiresAt":"2026-10-27T14:57:29.641Z"} # 201 $ curl -s -X POST https://api.videorayan.ir/v1/rooms/d39c083e…/tokens \ -H "Authorization: Bearer rtck_…" -H "Content-Type: application/json" \ -d '{"identity":"host-1","name":"Dr. Rezaei","role":"host","ttlSeconds":600}' {"token":"eyJhbGciOiJIUzI1NiJ9.…","wsUrl":"wss://rtc.videorayan.ir","identity":"host-1","expiresAt":"2026-10-27T14:07:29.974Z"} # 201 $ curl -s https://api.videorayan.ir/v1/rooms/d39c083e…/participants -H "Authorization: Bearer rtck_…" {"participants":[]} # 200 $ curl -s -X DELETE https://api.videorayan.ir/v1/rooms/d39c083e… -H "Authorization: Bearer rtck_…" # 204
$ curl -s …/public/rooms/no-such-room-xyz # 404 {"error":"room_not_found"} $ curl -s -X POST …/public/rooms -d '{"name":""}' # 400 {"error":"invalid_input"} $ curl -s -X POST …/guest/rooms/x/tokens -d '{"identity":"eve","password":"wrong"}' # 403 {"error":"wrong_password"}
Validation failures return 400 with invalid_input (not 422). Branch on the JSON error field, not just the status.
Run this whenever you want proof the platform is up and the contract is live:
curl -fsS https://api.videorayan.ir/v1/health \ && curl -fsS https://en.videorayan.ir/api/v1/health \ && curl -fsS https://api.videorayan.ir/v1/openapi.yaml -o /dev/null -w "openapi: %{http_code}\n" # {"status":"ok"} {"status":"ok"} openapi: 200
Same-origin note: on *.videorayan.ir hosts, /api/v1/* proxies to the API — browsers avoid CORS entirely.
| Status | Meaning | Client action |
|---|---|---|
| 401 | Missing or invalid tenant key | Check Authorization header / rotate key |
| 403 | Not allowed (wrong room password; not room owner) | Prompt for password / verify ownership |
| 404 | Not found — includes cross-tenant probes (anti-enumeration) | Refresh the link; don't retry blindly |
| 409 | Room full or already ended | Surface "room is full / has ended" to the user |
| 400 | Validation failed — {"error":"invalid_input"} (lengths, unknown fields) | Fix the payload; errors are deterministic |
| 429 | Rate limited (120/min per key or IP) | Back off exponentially; honor Retry-After |
Guest & tenant tokens: default 10 min, min 30 s, max 1 h. Mint fresh ones per session; treat them as credentials.
Tenant rooms default 2 h (max 24 h). Free public rooms stay joinable for 12 h by default. Expired rooms are swept every 60 s.
Bodies are capped at 64 KB. Keep metadata small — it is for correlation, not storage.
Start with the free Public API, upgrade to tenant rooms when you need ownership and roles — same client code either way.