Complete reference documentation for the HyperBabel API Platform.
All API requests must include your API Key in the Authorization header. Generate your API key from Console → API Keys.
Authorization: Bearer hb_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxAPI Key Types
hb_live_... — Production key (billing applies)https://api.hyperbabel.com/api/v1Monitor your API usage programmatically. Use this for billing alert automation or to build internal cost dashboards. Account management (register, login, API key creation) is done through the HyperBabel Console, not via API.
/api/v1/auth/usageReturn the current month's usage counters together with your plan's monthly limits as one flat object. Use for programmatic monitoring or billing alert automation. Compare each counter against its matching monthly_* limit (-1 means unlimited). Note: large counters are returned as JSON strings (BIGINT-backed) — parse with Number() before comparing.
// GET — no body // Headers Authorization: Bearer hb_live_xxx
// 200 — flat object: current-month counters + plan limits // BIGINT-backed counters serialize as strings — parse with Number(). { "messages_sent": "12400", "connection_mins": "5320", "translate_chars": "89000", "hd_video_mins": 320, "fhd_video_mins": 40, "hd_stream_mins": 60, "fhd_stream_mins": 0, "storage_used_mb": "1240", "stt_mins": "12", "api_calls": "48210", "monthly_messages": "100000", "monthly_connection_mins": "60000", "monthly_peak_connections": 500, "monthly_translate_chars": "1000000", "monthly_storage_mb": 10240, "monthly_hd_video_mins": 3000, "monthly_fhd_video_mins": 500, "monthly_hd_stream_mins": 1000, "monthly_fhd_stream_mins": 200, "monthly_stt_mins": "300", "max_channels": 500, "max_api_keys": 10, "max_group_members": 100, "max_open_members": 500, "plan_name": "basic", "current_period_end": "2026-04-01T00:00:00.000Z", "active_key_count": "2" } // 404 — org has no active subscription { "error": "No active subscription found" }
Mint short-lived JWTs scoped to one of your end-users so your mobile and web apps never need to embed an HyperBabel API key. Your backend stays the only place that holds hb_live_…; client apps carry only per-user tokens.
[ Client App ] ──Sign in with Firebase──→ Firebase ID token
│
│ POST /customer/auth/firebase-exchange (Firebase users — direct, no backend hop)
│ Authorization: Bearer <firebase-id-token>
▼
[ HyperBabel API ] ── verify token → mint customer JWT
│
│ { access_token, refresh_token, ... }
▼
[ Client App ] ──Bearer <customer access token>──→ /chat, /unitedchat, /storage, ...
Other auth providers (Auth0 / Cognito / your own): your backend calls
/customer/issue-token with the org API key and forwards the JWT to the client.⚠️ Why this exists
Embedding hb_live_… in a mobile app means anyone who decompiles the APK / IPA gets full access to your org. Web apps face the same risk through the network tab. This session-token-based Customer Auth pattern is the recommended approach for API key security in client apps — short-lived per-user tokens keep your org credential on your backend where it belongs.
When to use which credential
hb_live_…
Org API key
Server-to-server only — your backend, Cloud Function, Lambda. Never ship in a mobile or browser binary.
eyJ… (customer JWT)
Per-end-user, 1h access
Every SDK-facing call from a client app — chat, united chat (incl. room-scoped video calls), RTM, presence, push, storage, user blocks. Mint via /customer/auth/firebase-exchange (Firebase users, client-direct) or /customer/issue-token (other auth providers, server-to-server).
/api/v1/customer/auth/firebase-exchangeFor apps already using Firebase Auth. The client sends its short-lived Firebase ID token in the Authorization header — HyperBabel verifies it against Google's JWKS and mints a customer JWT for the same end-user. Register your Firebase project ID in the Console (Dashboard → Customer Auth → Firebase Projects) before calling. external_user_id, display_name, and email are auto-extracted from the verified token.
POST /api/v1/customer/auth/firebase-exchange Authorization: BearerContent-Type: application/json { "preferred_lang_cd": "ko-KR" // optional: send the device locale; the // server normalises against the supported // translation languages and falls back to 'en' }
// Same shape as /customer/issue-token { "access_token": "eyJhbGc…", "refresh_token": "eyJhbGc…", "expires_at": 1762450000, "refresh_expires_at": 1764991600, "user_id": "079632a3-…", "external_user_id": "firebase-uid", // Firebase UID "org_id": "11b20b79-…", "session_id": "02ddc384-…", "preferred_lang_cd": "ko", // canonical code actually stored "token_type": "Bearer" }
/api/v1/customer/issue-tokenMint a (access_token, refresh_token) pair scoped to one of your end-users. Server-to-server only — never call from a mobile or browser app. external_user_id is your stable opaque identifier (UUID recommended).
preferred_lang_cd is used to seed the user's preferred language on their first token issuance only. On subsequent calls for an existing user, the value stored in HyperBabel is preserved — sending a different code does not overwrite the user's chosen language. To update it explicitly, use PATCH /api/v1/customer/users/me from the client app.
POST /api/v1/customer/issue-token
Authorization: Bearer hb_live_xxx
Content-Type: application/json
{
"external_user_id": "alice-uuid",
"display_name": "Alice Chen",
"preferred_lang_cd": "en"
}{
"access_token": "eyJhbGc…",
"refresh_token": "eyJhbGc…",
"expires_at": 1762450000,
"refresh_expires_at": 1764991600,
"user_id": "079632a3-…",
"external_user_id": "alice-uuid",
"org_id": "11b20b79-…",
"session_id": "02ddc384-…",
"preferred_lang_cd": "en", // canonical code actually stored
"token_type": "Bearer"
}/api/v1/customer/refreshRotate a JWT pair using its refresh token. Safe to call directly from the client app — the refresh token in the body authenticates the request, no Authorization header needed. Old refresh token is revoked atomically; new pair has a fresh session_id.
POST /api/v1/customer/refresh
Content-Type: application/json
{
"refresh_token": "eyJhbGc…"
}// Same shape as /customer/issue-token { "access_token": "eyJhbGc…", // new "refresh_token": "eyJhbGc…", // rotated "session_id": "1b124c26-…", // new sid ... }
/api/v1/customer/revokeInvalidate every active session for one external_user_id. Global propagation under 60 seconds. Use on log-out-everywhere, password-changed, or abuse signals.
POST /api/v1/customer/revoke
Authorization: Bearer hb_live_xxx
Content-Type: application/json
{
"external_user_id": "alice-uuid"
}{
"revoked": 3
}/api/v1/customer/meVerifies the customer JWT and returns the identity it represents, plus the user's directory profile fields. Useful for client-side debugging and 'who am I' UX.
GET /api/v1/customer/me Authorization: Bearer
{
"org_id": "11b20b79-…",
"user_id": "079632a3-…",
"external_user_id": "alice-uuid",
"display_name": "Alice Chen",
"preferred_lang_cd": "en",
"photo_url": "https://cdn.hyperbabel.com/…", // null until set
"photo_variants": { "sm": "…", "md": "…", "lg": "…" }, // null until set
"bio": "Coffee & code", // null until set
"country_cd": "KR", // null until set
"interests": ["music", "travel"], // [] until set
"timezone": "Asia/Seoul", // null until set
"discoverable": true,
"status": "active",
"session_id": "02ddc384-…"
}Token lifetimes & safety boundary
/refresh./chat, /unitedchat (including room-scoped video calls), /rtm, /presence, /push, /storage, and /users (block). /video and /translate are org-API-key-only — a customer JWT gets 401 there (start calls via the United Chat room-scoped flow instead; chat translation is built in). They also cannot reach /admin, /billing, or /auth/api-keys.sender_id), get messages (room membership required), leave / mark-read (user_id), block / unblock / block-list (blocker_id), and video-call accept. On those, an id that doesn't match the token's external_user_id returns 403 forbidden (each endpoint's docs below note its guard). Other user-id fields (e.g. caller_id on call start, actor_id on add-members) are not identity-guarded — don't rely on the server rejecting a mismatched id there. Org API keys and org JWTs are backend credentials and can act on behalf of any of the org's users./revoke propagates within ~60 seconds globally via the edge cache./refresh rotates the pair atomically (the previous refresh token is revoked) and logs the current device's user-agent and IP, viewable in Console → Customer Auth → End Users.hb_live_ or hb_test_ — catches accidental regressions where a future commit reintroduces the org key.Both open-source sample demos wire the full Firebase Direct Exchange flow end-to-end — sign-in, customer JWT issuance, silent refresh, and the hb_live_/hb_test_ guard described above. Pick the platform you ship on.
HyperBabel provides two complementary chat APIs. Both share the same underlying infrastructure — choose based on how much you want HyperBabel to manage for you.
/api/v1/chat/Pure messaging layer. You manage rooms & members. Best for embedding messaging into an app that already has its own user/room management.
Includes
/api/v1/unitedchat/Complete chat solution. HyperBabel manages rooms, roles & video. Best for building a full-featured chat app quickly.
Everything in Chat API, plus
🔍 Pick by scenario
Build a KakaoTalk / LINE-style chat app
🚀United Chat APIAdd 1:1 live support / CS chatbot to my existing app
💬Chat APICommunity / fan club with open rooms and moderators
🚀United Chat APIIoT device log stream or server-to-server messaging
💬Chat APIGroup video call + chat room integration
🚀United Chat APII already manage my own rooms/users — just need messaging
💬Chat APIUnified room-based chat with built-in 1:1, Group, and Open chat support — including real-time messaging, role management, and integrated video calls. All endpoints require Authorization: Bearer hb_live_xxx and the chat scope.
/api/v1/unitedchat/roomsCreate a chat room. room_type: '1to1' | 'group' | 'open'. For 1:1, pass members with 1 user_id. For group, pass members array. Returns already_exists:true if a 1:1 between those users already exists.
// 1:1 room { "room_type": "1to1", "creator_id": "user_A", "members": ["user_B"] } // Group room { "room_type": "group", "room_name": "Project Alpha", "creator_id": "user_A", "members": ["user_B", "user_C"] } // Open room (public, up to 500 members) { "room_type": "open", "room_name": "Community Hub", "creator_id": "user_A" }
{
"room": {
"id": "uuid-xxxx",
"channel_type": "group",
"channel_name": "uc-m8z3k1",
"room_type": "group",
"realtime_channel": "hb:org_uuid:uc-m8z3k1",
"created_at": "2026-03-11T10:00:00Z"
}
}/api/v1/unitedchat/roomsGet all rooms the user is a member of, plus joinable open rooms. Pass user_id as a query param. Pass lang (e.g. 'ko', 'ja') to enable automatic description translation — translations are cached in the DB on first request so subsequent calls cost nothing extra. The response includes description_translated alongside the original description when a translation is available. Each entry in rooms also embeds last_message — the most recent message in that channel (or null if empty) — so a chat-list UI can render previews without a follow-up call per room. Deleted messages are returned with deleted_at set so the client can render a 'Message deleted' placeholder.
// GET — no body // Query: ?user_id=user_A&lang=ko
{
"rooms": [
{
"id": "uuid-xxxx",
"channel_type": "group",
"room_name": "Project Alpha",
"realtime_channel": "hb:org_uuid:uc-m8z3k1",
"member_count": "3",
"unread_count": "5",
"last_message": { // null when the room has no messages yet
"id": "msg-uuid-zzzz",
"channel_id": "uuid-xxxx",
"sender_id": "user_B",
"content": "안녕하세요",
"message_type": "text", // text | image | video | audio | file | location | contact | video_call | system
"sender_lang_cd": "ko", // ISO-639-1 the sender wrote in
"translated_content": { "en": "Hello" }, // server-side cache; populated lazily as users request translations
"metadata": null,
"deleted_at": null, // ISO timestamp when the sender removed the message
"created_at": "2026-05-20T12:34:56Z",
"updated_at": null
}
}
],
"open_rooms": [
{
"id": "uuid-yyyy",
"display_name": "Community Hub",
"description": "A space for HyperBabel developers 🚀",
"description_translated": "HyperBabel 개발자를 위한 공간 🚀", // present only when lang param is set & translation succeeds
"invite_code": "a1b2c3d4",
"member_count": "42",
"is_joinable": true
}
]
}/api/v1/unitedchat/rooms/:roomIdGet room details including full member list with roles.
// GET — no body // Path: :roomId = room UUID
{
"room": {
"id": "uuid-xxxx",
"channel_type": "group",
"display_name": "Project Alpha",
"realtime_channel": "hb:org_uuid:uc-m8z3k1",
"members": [
{ "user_id": "user_A", "role": "owner", "joined_at": "2026-03-11T10:00:00Z" },
{ "user_id": "user_B", "role": "member", "joined_at": "2026-03-11T10:01:00Z" }
]
}
}/api/v1/unitedchat/rooms/:roomId/nameSet a custom display name for the room (per-user). Each member can have their own name for the room.
{
"user_id": "user_A",
"custom_name": "My Alpha Team"
}{ "success": true }/api/v1/unitedchat/rooms/:roomId/descriptionSet or update the description for an open room (owner only). The description is visible in the open room list, helping users understand the room's purpose before joining. Pass null to clear.
{
"user_id": "owner_user",
"description": "A public room for discussing HyperBabel integrations 🚀"
}{ "success": true, "description": "A public room for discussing HyperBabel integrations 🚀" }/api/v1/unitedchat/rooms/join-by-codeJoin an open room using its 8-character invite code — no room ID required. How to get the code: open the chat room → click 🛡️ Members in the header → copy the Invite Code, or click 📋 Copy Link which generates a shareable URL like /join?code=a1b2c3d4 — extract the code param. ⚠️ user_id does NOT need to be pre-registered in HyperBabel — pass your own app's user identifier directly. Idempotent (safe to call multiple times). Banned users get 403. Full rooms get 403 room_full. On first join, broadcasts 'Yujin joined the room' system message to all members in real time.
// ── How to get the invite_code (programmatic) // Option A — List all open rooms the user hasn't joined yet: // GET /api/v1/unitedchat/rooms?user_id=X // → response.open_rooms[N].invite_code // // Option B — Get a specific room's details (member or owner): // GET /api/v1/unitedchat/rooms/:roomId // → response.room.invite_code // // Option C — UI (Console or your app): // Chat header → 🛡️ Members → Invite Code box → 📋 Copy Link // Shareable URL format: https://yourconsole.com/join?code=XXXXXXXX // ── Step 1: Authorization header (required for every request) Authorization: Bearer hb_live_xxxxxxxxxxxxxxxxxxxx // ── Step 2: Request body { // 8-char invite code obtained via API or Copy Link above "invite_code": "a1b2c3d4", // Your own app's user ID — no pre-registration in HyperBabel needed // Pass any string that uniquely identifies the user in your system "user_id": "user_C", // Optional: shown in the system message broadcast to the room // e.g. "Yujin joined the room" // Falls back to user_id if omitted "display_name": "Yujin" } // ── Error cases // 400 — invite_code or user_id missing // 403 — user is banned from this room (code: "banned") // 403 — room has reached its member limit (code: "room_full") // 404 — invalid invite code or room not found
{
"success": true,
// Use room_id for all subsequent message / member API calls
"room_id": "uuid-room-zzz",
"room_name": "HyperBabel Dev Chat",
// Subscribe to this HyperBabel Realtime channel to receive real-time messages
"realtime_channel": "hb:org_uuid:uc-m8z3k1"
}/api/v1/unitedchat/rooms/:roomId/joinJoin an open (public) room by room ID. Banned users cannot rejoin. Idempotent — safe to call on re-entry. Broadcasts a system message 'X joined the room' to all current members on a genuine first join, plus a structured `room.member_changed` realtime event with action='joined' so SDK clients can patch their member list without re-fetching.
{ "user_id": "user_C", "display_name": "Yujin" }{ "success": true }/api/v1/unitedchat/rooms/:roomId/leaveRemove a user from a room. Removes membership and custom name. For open rooms, the user can rejoin later using the room ID or invite code. Broadcasts a system message 'X left the room' to all remaining members, plus a structured `room.member_changed` realtime event with action='left'. Customer-JWT callers may only leave as themselves — passing another user's user_id returns 403 forbidden.
{ "user_id": "user_B", "display_name": "Bob" }{ "success": true }/api/v1/unitedchat/rooms/:roomId/readMark all messages in the room as read for a user. Resets the unread_count to 0 for that user. Also broadcasts a real-time read_receipt event to the room channel so the sender's client can instantly update the message status icon from ✓ (sent) to ✓✓ (read). Customer-JWT callers may only mark read as themselves — passing another user's user_id returns 403 forbidden.
{ "user_id": "user_A" }{ "success": true }
// Real-time event broadcast to all room members:
{
"type": "read_receipt",
"data": {
"user_id": "user_A",
"last_read_message_id": "uuid-msg-yyy",
"read_at": "2026-03-18T22:00:00.000Z"
}
}/api/v1/unitedchat/rooms/:roomId/messagesSend a message to a room. message_type: 'text' | 'image' | 'video' | 'audio' | 'file' | 'location' | 'contact'. Supports reply threading via reply_to. Returns 403 with code 'blocked_by_recipient' if a recipient has blocked the sender — handle this by informing the user they cannot send messages to this room. Other 403s: 'forbidden' (the sender is not a member of the room, or a customer-JWT caller passed a sender_id that is not their own user id), 'room_frozen' (the room is frozen and the sender is not owner/sub_admin), and 'banned' (the sender is banned from the open room). Sandbox traffic (test hb_test_* keys or the Console Sandbox) skips the membership and frozen checks, so demo flows can send without formal membership.
// ── Text message { "sender_id": "user_A", "content": "Hello!", "message_type": "text", "reply_to": "uuid-msg-xxx" // optional — thread reply } // ── Location message { "sender_id": "user_A", "content": "📍 Seoul City Hall", "message_type": "location", "metadata": { "name": "Seoul City Hall", "address": "Jung-gu, Seoul", "latitude": 37.5665, "longitude": 126.9780 } } // ── Contact message { "sender_id": "user_A", "content": "👤 Jane Doe", "message_type": "contact", "metadata": { "name": "Jane Doe", "phone": "+82-10-1234-5678", "email": "[email protected]" } }
{
"message": {
"id": "uuid-msg-yyy",
"channel_id": "uuid-xxxx",
"sender_id": "user_A",
"content": "📍 Seoul City Hall",
"message_type": "location",
"metadata": { "name": "Seoul City Hall", "latitude": 37.5665, "longitude": 126.9780 },
"created_at": "2026-03-18T10:05:00Z"
}
}/api/v1/unitedchat/rooms/:roomId/messagesGet messages for a room with cursor-based pagination (newest first). Pass cursor — the next_cursor from the previous response — to load older messages. Results default to the last 3 months; use created_at_gte / created_at_lte to change the time range. The response also includes read_statuses for all members — use these to render delivery/read icons (✓ sent, ✓✓ read, N unread count for group rooms). Customer-JWT callers must be a member of the room — non-members get 403 forbidden (org API keys are unrestricted within the org).
// GET — no body // Query params (all optional): ?cursor=uuid-msg-yyy // next_cursor from the previous page (older messages) ?limit=50 // default: 50, max: 100 ?created_at_gte=2026-03-01T00:00:00Z // default: last 3 months ?created_at_lte=2026-03-18T23:59:59Z
{
"messages": [
{
"id": "uuid-msg-yyy",
"sender_id": "user_A",
"content": "Hello everyone!",
"message_type": "text",
"created_at": "2026-03-11T10:05:00Z"
}
],
"has_more": true,
"next_cursor": "uuid-msg-aaa",
"read_statuses": [
{
"user_id": "user_B",
"last_read_message_id": "uuid-msg-yyy",
"last_read_at": "2026-03-11T10:06:00Z"
}
]
}/api/v1/unitedchat/rooms/:roomId/banBan a user from a room. Only room owner or sub_admin can ban. Banned users are removed from membership and cannot rejoin. Publishes a `room.member_changed` realtime event with action='banned' so the banned user's client can navigate them out and other members can update their member list instantly.
{
"user_id": "user_B",
"banned_by": "user_A"
}{ "success": true, "banned_user": "user_B", "banned_by": "user_A" }/api/v1/unitedchat/rooms/:roomId/ban/:userIdLift a ban for a user. Only owner or sub_admin can unban. Publishes a `room.member_changed` realtime event with action='unbanned'. Note: the unbanned user must call POST /join to rejoin — unban only removes the bar.
{ "unbanned_by": "user_A" }{ "success": true, "unbanned_user": "user_B" }/api/v1/unitedchat/rooms/:roomId/bansList all ban records for a room, most recent first. Includes both active bans and lifted (unbanned) records — check is_active to distinguish. Useful for rendering a moderation history or a 'banned users' admin panel.
// GET — no body{
"bans": [
{
"user_id": "user_B",
"banned_by": "user_A",
"banned_at": "2026-03-11T10:05:00Z",
"unbanned_by": null,
"unbanned_at": null,
"is_active": true
}
]
}/api/v1/unitedchat/rooms/:roomId/sub-adminsPromote a member to sub_admin role. Only the room owner can promote. Maximum 10 sub_admins per room. Publishes a `room.member_changed` realtime event with action='promoted' and new_role='sub_admin' so client UIs can refresh role badges live.
{
"user_id": "user_B",
"owner_id": "user_A"
}{ "success": true, "sub_admin": "user_B" }/api/v1/unitedchat/rooms/:roomId/sub-admins/:userIdDemote a sub_admin back to member. Only the room owner can demote (owner_id is required and validated). Publishes a `room.member_changed` realtime event with action='demoted' and new_role='member' so client UIs refresh role badges live. No-op if the target is not currently a sub_admin.
{ "owner_id": "user_A" }{ "success": true }/api/v1/unitedchat/rooms/:roomId/membersAdd one or more users to a group room. Only the room owner or a sub_admin can add members (actor_id is validated) — group rooms only (1:1 and open rooms are rejected). Idempotent per user: users who are banned, already members, or over the room's member capacity (set from your plan's group member limit at creation) are not added — they are reported in skipped with a reason instead of failing the whole call. Each successful add broadcasts a `room.member_changed` realtime event with action='joined' and writes an 'X was added to the room' system message.
{
"actor_id": "user_A", // must be the room owner or a sub_admin
"user_ids": ["user_D", "user_E"] // non-empty array, deduplicated server-side
}
// ── Error cases
// 400 invalid_request — actor_id missing, or user_ids empty
// 400 — target room is not a group room
// 403 forbidden — actor is not owner / sub_admin
// 404 not_found — room does not exist in this org{
"success": true,
"added": ["user_D"],
"skipped": [
{ "user_id": "user_E", "reason": "already_member" } // banned | room_full | already_member
]
}/api/v1/unitedchat/rooms/:roomId/membersGet paginated member list for a room, ordered by role (owner → sub_admin → member). Supports page/limit pagination and partial name search.
// GET — no body // Query params (all optional): ?page=1 // Page number, default: 1 ?limit=50 // Members per page, default: 50, max: 100 ?search=alice // Filter by user name (partial, case-insensitive)
{
"members": [
{ "user_id": "user_A", "role": "owner", "joined_at": "2026-03-11T10:00:00Z" },
{ "user_id": "user_B", "role": "sub_admin", "joined_at": "2026-03-11T10:01:00Z" },
{ "user_id": "user_C", "role": "member", "joined_at": "2026-03-11T10:02:00Z" }
],
"total": 142,
"page": 1,
"limit": 50,
"total_pages": 3
}/api/v1/unitedchat/rooms/:roomId/messages/:messageIdSoft-delete a message. Sets deleted_at — the message is excluded from getMessages responses. Only the original sender, room owner, or sub_admin can delete.
{ "user_id": "user_A" }{ "success": true, "message_id": "uuid-msg-xxx", "deleted_at": "2026-03-11T15:00:00Z" }/api/v1/unitedchat/rooms/:roomId/messages/:messageIdEdit a text message. Only the original sender can edit. Only text type messages are editable. The original content is preserved in original_content for audit trail. edited_at is set automatically.
{
"user_id": "user_A",
"content": "Updated message content"
}{
"success": true,
"message": {
"id": "uuid-msg-xxx",
"content": "Updated message content",
"original_content": "Original message content",
"edited_at": "2026-03-11T15:01:00Z"
}
}/api/v1/unitedchat/rooms/:roomId/muteMute push notifications for a room. Omit duration_minutes for indefinite mute.
{
"user_id": "user_A",
"duration_minutes": 60 // optional — omit for indefinite
}{
"success": true,
"channel_id": "uuid-xxxx",
"muted_until": "2026-03-11T16:00:00Z",
"is_indefinite": false
}/api/v1/unitedchat/rooms/:roomId/muteRemove mute from a room for a user.
{ "user_id": "user_A" }{ "success": true, "channel_id": "uuid-xxxx", "user_id": "user_A" }/api/v1/unitedchat/rooms/:roomId/mute/:userIdCheck whether a user has muted a room and until when.
// GET — no body{
"is_muted": true,
"channel_id": "uuid-xxxx",
"user_id": "user_A",
"muted_until": "2026-03-11T16:00:00Z",
"is_indefinite": false
}/api/v1/unitedchat/rooms/:roomId/freezeFreeze a room so that only the owner and sub_admins can send messages. Only owner or sub_admin can freeze. Publishes a `room.updated` realtime event so all connected clients flip the room into frozen mode (input becomes read-only) without a re-fetch.
{ "user_id": "user_A" }{ "success": true, "is_frozen": true, "channel_id": "uuid-xxxx" }/api/v1/unitedchat/rooms/:roomId/freezeUnfreeze a room to restore normal messaging for all members. Publishes a `room.updated` realtime event so connected clients re-enable the message input immediately.
{ "user_id": "user_A" }{ "success": true, "is_frozen": false, "channel_id": "uuid-xxxx" }/api/v1/unitedchat/rooms/:roomId/pin/:messageIdPin a message to the top of the room. Only 1 pinned message per room at a time — pinning a new message automatically replaces the existing pin. Supports all message types (text, image, file, video, audio, location, contact) except system and video_call messages. Permission: 1:1 rooms — any member; group / open rooms — owner or sub_admin only.
{ "user_id": "user_A" }{ "success": true, "pinned_message_id": "msg-uuid-xxxx" }/api/v1/unitedchat/rooms/:roomId/pinRemove the currently pinned message from the room. Permission: 1:1 rooms — any member; group / open rooms — owner or sub_admin only.
{ "user_id": "user_A" }{ "success": true }/api/v1/unitedchat/rooms/:roomId/searchSearch messages in a room using high-performance full-text search. Always provide created_at_gte + created_at_lte to enable indexing optimizations — max range 6 months, defaults to last 6 months. Results include a highlight field with matched terms wrapped in <mark> tags. Query syntax: spaces = AND, quotes = phrase match.
// Query params ?q=hello+world // Required — search query (spaces=AND, quotes=phrase) ?limit=20 // Optional, max 50 ?created_at_gte=2026-03-01T00:00:00Z ?created_at_lte=2026-03-11T23:59:59Z // Response header X-Applied-Created-At-Gte:
{
"messages": [
{
"id": "msg_456",
"sender_id": "user_123",
"sender_name": "Alice",
"content": "Hello World!",
"highlight": "Hello World!",
"created_at": "2026-03-01T10:01:00Z"
}
],
"query": "hello world",
"applied_range": {
"gte": "2025-09-11T23:37:00Z",
"lte": "2026-03-11T23:37:00Z"
}
}/api/v1/unitedchat/rooms/:roomId/typingBroadcast a typing event to eligible room members via the real-time channel. HyperBabel applies per-user send/receive preferences (configurable via the Typing Prefs API) and only publishes when at least one member has receive enabled. Supported for: 1:1 rooms, and group rooms with ≤ 4 members. Self-chat rooms are automatically skipped. Recommended: call when the user starts typing, throttled to once every 2 seconds. Recipients should auto-clear the indicator after 3 seconds of no new events.
{
"user_id": "user_A",
"display_name": "Alice" // shown as "Alice is typing..."
}// Published: HyperBabel real-time channel broadcast { "success": true, "user_id": "user_A" } // Skipped (non-error): send/recv prefs not met, group > 4 members, or self-chat { "success": true, "skipped": true, "reason": "send_disabled" | "no_recv_enabled" | "group_too_large" } // Real-time event payload received by eligible room members: { "type": "typing", "userId": "user_A", "userName": "Alice" }
/api/v1/unitedchat/rooms/:roomId/typing-prefsReturns each member's per-room typing indicator preferences (send and receive). If a member has not yet configured their preferences, both fields default to true (fail-open policy — typing indicators are ON by default unless explicitly disabled).
// No body required{
"prefs": [
{ "user_id": "user_A", "send_enabled": true, "recv_enabled": false, "updated_at": "..." },
{ "user_id": "user_B", "send_enabled": false, "recv_enabled": true, "updated_at": "..." }
]
}/api/v1/unitedchat/rooms/:roomId/typing-prefsSave or update the calling user's per-room typing indicator preferences. Uses UPSERT semantics — safe to call on every room entry. send_enabled controls whether this user's typing events are published. recv_enabled controls whether this user receives others' typing events. Defaults are true (fail-open) — both indicators are ON unless the user explicitly disables them.
{
"user_id": "user_A",
"send_enabled": true, // default: true (fail-open)
"recv_enabled": true // default: true (fail-open)
}{ "success": true, "send_enabled": true, "recv_enabled": true }Real-time messaging with channel management, cursor-based pagination, reactions, read receipts, and built-in AI translation. All endpoints require Authorization: Bearer hb_live_xxx and the chat scope.
/api/v1/chat/channelsCreate a new chat channel. channel_type: 'public' | 'private' | 'group'. max_members defaults to 100.
{
"channel_name": "general-discussion",
"channel_type": "group",
"max_members": 100
}// 201 Created { "id": "ch_123", "channel_name": "general-discussion", "channel_type": "group", "max_members": 100, "created_at": "2026-03-01T10:00:00Z" }
/api/v1/chat/channelsList all channels belonging to your organization. Optionally filter by channel_type.
// Query params (all optional) ?channel_type=group // filter: public | private | group // Headers Authorization: Bearer hb_live_xxx
{
"channels": [
{
"id": "ch_123",
"channel_name": "general-discussion",
"channel_type": "group",
"member_count": 12,
"created_at": "2026-03-01T10:00:00Z"
}
]
}/api/v1/chat/channels/:channelId/messagesSend a message to a channel. message_type: 'text' | 'image' | 'file' | 'system' | 'location' | 'contact' | 'video'. For file messages, set content to the file URL from the Storage API and include metadata.file. Note: sender_id must match ^[a-zA-Z0-9_-]+$ (letters, digits, underscore, hyphen).
// Text message { "sender_id": "user_123", "content": "Hello World!", "message_type": "text" } // File message (after Storage presign + confirm) { "sender_id": "user_123", "content": "https://cdn.hyperbabel.com/...", "message_type": "image", "metadata": { "file": { "original_name": "photo.jpg", "size_bytes": 524288, "mime_type": "image/jpeg" } } }
// 201 Created { "id": "msg_456", "channel_id": "ch_123", "sender_id": "user_123", "content": "Hello World!", "message_type": "text", "created_at": "2026-03-01T10:01:00Z" }
/api/v1/chat/channels/:channelId/messagesRetrieve paginated message history using cursor-based pagination. Use cursor from the previous response to load the next page. Results are ordered newest-first.
// Query params (all optional, fully combinable) ?limit=50 // messages per page, default: 50 ?cursor=msg_456 // message ID to paginate from (exclusive) // omit for first page (latest messages) // Example — second page: ?limit=50&cursor=msg_456 // Headers Authorization: Bearer hb_live_xxx
{
"messages": [
{
"id": "msg_456",
"channel_id": "ch_123",
"sender_id": "user_123",
"content": "Hello World!",
"message_type": "text",
"sender_lang_cd": "en", // detected language the sender wrote in
"translated_content": { // server-side translation cache
"ja": "こんにちは!" // populated lazily (e.g. via batch-translate)
}, // null when no translation cached yet
"created_at": "2026-03-01T10:01:00Z"
}
],
"has_more": true,
"next_cursor": "msg_200"
}/api/v1/unitedchat/rooms/:roomId/messages/batch-translateTranslate one or many messages to a target language in a single request. Pass message_ids: [single_id] to translate one message; the same endpoint covers both single and batch use cases. Up to 100 messages per call. Cached translations (per-message JSONB) are returned without re-billing; only cache misses incur translate_chars usage.
{
"message_ids": ["msg_456", "msg_457", "msg_458"],
"target_lang": "en"
}{
"translations": {
"msg_456": "Hello!",
"msg_457": "Hello!"
}
}/api/v1/chat/messages/:messageId/reactionsAdd an emoji reaction to a message. Each user can react once per emoji per message.
{
"user_id": "user_123",
"emoji": "👍"
}{
"message_id": "msg_456",
"emoji": "👍",
"user_id": "user_123",
"created_at": "2026-03-01T10:05:00Z"
}/api/v1/chat/messages/:messageId/reactionsRemove a previously added reaction from a message.
{
"user_id": "user_123",
"emoji": "👍"
}{
"success": true,
"message_id": "msg_456",
"emoji": "👍"
}/api/v1/chat/channels/:channelId/readMark all messages in a channel as read up to the current time for a given user. Updates the unread count to 0.
{
"user_id": "user_123"
}{
"success": true,
"channel_id": "ch_123",
"read_at": "2026-03-01T10:10:00Z"
}📡 Receiving Messages in Real-time (HyperBabel Realtime)
// Subscribe to the channel via HyperBabel Realtime // Channel pattern: hb:{orgIdWithoutDashes}:{channel_name} // (test hb_test_* keys get sandbox channels: sandbox:chat:{channel_name}; // live hb_live_ keys always get the hb:{org}:{channel_name} pattern) // The exact name is returned as `realtime_channel` by POST /chat/channels. // Event name: "message" // Payload envelope: { "type": "message", "timestamp": "2026-03-01T10:01:00.123Z", "data": { "id": "msg_456", "channel_id": "ch_123", "sender_id": "user_123", "content": "Hello World!", "message_type": "text", "sender_lang_cd": "en", "translated_content": null, // always null at send time "created_at": "2026-03-01T10:01:00Z" } } // Cached translations live in `translated_content` — populated lazily // as viewers request them (e.g. via batch-translate), and returned with // message history. Display translated_content[viewer_language] when // present; fall back to content.
/api/v1/chat/channels/:channelId/messages/:messageIdSoft-delete a message. Sets deleted_at — the message is excluded from all subsequent getMessages responses. Only the sender, or an admin who has permission, can delete.
{ "user_id": "user_123" }{ "success": true, "message_id": "msg_456", "deleted_at": "2026-03-11T15:00:00Z" }/api/v1/chat/channels/:channelId/messages/:messageIdEdit a text message. Only the original sender can edit. Only text type messages are editable. The original content is saved in original_content for audit. edited_at is set automatically.
{
"user_id": "user_123",
"content": "Updated message content"
}{
"success": true,
"message": {
"id": "msg_456",
"content": "Updated message content",
"original_content": "Original message content",
"edited_at": "2026-03-11T15:01:00Z"
}
}/api/v1/chat/channels/:channelId/searchSearch messages using high-performance full-text search. Always provide created_at_gte + created_at_lte to enable indexing optimizations — max range 6 months, defaults to last 6 months. Results include a highlight field with matched terms wrapped in <mark> tags.
// Query params ?q=hello+world // Required — search query (spaces=AND, quotes=phrase) ?limit=20 // Optional, max 50 ?created_at_gte=2026-03-01T00:00:00Z ?created_at_lte=2026-03-11T23:59:59Z // Response header X-Applied-Created-At-Gte:
{
"messages": [
{
"id": "msg_456",
"sender_id": "user_123",
"content": "Hello World!",
"highlight": "Hello World!",
"created_at": "2026-03-01T10:01:00Z"
}
],
"query": "hello world",
"applied_range": {
"gte": "2025-09-11T23:37:00Z",
"lte": "2026-03-11T23:37:00Z"
}
}/api/v1/chat/channels/:channelId/typingBroadcast a typing event via the real-time channel. No DB storage — fully stateless. Call with is_typing: true when the user starts typing, and is_typing: false when they stop or send a message.
{
"user_id": "user_123",
"is_typing": true
}{ "success": true, "user_id": "user_123", "is_typing": true }
// Real-time event received by other clients:
// event: "typing"
// data: { "user_id": "user_123", "is_typing": true, "timestamp": "..." }/api/v1/chat/channels/:channelId/messages/:messageId/threadGet all reply messages for a parent message (reply_to = messageId). Ordered oldest-first. Supports cursor pagination. created_at_gte defaults to the parent message's created_at automatically.
// Query params (all optional)
?limit=50
?cursor=msg_xxx // last message ID from previous page{
"parent_message": {
"id": "msg_456",
"content": "Hello World!",
"sender_id": "user_123",
"created_at": "2026-03-01T10:01:00Z"
},
"thread": [
{
"id": "msg_457",
"sender_id": "user_124",
"content": "Hi!",
"reply_to": "msg_456",
"created_at": "2026-03-01T10:02:00Z"
}
],
"has_more": false,
"next_cursor": null,
"thread_count": 1
}Lightweight online/offline presence tracking backed by DB heartbeat polling — no additional real-time channel costs. Clients call /heartbeat every 30 seconds. Users with no heartbeat for 90 seconds are considered offline.
/api/v1/presence/heartbeatCall every 30 seconds to stay marked as online. If no heartbeat is received for 90 seconds, the user is automatically considered offline. **Billing.** Each unique (org_id, current_minute, user_id) triggers +1 `connection_min` in the monthly rollup (server-side per-minute dedup — heartbeats more frequent than 1/min are absorbed without double-counting). Heartbeats slower than 1/min may miss minute buckets. Mobile demos auto-pause the heartbeat when the app backgrounds (JS runtime suspension / Swift Task suspension / Compose lifecycle cancellation).
{
"user_id": "user_123",
"device": "mobile" // optional
}{ "success": true, "status": "online" }/api/v1/presence/statusExplicitly set a user's status. Useful for manual away/dnd modes.
{
"user_id": "user_123",
"status": "dnd" // online | away | dnd | offline
}{ "success": true, "user_id": "user_123", "status": "dnd" }/api/v1/presenceGet presence status for up to 100 users at once. Users not found in presence table are returned as offline.
// Query
?user_ids=user_123,user_124,user_125 // comma-separated, max 100{
"presence": [
{ "user_id": "user_123", "status": "online", "last_seen": "2026-03-11T23:55:00Z", "device": "mobile" },
{ "user_id": "user_124", "status": "offline", "last_seen": null, "device": null }
],
"offline_threshold_seconds": 90
}Global user blocking within an organization. When user A blocks user B, any message B tries to send in a shared room is rejected server-side with 403 blocked_by_recipient. The response body includes a blocked_by array with the blocker's user ID. Note: sandbox requests bypass this check.
/api/v1/users/blockBlock a user globally within the org. Once blocked, any message the blocked user sends to a shared room is rejected server-side with 403 blocked_by_recipient. Sandbox requests skip this check. Errors: 400 invalid_request (blocker_id/blocked_id missing, or attempting to block yourself); 403 forbidden (customer-JWT callers may only block as themselves — blocker_id must be their own user id).
{
"blocker_id": "user_A",
"blocked_id": "user_B"
}{ "success": true, "blocker_id": "user_A", "blocked_id": "user_B" }/api/v1/users/blockRemove a block between two users. Errors: 400 invalid_request (blocker_id/blocked_id missing); 403 forbidden (customer-JWT callers may only remove their own block rows).
{
"blocker_id": "user_A",
"blocked_id": "user_B"
}{ "success": true, "blocker_id": "user_A", "blocked_id": "user_B" }/api/v1/users/:userId/blocksGet the list of user IDs that userId has blocked. Block lists are private: customer-JWT callers may only read their own list — requesting another user's returns 403 forbidden (org API keys keep full access for support/moderation tooling).
// GET — no body{
"user_id": "user_A",
"blocked_users": [
{ "blocked_id": "user_B", "created_at": "2026-03-11T10:00:00Z" }
]
}Register and manage FCM (Android/Web) or APNs (iOS) device tokens. Tokens are used by HyperBabel to deliver push notifications for new messages when users are offline.
💡 To enable actual push delivery, upload your Firebase service account JSON in the console dashboard under Push Settings. Without this, tokens are stored but no pushes are sent.
/api/v1/push/registerRegister a device token. If the same token already exists for this user, updated_at is refreshed.
{
"user_id": "user_123",
"token": "fcm_device_token_here",
"platform": "android" // ios | android | web
}{ "success": true, "user_id": "user_123", "platform": "android" }/api/v1/push/unregisterRemove a device token. Call this on logout or when the token is refreshed.
{
"user_id": "user_123",
"token": "fcm_device_token_here"
}{ "success": true, "user_id": "user_123" }/api/v1/push/tokensGet all registered device tokens for a user.
// Query
?user_id=user_123{
"user_id": "user_123",
"tokens": [
{ "token": "fcm_device_token_here", "platform": "android", "updated_at": "2026-03-11T10:00:00Z" }
]
}Full video call infrastructure with a high-performance media layer and a real-time signaling layer. Supports 1:1 calls and, on plans that include group video, group calls. All events are automatically recorded as chat messages when linked to a chat room.
quality field on session creation — POST /api/v1/video/sessions or POST /api/v1/unitedchat/rooms/:roomId/video-call. It cannot be set or changed on /join, and it defaults to 'hd', so a session you never declare is billed as HD regardless of what you actually publish. The value is echoed back in the create response and in the video.session.created webhook — log it if you want a record of what you declared.POST /unitedchat/rooms/:roomId/video-call) allow up to 3 participants total (caller + 2 target_user_ids); standalone sessions (POST /video/sessions) allow up to 4. Plans without group video are limited to 2 participants (1:1) and return 400 plan_upgrade_required; asking for more than the route's own ceiling returns 400 limit_exceeded.1:1 Video Call
Caller → Callee (Invite/Accept/Reject). Either party leaving auto-ends the session via the activeCount ≤ 1 rule.
Group Video Call
Up to 3 participants on the room-scoped route (4 via /video/sessions), and only on plans that include group video. Individual /leave keeps session alive for others. Last active participant leaving auto-ends.
Create a video session that is not tied to a chat room — use this when your app has its own call UI and does not need the automatic call_started / call_ended chat messages. Organisation API key only (customer JWTs cannot create sessions); the tokens it returns are what your clients join with.
/api/v1/video/sessionsAllocate a media channel and issue an RTC token per participant. The response is the only place tokens are returned — they are not retrievable later (use /renew-token to refresh). **Declared quality.** `quality` is `'hd'` (default) | `'fhd'` | `'2k'` | `'2k_plus'` and decides which bucket the session's user-minutes are billed into (`hd_video_mins` / `fhd_video_mins` / `k2_video_mins` / `k2plus_video_mins`). It is fixed at creation — to change tiers, end the session and create a new one. Media flows directly between your app and our infrastructure, so we cannot observe the real resolution: declaring it accurately is a contractual obligation, and group calls are metered on AGGREGATE received resolution (a 4-party call at 720p per stream already sits above the 1:1 HD ceiling).\n\n**Optional self-check — `publish_resolution`.** Send it as `{ width: 1280, height: 720 }` — the resolution this session will actually publish **at this participant count** (not your camera's maximum) and we will check your declaration for you. Each participant receives every other stream, so we multiply by `participants` − 1. We add that up and compare it with `quality`; if the sum lands in a higher tier the response carries a `quality_warning` string. The call still succeeds and billing still follows `quality` — the field exists because the most common billing surprise is an honest 720p declaration on a group call, not a false one. If your app lowers the resolution as people join, send the value for the size you are joining at. **Participant cap.** `participants` may hold up to 4 entries on plans that include group video (once a dedicated realtime project is provisioned); otherwise the cap is 2 (1:1). Sandbox (`hb_test_*`) keys always get the full cap — sandbox traffic is not billed. **Billing.** Minutes are counted per participant from their join time to session end, so a 10-minute call between two people records 20 user-minutes. Sandbox sessions are audit-logged but excluded from the monthly rollup, and they auto-end after 10 minutes.
{
"call_type": "1to1", // "1to1" (default) | "group"
"quality": "hd", // optional — "hd" (default) | "fhd" | "2k" | "2k_plus"
"publish_resolution": { // optional — what you will actually publish, at THIS size
"width": 1280, "height": 720
},
"participants": [ // 1–4 entries (plan-dependent, see above)
{ "user_id": "user_A", "display_name": "Ann", "role": "publisher" },
{ "user_id": "user_B", "display_name": "Ben", "role": "publisher" }
],
"settings": { } // optional, free-form; echoed back on the session
}// 201 Created { "session": { "id": "b6f1…", "channel_name": "org-…-video-4f2a", "rtm_channel": "sandbox_video:b6f1…", "call_type": "1to1", "status": "created", "quality": "hd", // ← the tier this session bills at "quality_warning": null, // ← set only if publish_resolution implies a higher tier "app_id": "" , "participants": [ { "user_id": "user_A", "uid": 1, "rtc_token": "…", "status": "active" }, { "user_id": "user_B", "uid": 2, "rtc_token": "…", "status": "active" } ] // "sandbox_time_limit_seconds": 600 ← ONLY on sandbox (hb_test_*) sessions } } // 400 — plan is 1:1 only { "error": { "code": "plan_upgrade_required", "message": "Maximum 2 participants for a video call on your plan" } } // 402 — monthly spend limit reached (calls already running are unaffected) { "error": { "code": "spend_cap_reached", "details": { "spent": 312.4, "cap": 297 } } } // 403 — free-plan monthly quota for this quality tier is exhausted { "error": { "code": "quota_exceeded" } } // 400 — sandbox (test key) only runs at HD { "error": { "code": "sandbox_quality_not_allowed" } } // 403 — this month's sandbox allowance for the feature is used up { "error": { "code": "sandbox_limit_reached" } } // 409 — one of the participants is already in another live session { "error": { "code": "targets_busy" } }
A calls B. B receives a real-time CALL_INVITE signal via HyperBabel Realtime. B accepts or rejects. When either party ends the call, the entire session terminates.
// 1:1 Call Flow (room-scoped)
Caller (A) Callee (B)
| |
|-- POST /rooms/:id/video-call -------->| // CALL_INVITE via HyperBabel Realtime
|<-- /rooms/:id/video-call/accept ------| // B accepts → joins RTC channel
| or /reject (reason: declined | |
| busy | missed) |
|======= RTC Media Stream ==============|
|-- POST /rooms/:id/video-call/leave -->| // Either side calls /leave
| | // → activeCount≤1 → session auto-ends
| | // → call.ended broadcast📊 Incoming Call Lifecycle — Accept / Reject / Busy
When a call is initiated, the callee receives a real-time CALL_INVITE event on their private channel. Depending on the callee's state, one of three paths is taken: accept, reject (or 30 s timeout), or a silent busy rejection when the callee is already in another call.
IncomingCallListener.jsx + IncomingCallOverlay.jsx
✅ Accept
POST /video-call/accept
Callee joins RTC channel
❌ Reject
POST /video-call/reject (reason: declined)
call_rejected logged
⏱ 30 s Timeout
POST /video-call/reject (reason: missed)
call_missed logged · caller exits
📵 Busy (already in a call)
POST /video-call/reject (reason: busy)
call_busy logged · caller toast
/api/v1/unitedchat/rooms/:roomId/video-callInitiate a 1:1 or group video call linked to a chat room. Generates RTC tokens, sends CALL_INVITE signals, records a call_started system message. Declare the billing resolution tier with `quality`: `'hd'` (default) | `'fhd'` | `'2k'` | `'2k_plus'`. The value is persisted on the session row and decides which bucket the call's user-minutes land in (`hd_video_mins` / `fhd_video_mins` / `k2_video_mins` / `k2plus_video_mins`). It cannot be changed mid-session — end the session and create a new one to switch tiers. Declaring the tier your app actually transmits and receives is a contractual obligation; on group calls providers meter by AGGREGATE received resolution, so a multi-party call can sit a tier above the same per-stream resolution in 1:1.\n\n**Optional self-check — `publish_resolution`.** Send it as `{ width: 1280, height: 720 }` — the resolution this session will actually publish **at this participant count** (not your camera's maximum) and we will check your declaration for you. Each participant receives every other stream, so we multiply by the participant count − 1. We add that up and compare it with `quality`; if the sum lands in a higher tier the response carries a `quality_warning` string. The call still succeeds and billing still follows `quality` — the field exists because the most common billing surprise is an honest 720p declaration on a group call, not a false one. If your app lowers the resolution as people join, send the value for the size you are joining at.
{
"caller_id": "user_A",
"target_user_ids": ["user_B"],
"quality": "hd", // optional — "hd" (default) | "fhd" | "2k" | "2k_plus"
"publish_resolution": { "width": 1280, "height": 720 } // optional self-check
}{
"session": {
"id": "sess_789",
"channel_name": "uc-video-mm62pcca",
"call_type": "1to1",
"status": "created",
"quality": "hd", // ← the tier this session bills at
"participants": [
{ "user_id": "user_A", "uid": 1, "rtc_token": "...", "status": "active" },
{ "user_id": "user_B", "uid": 2, "rtc_token": "...", "status": "invited" }
],
"app_id": ""
},
"quality_warning": null // ← set only if publish_resolution implies a higher tier
}/api/v1/unitedchat/rooms/:roomId/video-call/acceptRecord call acceptance. Marks participant status as active. The accepting user joins the RTC media channel using their rtc_token.
{
"user_id": "user_B",
"session_id": "sess_789"
}{
"success": true,
"session_id": "sess_789"
}/api/v1/unitedchat/rooms/:roomId/video-call/rejectRecord a call rejection. Reason: "declined" | "busy" | "missed". A CALL_REJECT or CALL_BUSY signal is sent to the caller via HyperBabel Realtime.
{
"user_id": "user_B",
"session_id": "sess_789",
"reason": "busy"
}{
"success": true
}/api/v1/unitedchat/rooms/:roomId/video-call/leaveEither participant calls /leave when they're done. When active participants drop to 1 or below, the backend automatically marks the session ended, broadcasts call.ended, and records the call_ended chat message with duration. This is the recommended ending pattern for room-scoped 1:1 calls — it works for both caller and callee without requiring host-only permission. **Billing.** When this call ends the session (last active participant left), it writes the call's user-minutes into the bucket for `session.quality` (`hd_video_mins` / `fhd_video_mins` / `k2_video_mins` / `k2plus_video_mins`) and bumps `api_calls`. Minutes are user-minutes — a 10-minute call between two people records 20. Sandbox-environment sessions skip the monthly rollup. Customer-JWT-authenticated callers (Firebase Direct Exchange mobile apps) are metered identically to API-key callers — this route was the critical fix path for ensuring customer-JWT video calls get billed.
{
"session_id": "sess_789",
"user_id": "user_A"
}// Last active participant — session auto-ends { "success": true, "action": "session_ended", "active_count": 0 }
/api/v1/unitedchat/rooms/:roomId/video-call/endForcefully terminate the session for ALL participants. Permission gate: only the original caller, the room owner, or a sub_admin can call this. Regular participants should use /leave instead — calling /end returns 403 forbidden. Useful for moderators dismissing a runaway group call. **Billing.** Same metering as /leave — records user-minutes into the bucket for `session.quality` (`hd_video_mins` / `fhd_video_mins` / `k2_video_mins` / `k2plus_video_mins`) plus an `api_calls` bump. Sandbox skipped from monthly. Customer-JWT and API-key callers metered identically.
{
"session_id": "sess_789",
"user_id": "user_A"
}{
"success": true,
"duration_seconds": 222
}
// Non-host attempt:
// 403 { "error": { "code": "forbidden", "message": "Only the caller or a room owner / sub_admin can end the call for all. Use /leave to drop only yourself." } }A participant can leave individually without ending the session. The session auto-ends when the last active participant leaves.
target_user_ids) on this route; POST /video/sessions allows 4. Requesting more returns 400 limit_exceeded. On plans without group video the cap is 2 (1:1) and the error is 400 plan_upgrade_required; joining a full session returns 409 call_full.// Group Call Flow
Caller (A) Invitee (B) Invitee (C)
|-- POST /video-call ->|-- CALL_INVITE ----->|
|<===== RTC media channel (bidirectional) =====>|
| |
|-- A leaves -------->| | // POST /leave → A status="left"
| |<-- call.participant_left // toast shown to B, C
|-- B leaves -------->| | // POST /leave → 1 active → auto end
| | call.ended | // → C exits/api/v1/unitedchat/rooms/:roomId/video-call/leaveLeave a group video call without ending the session for others. If active participants drop to 1 or below, the session auto-ends and call.ended is published.
{
"session_id": "sess_789",
"user_id": "user_A"
}// Case 1: Others still active { "success": true, "action": "participant_left", "active_count": 2 } // Case 2: Last participant → session auto-ended { "success": true, "action": "session_ended", "active_count": 0 }
/api/v1/unitedchat/rooms/:roomId/video-call/activeCheck if a chat room has an ongoing video call. Show a 'Join ongoing call' banner instead of 'Start call' when active=true.
// No body — GET request // Header: Authorization: Bearer
{
"active": true,
"session": {
"id": "sess_789",
"call_type": "group",
"status": "active",
"active_participant_count": 2,
"participants": [...]
}
}
// or: { "active": false, "session": null }/api/v1/video/sessions/:sessionId/joinJoin an in-progress group call. Generates a new RTC token. If the user previously left, their status is restored to 'active'. Returns 400 if session is already ended. **Billing tier is not set here.** The session's `quality` was fixed when it was created and cannot be changed by joining — to bill at a different tier, end the session and create a new one. Joining a call whose plan does not allow more participants returns `409 call_full`.
{
"user_id": "user_D",
"display_name": "Diana"
}{
"session": { "id": "sess_789", "status": "active" },
"token": "" ,
"uid": 4
}/api/v1/unitedchat/rooms/:roomId/video-call/renew-tokenMint a fresh RTC token for the current user's participant slot in the room's active session. Pair this with the media SDK's 'token will expire' callback — the issued RTC tokens last 1 hour, so calls that exceed that (or that get re-joined after a long pause) will otherwise silently drop. The endpoint does not write to the session — only the token is regenerated, with the same uid + channel + publisher role. Returns 403 forbidden if the caller is not a participant of the session, 404 if no active session exists.
{
"user_id": "user_A",
"session_id": "sess_789" // optional — defaults to the room's most recent live session
}{
"session_id": "sess_789",
"channel_name": "uc-video-mm62pcca",
"uid": 1,
"rtc_token": "" ,
"app_id": "" ,
"expires_in": 3600
}HyperBabel includes a built-in real-time signaling layer for call events. Your app receives these events through the HyperBabel Realtime API. A Realtime token scoped to your org is obtained via the token endpoint.
private:user:{orgId}:{callee}Sent to each invitee when a call is initiated. Carries the callee's RTC credentials, the actual call cardinality, and the full target list so the receiving overlay can render its label (e.g. "Group call · with 3 others") without an extra room fetch.
{
"type": "CALL_INVITE",
"sessionId": "sess_789",
"callerId": "user_A",
"callerName": "Alice",
"callType": "1to1" | "group", // call cardinality
"mediaType": "video", // media kind
"channelName": "uc-video-mm62pcca",
"rtcToken": "" ,
"uid": 2,
"app_id": "" ,
"roomId": "room_xyz",
"roomSignalChannel": "" ,
"target_count": 3, // total invitees (excl. caller)
"target_ids": ["user_B","user_C","user_D"],
"target_names": { // { user_id → display_name }
"user_B": "Bob", "user_C": "Carol", "user_D": "Dave"
}
}private:user:{orgId}:{caller}Sent to caller when callee accepts. Caller can now join the RTC media channel.
{
"type": "CALL_ACCEPT",
"session_id": "sess_789",
"user_id": "user_B"
}private:user:{orgId}:{caller}Sent to caller when callee explicitly declines the call.
{
"type": "CALL_REJECT",
"session_id": "sess_789",
"user_id": "user_B"
}private:user:{orgId}:{caller}Sent to caller when callee is already in another call or unavailable.
{
"type": "CALL_BUSY",
"session_id": "sess_789",
"user_id": "user_B"
}hb-video:{sessionId}Broadcast when someone leaves a group call (session still active).
{
"type": "call.participant_left",
"session_id": "sess_789",
"user_id": "user_A",
"active_count": 2
}hb-video:{sessionId}Broadcast when the session ends (1:1 end, or last group participant leaves).
{
"type": "call.ended",
"session_id": "sess_789",
"reason": "last_participant_left"
}Endpoints in this section share a structured error envelope: { "error": { "code": "...", "message": "..." } }. Map these codes in your client so users see the right copy and your app can decide whether to retry, suggest an action, or silently dismiss.
| HTTP | code | Where | Meaning · Recovery |
|---|---|---|---|
| 400 | no_valid_targets | POST /video-call | target_user_ids is empty after deduplication and removing the caller. Ask the user to pick at least one other participant. |
| 400 | limit_exceeded | POST /video-call | More than 2 target_user_ids requested on this route. Cap is 3 participants total (caller + 2); POST /video/sessions allows 4. |
| 400 | plan_upgrade_required | POST /video-call · POST /video/sessions | The plan does not include group video, so calls are limited to 2 participants (1:1). Upgrade the organisation's plan to start group calls. |
| 400 | sandbox_quality_not_allowed | POST /video/sessions · POST /stream/sessions · POST /video-call | Test API keys run at HD only. Higher tiers (fhd, 2k, 2k_plus) take the same code path, so the sandbox verifies your integration rather than the resolution tier — request those with a live key. |
| 403 | sandbox_limit_reached | POST /video/sessions · POST /stream/sessions · POST /video-call · POST /storage/presign | This month's sandbox allowance for that feature is used up. It resets on the 1st of next month; live API keys are metered separately on your plan. Sessions already running are unaffected. |
| 402 | spend_cap_reached | POST /video-call · POST /video/sessions | The organisation reached its monthly spend limit, so no new billable session may start. Calls already in progress are unaffected. Raise the limit in Console → Subscription to resume immediately. |
| 409 | call_full | POST /video/sessions/:id/join | The session is already at its participant cap for the organisation's plan. Late joiners should be told the call is full. |
| 400 | forbidden | POST /video-call | "Cannot video call yourself" — the 1:1 room only contains the caller. Ask the user to pick a room with another member. |
| 403 | forbidden | POST /video-call/accept | "Cannot accept a call as another user" — a customer-JWT caller passed a user_id that is not their own. Accept with the authenticated user's own id (org API keys can accept on behalf of any user). |
| 403 | forbidden | POST /video-call/end | Only the call's caller, the room owner, or a sub_admin can end the session for everyone. Regular participants should call /leave instead — the backend auto-ends when activeCount ≤ 1. |
| 403 | forbidden | POST /video-call/renew-token | The user is not a participant of the targeted session. Re-check session_id / user_id before retrying. |
| 404 | not_found | /video-call/* | The room (or session, for /renew-token) does not exist in this org. Typically a stale URL — refresh the room list. |
| 409 | caller_busy | POST /video-call | The caller is already in another active session in this org (any device). Direct them to end the existing call first. The server checks every active participant across all platforms, so this is authoritative even when the existing call is on another device / browser. |
| 409 | targets_busy | POST /video-call | Every requested target is currently in another active session. Response carries busy_targets[]. For partial busy in group calls, the call proceeds with the available targets and the caller receives CALL_BUSY signals per skipped target — no 409. |
| 410 | session_ended | POST /video-call/accept | The session was wound down between INVITE delivery and the Accept attempt — caller cancelled, cron sweep, or End-for-All by another participant. Dismiss the incoming overlay and show a brief "Call already ended" cue. |
A successful no-op (e.g. /leave on an already-ended session, /end while a /reject was racing) returns200with a message body so the client doesn't have to special-case idempotent retries.
Your app should call /leave or/end whenever a session completes — that's how durations are measured accurately and how chat history records the outcome. But OS process termination, network outages, and force-quits all skip cleanup. HyperBabel runs a server-side sweeper so abandoned sessions don't pin a participant in a permanent "busy" state.
status='created' · 15 min
Sessions where no one ever accepted (caller alone) auto-end 15 minutes after creation. Reason: auto_expired_abandoned.
status='active' · 8 hr
Calls running longer than 8 hours auto-end. Reason:auto_expired. Practically only triggers for orphaned sessions (every participant's app crashed).
When an auto-expire fires, HyperBabel publishescall.ended on the session's signal channel with the matching reason. Treat it identically to a normal end — wind down the local UI, surface the duration if you tracked it. The sweeper does not retroactively bill — billable minutes for a sweep-ended session are computed fromstarted_at to the sweep timestamp.
Full live broadcast infrastructure. Supports host broadcasting and viewer token generation. All endpoints require Authorization: Bearer hb_live_xxx and the live_stream scope.
403 plan_upgrade_required on session creation. Plans that ship a dedicated realtime project also return 409 provisioning_in_progress until that project is set up. Sandbox keys (hb_test_*) are exempt from both — sandbox traffic is not billed, so you can try live streaming on any plan from the Console Sandbox Hub. See the Pricing page for which plans include it in production./api/v1/stream/sessionsList stream sessions for the organisation, filtered by status. Use this to build a live broadcast discovery screen — viewers can see who is currently live and click to join. Ended streams are automatically excluded when using status=live.
// GET — no body // Query params: // status — 'live' | 'created' | 'ended' (default: 'live') // limit — max results (default: 20, max: 100) Authorization: Bearer hb_live_xxx // Example: GET /api/v1/stream/sessions?status=live&limit=20
{
"sessions": [
{
"id": "stream_uuid",
"title": "Live Q&A Session",
"status": "live",
"host": {
"user_id": "host-001",
"display_name": "Yujin"
},
"viewer_count": 42,
"started_at": "2026-03-16T10:00:00Z",
"created_at": "2026-03-16T09:58:00Z"
}
],
"total": 1
}/api/v1/stream/sessionsCreate a new live stream session and generate an RTC publisher token for the host. Optionally provide host display info (name, profile image, preferred language) for rich stream UI. Declare the billing resolution tier with `quality`: `'hd'` (default) | `'fhd'` | `'2k'` | `'2k_plus'`. The value is persisted on the session row and decides which bucket the stream's user-minutes land in (`hd_stream_mins` / `fhd_stream_mins` / `k2_stream_mins` / `k2plus_stream_mins`). It cannot be changed mid-session.\n\n**Optional self-check — `publish_resolution`.** Send it as `{ width: 1280, height: 720 }` — the resolution this session will actually publish **at this participant count** (not your camera's maximum) and we will check your declaration for you. A viewer subscribes to the host stream only, so for a broadcast the aggregate is simply the host resolution itself — no multiplication. We add that up and compare it with `quality`; if the sum lands in a higher tier the response carries a `quality_warning` string. The call still succeeds and billing still follows `quality` — the field exists because the most common billing surprise is an honest 720p declaration on a group call, not a false one. If your app lowers the resolution as people join, send the value for the size you are joining at. Pre-checks run before any broadcaster credential is allocated: `403 { code: 'plan_upgrade_required' }` when the plan does not include live streaming, `409 { code: 'provisioning_in_progress' }` while a dedicated realtime project is still being set up, `402 { code: 'spend_cap_reached' }` when the organisation hit its monthly spend limit, and `403 { code: 'quota_exceeded' }` when a free-tier monthly limit is exhausted. Sandbox (`hb_test_*`) keys skip the quota pre-check but not the plan entitlement. **`/start` response note:** the response of `POST /api/v1/stream/sessions/:sessionId/start` includes `sandbox_time_limit_seconds: 900` ONLY for sandbox keys. Live/production keys never receive this field — their streams have no time cap.
{
"host_user_id": "host-001",
"title": "Live Q&A Session",
"host_display_name": "Yujin", // optional
"host_profile_image_url": "https://cdn.example.com/yujin.png", // optional
"host_preferred_lang_cd": "ko", // optional, BCP-47
"quality": "hd", // optional — "hd" | "fhd" | "2k" | "2k_plus"
"publish_resolution": { // optional — what the host will actually publish
"width": 1280, "height": 720
}
}// 201 Created { "session": { "id": "stream_uuid", "channel_name": "hb:orgid:stream:abc123", "title": "Live Q&A Session", "status": "created", "quality": "hd", // ← the tier this stream bills at "quality_warning": null, // ← set only if publish_resolution implies a higher tier "app_id": "" , "host": { "user_id": "host-001", "display_name": "Yujin", "uid": 10000, "rtc_token": "" } } }
/api/v1/stream/sessions/:sessionId/viewer-tokenGenerate an RTC subscriber token for a viewer. Call this whenever a viewer joins the stream. Optionally provide viewer info to identify them in the stream chat.
{
"user_id": "viewer-002", // optional
"viewer_display_name": "Oliver", // optional
"viewer_profile_image_url": "https://cdn.example.com/oliver.png", // optional
"viewer_preferred_lang_cd": "en" // optional, for translation
}{
"channel_name": "hb:orgid:stream:abc123",
"uid": 10042,
"token": "" ,
"display_name": "Oliver",
"profile_image_url": "https://cdn.example.com/oliver.png",
"preferred_lang_cd": "en",
"app_id": ""
}/api/v1/stream/sessions/:sessionId/startTransition the stream session from 'created' to 'live' status. Call this when the host taps 'Go Live'. Fires a stream.session.started webhook.
// No body required // Headers Authorization: Bearer hb_live_xxx
{
"session_id": "stream_uuid",
"status": "live",
"started_at": "2026-03-10T10:00:00Z"
}/api/v1/stream/sessions/:sessionId/heartbeatHosts MUST POST to this endpoint every ~30 seconds while broadcasting. The server uses the most recent heartbeat to detect a crashed or abandoned host within minutes and end the session automatically — billing then reflects the actual stream duration (last heartbeat → started_at) rather than the 8-hour wall-clock fallback. Fire-and-forget: a single failed heartbeat is non-fatal; stop sending once you call /end.
// No body required // Headers Authorization: Bearer hb_live_xxx
{ "ok": true }/api/v1/stream/sessions/:sessionId/endMark the stream as ended, calculate duration, and record usage. Fires a stream.session.ended webhook. Production streams are billed per minute.
// No body required // Headers Authorization: Bearer hb_live_xxx
{
"session_id": "stream_uuid",
"status": "ended",
"duration_seconds": 3600,
"ended_at": "2026-03-10T11:00:00Z"
}AI-powered translation supporting individual text, batch multi-language, automatic language detection, and supported language lookup. All endpoints require Authorization: Bearer hb_live_xxx and the translate scope.
/api/v1/translate/textTranslate a single text string to a target language. source_language defaults to 'auto' (auto-detect). Returns the detected source language and the input character count for usage transparency.
{
"text": "안녕하세요",
"target_language": "en",
"source_language": "auto" // optional, default: auto-detect
}{
"translated_text": "Hello",
"source_language": "ko",
"target_language": "en",
"char_count": 5
}/api/v1/translate/batchTranslate a single text string to multiple target languages in one request. Ideal for broadcasting a message to a multilingual audience. successful_languages lists the codes that returned a translation (failures, e.g. unsupported pairs, are silently skipped).
{
"text": "안녕하세요",
"target_languages": ["en", "ja", "fr", "zh"],
"source_language": "auto" // optional
}{
"translations": {
"en": "Hello",
"ja": "こんにちは",
"fr": "Bonjour",
"zh": "你好"
},
"char_count": 5,
"successful_languages": ["en", "ja", "fr", "zh"]
}/api/v1/translate/detectDetect the language of a given text string. Returns the detected language code (BCP-47) and a confidence score (0.0–1.0).
{
"text": "안녕하세요"
}{
"detected_language": "ko",
"confidence": 0.99
}/api/v1/translate/languagesGet the list of all supported translation language codes. Use the returned codes as source_language or target_language values. count is the total number of languages in the languages array.
Mobile/web SDKs using a customer JWT should call /api/v1/customer/translate/languages instead — same response, customer-JWT auth so the org API key never ships in the binary.
// GET — no body // Headers Authorization: Bearer hb_live_xxx
{
"languages": [
{ "code": "ko", "name": "Korean" },
{ "code": "en", "name": "English" },
{ "code": "ja", "name": "Japanese" },
{ "code": "zh", "name": "Chinese (Simplified)" },
{ "code": "fr", "name": "French" }
],
"count": 130
}Realtime speech-to-text with live translation over a single WebSocket. Stream your end-user's microphone audio and receive live captions — the original transcript and, optionally, its translation — as the user speaks. Recognition covers 60 spoken languages with in-stream translation between them, powered by the same HyperBabel engine behind HyperBabel Video live captions.
Endpoint — wss://api.hyperbabel.com/api/v1/stt-relay
Auth — end-user customer JWT passed as the ?token= query parameter (a WebSocket handshake cannot set headers from browsers/mobile). Mint it server-side via POST /api/v1/customer/issue-token — your org API key never ships in the client binary.
Audio in — raw binary PCM, 16 kHz · mono · 16-bit signed little-endian.
Captions out — JSON messages: live partial hypotheses and utterance-complete final segments, each with a matching translation track.
Scope — speech translation attaches to an active video call session (created via the Video Call API or the United Chat call flow). Standalone transcription outside a call is not supported in v1.
Billing — metered on the audio you actually send (per minute). Gate capture behind a mute button or client-side voice detection and you only pay for speech.
Paid plans include a monthly allowance of speech-translation minutes, and usage is metered per minute of audio streamed to the relay — not per call and not per participant. Two speakers captioned for the same 10-minute call consume 20 minutes.
Plans with a zero allowance (the Free plan) are rejected at connect with 403 stt_not_enabled — speech translation is not part of that plan. Past the allowance: paid plans keep working and the excess is billed as metered overage; plans with a capped free allocation stop mid-session with {"kind":"error","code":"limit_exceeded"} (the video call itself is unaffected). A session is also refused with 402 spend_cap_reached once the organisation reaches its monthly spend limit. Current allowances and the overage rate are listed on the Pricing page.
Check it programmatically with GET /api/v1/auth/usage (org API key) — the response carries stt_mins (consumed this billing month) against monthly_stt_mins (your plan's allowance; -1 = unlimited, 0 = not included in the plan). Poll it from your backend to raise your own alerts before users hit the ceiling.
1. [Your server] POST /api/v1/customer/issue-token (org API key)
→ { access_token } // end-user customer JWT
2. [Your server] Create the video call session (org API key —
POST /api/v1/video/sessions is API-key-only) → { session.id }
— or the client starts a room-scoped call itself with its
customer JWT: POST /api/v1/unitedchat/rooms/:roomId/video-call
3. [Client] Open the relay socket
wss://api.hyperbabel.com/api/v1/stt-relay
?token=<customer_jwt>
&lang=ko // spoken language
&translate=en // optional target
&session_id=<video session id>
4. [Client] Wait for {"kind":"ready"} → stream mic PCM (binary frames)
← {"kind":"partial","text":"안녕하","lang":"ko"}
← {"kind":"final","text":"안녕하세요.","lang":"ko"}
← {"kind":"final_tr","text":"Hello.","lang":"en"}
5. [Client] Send "EOS" (text frame) to finish — usage is metered on close.Want to see it before writing code? Open Console → Sandbox Hub → Live Captions Test — a zero-setup playground that runs this exact API against your sandbox environment.
| Parameter | Required | Description |
|---|---|---|
token | Yes | End-user customer access JWT (from /customer/issue-token). |
lang | Yes | Spoken language code — any stt_lang_cd from GET /api/v1/stt/languages. |
translate | No | Target language code for live translation. Omit for transcription-only (no *_tr messages). |
session_id | One of | Active video call session id (the session.id returned by POST /api/v1/video/sessions). The connecting user must be a participant. |
room_id | One of | Alternative to session_id for room-scoped calls created through the United Chat call flow. |
| Frame | Meaning |
|---|---|
| binary | Raw PCM audio — 16 kHz, mono, 16-bit signed little-endian. Send in small batches (~100 ms) as the mic produces it. Send only after ready — queue or drop audio captured before it. |
"FINALIZE" | Force-finalize the current utterance but keep the stream open — useful when the user pauses captions (standby stays warm, resuming is instant). |
"EOS" | End of stream. The relay flushes the last utterance, meters the session, and closes. |
kind | Meaning |
|---|---|
ready | Recognition engine connected — start streaming audio. Sent once per connection. |
partial | Live hypothesis of the current utterance (original language). Cumulative — each partial replaces the previous one. |
final | Utterance complete (detected at natural pauses) — a clean, stable segment. Ideal unit for display history or downstream processing. |
partial_tr / final_tr | Translation track — same semantics as partial/final, in the translate target language. Only sent when translate was set. |
keepalive | Heartbeat during audio gaps — ignore it (but treat unknown kinds as ignorable too, for forward compatibility). |
error | Something went wrong — { "kind":"error", "code"?, "message" }. See Errors & Limits below. |
// Message samples
{"kind":"ready"}
{"kind":"partial","text":"I am a Kor","lang":"en"}
{"kind":"final","text":"I am a Korean.","lang":"en"}
{"kind":"partial_tr","text":"나는 한국","lang":"ko"}
{"kind":"final_tr","text":"나는 한국 사람입니다.","lang":"ko"}
{"kind":"error","code":"limit_exceeded","message":"Monthly free STT allocation exhausted — upgrade to a paid plan to continue","feature":"stt"}Resilience: the relay transparently maintains its upstream connection across brief engine reconnects — your socket stays open and no audio is lost during normal operation. If the socket does close, reconnect with the same parameters (mint a fresh JWT if yours expired).
/api/v1/stt/languagesThe full list of spoken-language codes accepted by the lang parameter. Accepts either an org API key or a customer JWT, so client apps can populate a language picker directly. The set is small and changes rarely — cache it client-side (the response sends Cache-Control accordingly). Use the same codes (base part) for the translate target.
// GET — no body // Headers (either works) Authorization: Bearer hb_live_xxx // org API key Authorization: Bearer// end-user token
{
"languages": [
{
"id": 31,
"stt_lang_cd": "ko",
"language_name": "Korean",
"native_name": "한국어",
"country_name": null,
"country_cd": null,
"search_keywords": null
},
{
"id": 15,
"stt_lang_cd": "en",
"language_name": "English",
"native_name": "English",
"country_name": null,
"country_cd": null,
"search_keywords": null
}
]
}The WebSocket handshake is an HTTP request — gate failures are returned as regular HTTP errors before the socket opens. After the upgrade, problems arrive as {"kind":"error"} messages.
| Status | error.code | Meaning |
|---|---|---|
| 401 | — | Missing/invalid/expired customer JWT. |
| 403 | subscription_inactive | The organization has no active subscription. |
| 403 | stt_not_enabled | Speech translation is not included in the org's plan. |
| 403 | quota_exceeded | Free-plan monthly speech allocation exhausted (paid plans continue into metered overage instead). |
| 402 | spend_cap_reached | The organization reached its monthly spend limit, so no new billable session may start. Raise the limit in Console → Subscription to resume. |
| 409 | no_active_call | No active video call session found with this user as a participant — check session_id/room_id and that the call hasn't ended. |
| 429 | rate_limited | Too many session opens (per-user, per-minute). Back off and retry. |
| 429 | concurrency_limit | Too many concurrent speech sessions for this user or organization (plan-dependent). |
Mid-session: when a free-plan org runs out of its monthly allocation while streaming, the relay sends {"kind":"error","code":"limit_exceeded"}, flushes the last utterance, and closes — the video call itself is unaffected. Usage and remaining quota are visible in Console → Subscription.
Globally distributed file storage with built-in security. Supports chat file delivery and external API uploads. Files are automatically categorized by extension — the category drives the storage folder layout and the message_type hint returned by presign.
Download egress is 100% free.
Folder navigation, file listing, downloads, deletion, and preview (image/video/audio) functions are provided in the Storage Explorer on the console dashboard.
🖼️ images
jpg, jpeg, png, gif, webp, avif, svg, heic, heif
🎬 videos
mp4, mov, avi, webm, mkv
🎵 audio
mp3, wav, aac, m4a, flac, ogg
📄 documents
pdf, doc, docx, xls, xlsx, ppt, pptx, txt, csv
📦 files
everything else (zip, apk, bin, ...)
There is no global extension allowlist — any file type can be uploaded. The exception is open rooms, where your organization's open-room policy (Console) can disable file transfers or restrict allowed types — see the presign error cases below.
The per-file limit equals your plan's monthly storage quota (monthly_storage_mb), capped at a hard 5 GB per file (plans with unlimited storage get the 5 GB cap). Oversized presign requests are rejected with 400 file_too_large.
/api/v1/storage/presignPre-validate file size (per-file limit) and storage quota, derive the file's category from its extension, and return a presigned PUT URL for direct-to-storage upload. The URL is valid for 15 minutes. Free-tier orgs that have exhausted their `monthly_storage_mb` allocation get `403 { error: { code: 'quota_exceeded' } }` BEFORE any bytes hit storage — the upload won't even start. Sandbox (`hb_test_*`) keys skip this pre-check. Organisations that reached their monthly spend limit get `402 { error: { code: 'spend_cap_reached' } }` at the same point. When channelId points at an open room, your organization's open-room policy (configured in the Console) also applies — file transfers can be disabled entirely, or restricted to specific types.
// application/json { "filename": "profile_photo.jpg", "mimeType": "image/jpeg", "fileSize": 524288, // bytes "channelId": "ch_uuid", // for chat uploads "folder": "uploads" // for API uploads (default: "uploads") } // Headers Authorization: Bearer hb_live_xxx
// 201 Created { "message": "Presigned upload URL created", "data": { "upload_url": "https://storage.hyperbabel.com/...?Signature=...", "key": "storage/org_uuid/...", "expires_in": 900, "message_type": "image", "category": "images" } } // 400 — file exceeds the plan per-file limit { "error": { "code": "file_too_large", "message": "File exceeds plan per-file limit (500 MB)." } } // 403 — free-tier monthly storage quota exhausted { "error": { "code": "quota_exceeded" } } // 403 — open-room policy: file transfers disabled { "error": { "code": "open_room_files_disabled" } } // 400 — open-room policy: file type not allowed { "error": { "code": "open_room_file_type_not_allowed" } }
📤 Step 2 — Direct Upload (Client → Storage)
PUT the raw file binary to the upload_url from Step 1. The file goes directly to storage — the HyperBabel server is not involved.
/api/v1/storage/confirmAfter the client finishes uploading to storage, call this endpoint to verify the file exists, record usage, and get back the final file metadata. Idempotent — repeat confirms of the same key do not double-count usage.
// application/json { "key": "/chat/ , // from presign response "originalName": "profile_photo.jpg", // Optional — both fields auto-populate when omitted: // • channel_id: extracted from the key path (chat uploads only) // • uploader_id: customer JWT end-user's external_user_id "channel_id": "/images/..." " , "uploader_id": "alice-uuid" } // Headers Authorization: Bearer
// 200 OK { "message": "Upload confirmed", "data": { "key": "storage/org_uuid/...", "url": "https://cdn.hyperbabel.com/...", "original_name": "profile_photo.jpg", "size_bytes": 524288, "mime_type": "image/jpeg", "message_type": "image" } } // 404 — file not found in storage (upload failed or incomplete) { "error": { "code": "not_found", "message": "File not found in storage. Upload may have failed." } }
💬 Chat File Delivery — Full Flow
// 1. Presign POST /api/v1/storage/presign { "filename": "photo.jpg", "mimeType": "image/jpeg", "fileSize": 524288, "channelId": "ch_uuid" } → { "upload_url": "...", "key": "...", "message_type": "image" } // 2. Direct upload to storage PUT {upload_url} Content-Type: image/jpeg (raw file binary) // 3. Confirm upload POST /api/v1/storage/confirm { "key": "...", "originalName": "photo.jpg" } → { "url": "https://cdn.hyperbabel.com/...", "original_name": "photo.jpg", "size_bytes": 524288, "mime_type": "image/jpeg", "message_type": "image" } // 4. Send as chat message POST /api/v1/unitedchat/rooms/{roomId}/messages { "sender_id": "user_123", "content": "{url from step 3}", "message_type": "image", "metadata": { "file": { "original_name": "photo.jpg", "size_bytes": 524288, "mime_type": "image/jpeg" } } }
/api/v1/storage/url/:key(*)Generate a temporary signed URL for a private file. The original filename is automatically set in the Content-Disposition header. Valid for 15 minutes. :key is the full path returned from the upload response.
// GET — no body // Header: Authorization: Bearer hb_live_xxx or JWT // :key = full file path from upload response
{
"url": "https://storage.hyperbabel.com/...?Expires=900&Signature=...",
"expires_in": 900
}