API Reference

API Reference

Complete reference documentation for the HyperBabel API Platform.

v1.1.7
Updated Oct 2026

Authentication

All API requests must include your API Key in the Authorization header. Generate your API key from Console → API Keys.

HTTP HeaderAuthorization: Bearer hb_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

API Key Types

  • hb_live_... — Production key (billing applies)

Base URL

https://api.hyperbabel.com/api/v1

Usage API

Monitor 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.

💡 Account & API key management is done through the HyperBabel Console dashboard — not via API endpoints.
GET

Get Usage Summary

/api/v1/auth/usage

Return 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.

Request
// GET — no body
// Headers
Authorization: Bearer hb_live_xxx
Response
// 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" }
Authentication

Customer Auth

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.

// End-to-end flow
[ 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).

💡 Step-by-step integration walkthrough with code samples for React Native, Web, and iOS: Customer Auth + Firebase OAuth Integration Guide →
POST

Exchange Firebase ID Token (client-direct, no backend hop)

/api/v1/customer/auth/firebase-exchange

For 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.

Request
POST /api/v1/customer/auth/firebase-exchange
Authorization: Bearer 
Content-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'
}
Response
// 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"
}
POST

Issue Customer Token (server-to-server)

/api/v1/customer/issue-token

Mint 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.

Request
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"
}
Response
{
  "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"
}
POST

Refresh Customer Token (client-direct)

/api/v1/customer/refresh

Rotate 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.

Request
POST /api/v1/customer/refresh
Content-Type: application/json

{
  "refresh_token": "eyJhbGc…"
}
Response
// Same shape as /customer/issue-token
{
  "access_token":  "eyJhbGc…",        // new
  "refresh_token": "eyJhbGc…",        // rotated
  "session_id":    "1b124c26-…",      // new sid
  ...
}
POST

Revoke Customer Sessions (server-to-server)

/api/v1/customer/revoke

Invalidate every active session for one external_user_id. Global propagation under 60 seconds. Use on log-out-everywhere, password-changed, or abuse signals.

Request
POST /api/v1/customer/revoke
Authorization: Bearer hb_live_xxx
Content-Type: application/json

{
  "external_user_id": "alice-uuid"
}
Response
{
  "revoked": 3
}
GET

Get Current Customer Identity

/api/v1/customer/me

Verifies 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.

Request
GET /api/v1/customer/me
Authorization: Bearer 
Response
{
  "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

  • Access token: 1 hour by default. Override per org from Console → Customer Auth (5 min – 24 h).
  • Refresh token: 30 days, fixed. Rotated on every /refresh.
  • Scope: Customer JWTs reach /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 console or administration endpoints, or /auth/api-keys.
  • Identity scoping: a customer JWT may only act as its own end-user on the identity-sensitive endpoints — send message (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.
  • Revocation latency: /revoke propagates within ~60 seconds globally via the edge cache.
  • Refresh audit trail: every /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.
  • Defensive client guard: add a one-line check in your HTTP client that throws if any header value starts with hb_live_ or hb_test_ — catches accidental regressions where a future commit reintroduces the org key.
  • Quota impact: all API traffic authenticated with a customer JWT counts against your org's monthly plan quotas — chat messages, video / live-stream minutes, translation chars, storage MB, presence minutes — same as direct API-key calls. Customer JWTs only change scope (no console, administration or API-key endpoints), not billing. Use the Console → Subscription dashboard to see the rolled-up totals.

Working examples

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.

Quick Guide

Which Chat API should I use?

HyperBabel provides two complementary chat APIs. Both share the same underlying infrastructure — choose based on how much you want HyperBabel to manage for you.

💬Chat API
/api/v1/chat/
Low-level

🔒 Server-to-server only. Call it from your backend with your organization API key. End-user (customer) JWTs are rejected with 403 on reads as well as writes — for an app your users sign in to, use United Chat.

Pure messaging layer. You manage rooms & members. Best for embedding messaging into an app that already has its own user/room management.

Includes

✓Channel creation & listing
✓Send / Receive messages
✓Message delete & edit
✓Reactions & read receipts
✓Full-text search
✓Typing indicators
✓Thread (replies)
✓Auto AI translation
—Room type management (1:1 / Group / Open)
—Member roles & permissions
—Ban / Sub-admin management
—Integrated video call
—Message pin
—Room freeze / mute
🚀United Chat API
/api/v1/unitedchat/
All-in-one

👤 Your users can call this directly. Works with an organization API key or an end-user (customer) JWT from Customer Auth, so the app talks to HyperBabel without routing every message through your backend.

Complete chat solution. HyperBabel manages rooms, roles & video. Best for building a full-featured chat app quickly.

Everything in Chat API, plus

✓Room types: 1:1 · Group · Open
✓Member roles (owner / sub_admin / member)
✓Ban & kick management
✓Integrated video call (1:1 & group)
✓Message pin
✓Room freeze (admin-only posting)
✓Per-user mute notifications
✓Auto AI translation

🔍 Pick by scenario

Build a KakaoTalk / LINE-style chat app

🚀United Chat API

Add 1:1 live support / CS chatbot to my existing app

💬Chat API

Community / fan club with open rooms and moderators

🚀United Chat API

IoT device log stream or server-to-server messaging

💬Chat API

Group video call + chat room integration

🚀United Chat API

I already manage my own rooms/users — just need messaging

💬Chat API
💡 Note: Both APIs share the same message store. You can start with Chat API and migrate to United Chat API later without losing any message history.

United Chat API

Unified 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.

POST

Create Room

/api/v1/unitedchat/rooms

Create 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.

Request
// 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"
}
Response
{
  "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"
  }
}
GET

List Rooms

/api/v1/unitedchat/rooms

Get 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. On 1:1 rooms, peer_user_id is the other member's user id — it is null for group and open rooms, once the peer has left the room, or while the peer has no end-user record yet (one is created when your server issues that user a realtime token or a customer JWT).

Request
// GET — no body
// Query: ?user_id=user_A&lang=ko
Response
{
  "rooms": [
    {
      "id": "uuid-xxxx",
      "channel_type": "group",
      "room_name": "Project Alpha",
      "realtime_channel": "hb:org_uuid:uc-m8z3k1",
      "member_count": "3",
      "unread_count": "5",
      "peer_user_id": null,                              // 1:1 rooms: the other member's user id (null here — group room)
      "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
    }
  ]
}
GET

Get Room

/api/v1/unitedchat/rooms/:roomId

Get room details including full member list with roles.

Request
// GET — no body
// Path: :roomId = room UUID
Response
{
  "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" }
    ]
  }
}
PUT

Update Room Name

/api/v1/unitedchat/rooms/:roomId/name

Set a custom display name for the room (per-user). Each member can have their own name for the room.

Request
{
  "user_id": "user_A",
  "custom_name": "My Alpha Team"
}
Response
{ "success": true }
PUT

Update Open Room Description

/api/v1/unitedchat/rooms/:roomId/description

Set 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.

Request
{
  "user_id": "owner_user",
  "description": "A public room for discussing HyperBabel integrations 🚀"
}
Response
{ "success": true, "description": "A public room for discussing HyperBabel integrations 🚀" }
POST

Join Open Room by Invite Code

/api/v1/unitedchat/rooms/join-by-code

Join 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.

Request
// ── 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
Response
{
  "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"
}
POST

Join Open Room

/api/v1/unitedchat/rooms/:roomId/join

Join 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.

Request
{ "user_id": "user_C", "display_name": "Yujin" }
Response
{ "success": true }
POST

Leave Room

/api/v1/unitedchat/rooms/:roomId/leave

Remove 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.

Request
{ "user_id": "user_B", "display_name": "Bob" }
Response
{ "success": true }
POST

Mark Room as Read

/api/v1/unitedchat/rooms/:roomId/read

Mark 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.

Request
{ "user_id": "user_A" }
Response
{ "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"
  }
}
POST

Send Message

/api/v1/unitedchat/rooms/:roomId/messages

Send a message to a room. message_type: 'text' | 'image' | 'video' | 'audio' | 'file' | 'location' | 'contact'. Supports reply threading via reply_to, which must be the id of a message in the same room — otherwise the request fails with 400 'invalid_reply_to' (sandbox traffic included). 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.

Request
// ── Text message
{
  "sender_id": "user_A",
  "content": "Hello!",
  "message_type": "text",
  "reply_to": "uuid-msg-xxx"   // optional — thread reply; a message id from this room
}

// ── 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": "jane@example.com"
  }
}
Response
{
  "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"
  }
}
GET

Get Messages

/api/v1/unitedchat/rooms/:roomId/messages

Get 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).

Request
// 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
Response
{
  "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"
    }
  ]
}
POST

Ban User

/api/v1/unitedchat/rooms/:roomId/ban

Ban 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.

Request
{
  "user_id": "user_B",
  "banned_by": "user_A"
}
Response
{ "success": true, "banned_user": "user_B", "banned_by": "user_A" }
DELETE

Unban User

/api/v1/unitedchat/rooms/:roomId/ban/:userId

Lift 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.

Request
{ "unbanned_by": "user_A" }
Response
{ "success": true, "unbanned_user": "user_B" }
GET

List Bans

/api/v1/unitedchat/rooms/:roomId/bans

List 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.

Request
// GET — no body
Response
{
  "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
    }
  ]
}
POST

Add Sub-Admin

/api/v1/unitedchat/rooms/:roomId/sub-admins

Promote 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.

Request
{
  "user_id": "user_B",
  "owner_id": "user_A"
}
Response
{ "success": true, "sub_admin": "user_B" }
DELETE

Remove Sub-Admin

/api/v1/unitedchat/rooms/:roomId/sub-admins/:userId

Demote 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.

Request
{ "owner_id": "user_A" }
Response
{ "success": true }
POST

Add Members

/api/v1/unitedchat/rooms/:roomId/members

Add 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.

Request
{
  "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
Response
{
  "success": true,
  "added": ["user_D"],
  "skipped": [
    { "user_id": "user_E", "reason": "already_member" }   // banned | room_full | already_member
  ]
}
GET

Get Members

/api/v1/unitedchat/rooms/:roomId/members

Get paginated member list for a room, ordered by role (owner → sub_admin → member). Supports page/limit pagination and partial name search.

Request
// 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)
Response
{
  "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
}
DELETE

Delete Message

/api/v1/unitedchat/rooms/:roomId/messages/:messageId

Soft-delete a message. Sets deleted_at — the message is excluded from getMessages responses. Only the original sender, room owner, or sub_admin can delete.

Request
{ "user_id": "user_A" }
Response
{ "success": true, "message_id": "uuid-msg-xxx", "deleted_at": "2026-03-11T15:00:00Z" }
PUT

Edit Message

/api/v1/unitedchat/rooms/:roomId/messages/:messageId

Edit 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.

Request
{
  "user_id": "user_A",
  "content": "Updated message content"
}
Response
{
  "success": true,
  "message": {
    "id": "uuid-msg-xxx",
    "content": "Updated message content",
    "original_content": "Original message content",
    "edited_at": "2026-03-11T15:01:00Z"
  }
}
POST

Mute Room

/api/v1/unitedchat/rooms/:roomId/mute

Mute push notifications for a room. Omit duration_minutes for indefinite mute.

Request
{
  "user_id": "user_A",
  "duration_minutes": 60   // optional — omit for indefinite
}
Response
{
  "success": true,
  "channel_id": "uuid-xxxx",
  "muted_until": "2026-03-11T16:00:00Z",
  "is_indefinite": false
}
DELETE

Unmute Room

/api/v1/unitedchat/rooms/:roomId/mute

Remove mute from a room for a user.

Request
{ "user_id": "user_A" }
Response
{ "success": true, "channel_id": "uuid-xxxx", "user_id": "user_A" }
GET

Get Mute Status

/api/v1/unitedchat/rooms/:roomId/mute/:userId

Check whether a user has muted a room and until when.

Request
// GET — no body
Response
{
  "is_muted": true,
  "channel_id": "uuid-xxxx",
  "user_id": "user_A",
  "muted_until": "2026-03-11T16:00:00Z",
  "is_indefinite": false
}
POST

Freeze Room

/api/v1/unitedchat/rooms/:roomId/freeze

Freeze 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.

Request
{ "user_id": "user_A" }
Response
{ "success": true, "is_frozen": true, "channel_id": "uuid-xxxx" }
DELETE

Unfreeze Room

/api/v1/unitedchat/rooms/:roomId/freeze

Unfreeze a room to restore normal messaging for all members. Publishes a `room.updated` realtime event so connected clients re-enable the message input immediately.

Request
{ "user_id": "user_A" }
Response
{ "success": true, "is_frozen": false, "channel_id": "uuid-xxxx" }
PUT

Pin Message

/api/v1/unitedchat/rooms/:roomId/pin/:messageId

Pin 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.

Request
{ "user_id": "user_A" }
Response
{ "success": true, "pinned_message_id": "msg-uuid-xxxx" }
DELETE

Unpin Message

/api/v1/unitedchat/rooms/:roomId/pin

Remove the currently pinned message from the room. Permission: 1:1 rooms — any member; group / open rooms — owner or sub_admin only.

Request
{ "user_id": "user_A" }
Response
{ "success": true }
GET

Full-Text Search

/api/v1/unitedchat/rooms/:roomId/search

Search 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 (an apostrophe or backslash also separates words); each word matches as a prefix, case-insensitive and without stemming — 'hel wor' finds 'Hello World', '안녕' finds '안녕하세요'. Only the first 8 words are used, and quotes do not make a phrase match.

Request
// Query params
?q=hello+world         // Required — every word must match, each as a prefix
?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: 
Response
{
  "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"
  }
}
POST

Typing Indicator

/api/v1/unitedchat/rooms/:roomId/typing

Broadcast 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.

Request
{
  "user_id": "user_A",
  "display_name": "Alice"   // shown as "Alice is typing..."
}
Response
// 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"
}
GET

Get Typing Preferences

/api/v1/unitedchat/rooms/:roomId/typing-prefs

Returns 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).

Request
// No body required
Response
{
  "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": "..." }
  ]
}
PUT

Update Typing Preferences

/api/v1/unitedchat/rooms/:roomId/typing-prefs

Save 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.

Request
{
  "user_id": "user_A",
  "send_enabled": true,    // default: true (fail-open)
  "recv_enabled": true     // default: true (fail-open)
}
Response
{ "success": true, "send_enabled": true, "recv_enabled": true }

Chat API

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.

POST

Create Channel

/api/v1/chat/channels

Create a new chat channel. channel_type: 'public' | 'private' | 'group'. max_members defaults to 100.

Request
{
  "channel_name": "general-discussion",
  "channel_type": "group",
  "max_members": 100
}
Response
// 201 Created
{
  "id": "ch_123",
  "channel_name": "general-discussion",
  "channel_type": "group",
  "max_members": 100,
  "created_at": "2026-03-01T10:00:00Z"
}
GET

List Channels

/api/v1/chat/channels

List all channels belonging to your organization. Optionally filter by channel_type.

Request
// Query params (all optional)
?channel_type=group     // filter: public | private | group

// Headers
Authorization: Bearer hb_live_xxx
Response
{
  "channels": [
    {
      "id": "ch_123",
      "channel_name": "general-discussion",
      "channel_type": "group",
      "member_count": 12,
      "created_at": "2026-03-01T10:00:00Z"
    }
  ]
}
POST

Send Message

/api/v1/chat/channels/:channelId/messages

Send 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).

Request
// 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"
    }
  }
}
Response
// 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"
}
GET

Get Message History

/api/v1/chat/channels/:channelId/messages

Retrieve paginated message history using cursor-based pagination. Use cursor from the previous response to load the next page. Results are ordered newest-first.

Request
// 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
Response
{
  "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"
}
POST

Batch Translate Messages

/api/v1/unitedchat/rooms/:roomId/messages/batch-translate

Translate 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. Non-text messages (image, video, audio, file, location, contact, system, video_call) are skipped and get no entry in translations. Cached translations (stored per message) are returned without re-billing; only cache misses incur translate_chars usage. If the end-user has exhausted their daily translation allowance the response adds 'limited': true and omits the untranslated ids — show the original text and offer an upgrade rather than treating it as an error. If the translation service could not translate some messages this time, the response adds 'failed': [message ids] — those ids are absent from translations; retry them later.

Request
{
  "message_ids": ["msg_456", "msg_457", "msg_458"],
  "target_lang": "en"
}
Response
{
  "translations": {
    "msg_456": "Hello!",
    "msg_457": "Hello!"
  },
  "limited": true   // only when the daily allowance is exhausted; then the
                    // untranslated ids are simply absent (cached ones still
                    // return). Show the original text — this is not an error.
}
POST

Add Reaction

/api/v1/chat/messages/:messageId/reactions

Add an emoji reaction to a message. Each user can react once per emoji per message. Server-to-server only — this route rejects end-user (Session-Token) JWTs; for end-user apps use the room-scoped route below.

Request
{
  "user_id": "user_123",
  "emoji": "👍"
}
Response
{
  "message_id": "msg_456",
  "emoji": "👍",
  "user_id": "user_123",
  "created_at": "2026-03-01T10:05:00Z"
}
DELETE

Remove Reaction

/api/v1/chat/messages/:messageId/reactions

Remove a previously added reaction from a message. Server-to-server only — see the room-scoped route below for end-user apps.

Request
{
  "user_id": "user_123",
  "emoji": "👍"
}
Response
{
  "success": true,
  "message_id": "msg_456",
  "emoji": "👍"
}
POST

Add Reaction (end-user)

/api/v1/unitedchat/rooms/:roomId/messages/:messageId/reactions

Room-scoped reaction for end-user apps. Accepts an end-user (Session-Token) JWT and pins user_id to the token owner, so one user cannot react on behalf of another. Returns the full reaction map.

Request
{
  "user_id": "user_123",
  "emoji": "👍"
}
Response
{
  "reactions": {
    "👍": ["user_123", "user_456"],
    "❤️": ["user_789"]
  }
}
DELETE

Remove Reaction (end-user)

/api/v1/unitedchat/rooms/:roomId/messages/:messageId/reactions

Room-scoped removal. When the last reactor for an emoji is removed, that emoji key is dropped from the map.

Request
{
  "user_id": "user_123",
  "emoji": "👍"
}
Response
{
  "reactions": {
    "❤️": ["user_789"]
  }
}
POST

Mark Channel as Read

/api/v1/chat/channels/:channelId/read

Mark all messages in a channel as read up to the current time for a given user. Updates the unread count to 0.

Request
{
  "user_id": "user_123"
}
Response
{
  "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.
DELETE

Delete Message

/api/v1/chat/channels/:channelId/messages/:messageId

Soft-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.

Request
{ "user_id": "user_123" }
Response
{ "success": true, "message_id": "msg_456", "deleted_at": "2026-03-11T15:00:00Z" }
PUT

Edit Message

/api/v1/chat/channels/:channelId/messages/:messageId

Edit 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.

Request
{
  "user_id": "user_123",
  "content": "Updated message content"
}
Response
{
  "success": true,
  "message": {
    "id": "msg_456",
    "content": "Updated message content",
    "original_content": "Original message content",
    "edited_at": "2026-03-11T15:01:00Z"
  }
}
GET

Full-Text Search

/api/v1/chat/channels/:channelId/search

Search 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.

Request
// Query params
?q=hello+world         // Required — every word must match (AND), whole words only; quotes do not make a phrase match
?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: 
Response
{
  "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"
  }
}
POST

Typing Indicator

/api/v1/chat/channels/:channelId/typing

Broadcast 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.

Request
{
  "user_id": "user_123",
  "is_typing": true
}
Response
{ "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": "..." }
GET

Get Thread (Replies)

/api/v1/chat/channels/:channelId/messages/:messageId/thread

Get 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.

Request
// Query params (all optional)
?limit=50
?cursor=msg_xxx    // last message ID from previous page
Response
{
  "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
}

Presence API

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.

POST

Send Heartbeat

/api/v1/presence/heartbeat

Call 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).

Request
{
  "user_id": "user_123",
  "device": "mobile"   // optional
}
Response
{ "success": true, "status": "online" }
POST

Set Status

/api/v1/presence/status

Explicitly set a user's status. Useful for manual away/dnd modes.

Request
{
  "user_id": "user_123",
  "status": "dnd"   // online | away | dnd | offline
}
Response
{ "success": true, "user_id": "user_123", "status": "dnd" }
GET

Get Presence (Bulk)

/api/v1/presence

Get presence status for up to 100 users at once. Users not found in presence table are returned as offline.

Request
// Query
?user_ids=user_123,user_124,user_125   // comma-separated, max 100
Response
{
  "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
}

User Block API

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.

POST

Block User

/api/v1/users/block

Block 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).

Request
{
  "blocker_id": "user_A",
  "blocked_id": "user_B"
}
Response
{ "success": true, "blocker_id": "user_A", "blocked_id": "user_B" }
DELETE

Unblock User

/api/v1/users/block

Remove 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).

Request
{
  "blocker_id": "user_A",
  "blocked_id": "user_B"
}
Response
{ "success": true, "blocker_id": "user_A", "blocked_id": "user_B" }
GET

Get Block List

/api/v1/users/:userId/blocks

Get 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).

Request
// GET — no body
Response
{
  "user_id": "user_A",
  "blocked_users": [
    { "blocked_id": "user_B", "created_at": "2026-03-11T10:00:00Z" }
  ]
}

Push Token API

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.

POST

Register Push Token

/api/v1/push/register

Register a device token. If the same token already exists for this user, updated_at is refreshed.

Request
{
  "user_id": "user_123",
  "token": "fcm_device_token_here",
  "platform": "android"   // ios | android | web
}
Response
{ "success": true, "user_id": "user_123", "platform": "android" }
DELETE

Unregister Push Token

/api/v1/push/unregister

Remove a device token. Call this on logout or when the token is refreshed.

Request
{
  "user_id": "user_123",
  "token": "fcm_device_token_here"
}
Response
{ "success": true, "user_id": "user_123" }
GET

Get Push Tokens

/api/v1/push/tokens

Get all registered device tokens for a user.

Request
// Query
?user_id=user_123
Response
{
  "user_id": "user_123",
  "tokens": [
    { "token": "fcm_device_token_here", "platform": "android", "updated_at": "2026-03-11T10:00:00Z" }
  ]
}

Video Call API

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.

💰 Declare your billing tier when you create the session. Video is billed by resolution tier, and the tier comes from the 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.
⚠️ Group call capacity is plan-dependent. Room-scoped calls (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.

Standalone Session

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.

POST

Create Video Session

/api/v1/video/sessions

Allocate 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. **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.

Request
{
  "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
}
Response
// 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" } }

1:1 Video Call

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.

Sequence Diagram·React demo: IncomingCallListener.jsx + IncomingCallOverlay.jsx
Incoming Video Call Flow — Accept / Reject / Busy sequence diagram

✅ 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

POST

Start Video Call

/api/v1/unitedchat/rooms/:roomId/video-call

Initiate 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.

Request
{
  "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
}
Response
{
  "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
}
POST

Accept Call

/api/v1/unitedchat/rooms/:roomId/video-call/accept

Record call acceptance. Marks participant status as active. The accepting user joins the RTC media channel using their rtc_token.

Request
{
  "user_id": "user_B",
  "session_id": "sess_789"
}
Response
{
  "success": true,
  "session_id": "sess_789"
}
POST

Reject Call

/api/v1/unitedchat/rooms/:roomId/video-call/reject

Record a call rejection. Reason: "declined" | "busy" | "missed". A CALL_REJECT or CALL_BUSY signal is sent to the caller via HyperBabel Realtime.

Request
{
  "user_id": "user_B",
  "session_id": "sess_789",
  "reason": "busy"
}
Response
{
  "success": true
}
POST

Leave Call (recommended for 1:1 end)

/api/v1/unitedchat/rooms/:roomId/video-call/leave

Either 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.

Request
{
  "session_id": "sess_789",
  "user_id": "user_A"
}
Response
// Last active participant — session auto-ends
{ "success": true, "action": "session_ended", "active_count": 0 }
POST

End Call for Everyone (host-only)

/api/v1/unitedchat/rooms/:roomId/video-call/end

Forcefully 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.

Request
{
  "session_id": "sess_789",
  "user_id": "user_A"
}
Response
{
  "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." } }

Group Video Call

A participant can leave individually without ending the session. The session auto-ends when the last active participant leaves.

⚠️ Capacity: Up to 3 participants total (caller + 2 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
POST

Leave Group Call (Individual)

/api/v1/unitedchat/rooms/:roomId/video-call/leave

Leave 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.

Request
{
  "session_id": "sess_789",
  "user_id": "user_A"
}
Response
// 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 }
GET

Get Active Call (Join Banner)

/api/v1/unitedchat/rooms/:roomId/video-call/active

Check if a chat room has an ongoing video call. Show a 'Join ongoing call' banner instead of 'Start call' when active=true.

Request
// No body — GET request
// Header: Authorization: Bearer 
Response
{
  "active": true,
  "session": {
    "id": "sess_789",
    "call_type": "group",
    "status": "active",
    "active_participant_count": 2,
    "participants": [...]
  }
}
// or: { "active": false, "session": null }
POST

Join Session (Late-join / Re-join)

/api/v1/video/sessions/:sessionId/join

Join 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`.

Request
{
  "user_id": "user_D",
  "display_name": "Diana"
}
Response
{
  "session": { "id": "sess_789", "status": "active" },
  "token": "",
  "uid": 4
}
POST

Renew RTC Token (long calls / re-join)

/api/v1/unitedchat/rooms/:roomId/video-call/renew-token

Mint 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.

Request
{
  "user_id": "user_A",
  "session_id": "sess_789"   // optional — defaults to the room's most recent live session
}
Response
{
  "session_id": "sess_789",
  "channel_name": "uc-video-mm62pcca",
  "uid": 1,
  "rtc_token": "",
  "app_id": "",
  "expires_in": 3600
}

Realtime Signaling

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.

Channel Naming
// User private channel (call invites)
private:user:{orgId}:{userId}
// Video session channel (call events)
hb-video:{sessionId}
CALL_INVITE
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"
  }
}
CALL_ACCEPT
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"
}
CALL_REJECT
private:user:{orgId}:{caller}

Sent to caller when callee explicitly declines the call.

{
  "type": "CALL_REJECT",
  "session_id": "sess_789",
  "user_id": "user_B"
}
CALL_BUSY
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"
}
call.participant_left
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
}
call.ended
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"
}

Video Call · Error Codes

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.

HTTPcodeWhereMeaning · Recovery
400no_valid_targetsPOST /video-calltarget_user_ids is empty after deduplication and removing the caller. Ask the user to pick at least one other participant.
400limit_exceededPOST /video-callMore than 2 target_user_ids requested on this route. Cap is 3 participants total (caller + 2); POST /video/sessions allows 4.
400plan_upgrade_requiredPOST /video-call · POST /video/sessionsThe plan does not include group video, so calls are limited to 2 participants (1:1). Upgrade the organisation's plan to start group calls.
400sandbox_quality_not_allowedPOST /video/sessions · POST /stream/sessions · POST /video-callTest 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.
403sandbox_limit_reachedPOST /video/sessions · POST /stream/sessions · POST /video-call · POST /storage/presignThis 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.
402spend_cap_reachedPOST /video-call · POST /video/sessionsThe 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.
409call_fullPOST /video/sessions/:id/joinThe session is already at its participant cap for the organisation's plan. Late joiners should be told the call is full.
400forbiddenPOST /video-call"Cannot video call yourself" — the 1:1 room only contains the caller. Ask the user to pick a room with another member.
403forbiddenPOST /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).
403forbiddenPOST /video-call/endOnly 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.
403forbiddenPOST /video-call/renew-tokenThe user is not a participant of the targeted session. Re-check session_id / user_id before retrying.
404not_found/video-call/*The room (or session, for /renew-token) does not exist in this org. Typically a stale URL — refresh the room list.
409caller_busyPOST /video-callThe 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.
409targets_busyPOST /video-callEvery 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.
410session_endedPOST /video-call/acceptThe 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.

Auto-expire Safety Net

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.

Live Stream API

Full live broadcast infrastructure. Supports host broadcasting and viewer token generation. All endpoints require Authorization: Bearer hb_live_xxx and the live_stream scope.

⚠️ Live streaming is a plan entitlement. Organisations whose plan does not include it get 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, 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.
GET

List Live Streams

/api/v1/stream/sessions

List 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.

Request
// 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
Response
{
  "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
}
POST

Create Stream Session

/api/v1/stream/sessions

Create 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.

Request
{
  "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
  }
}
Response
// 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": ""
    }
  }
}
POST

Get Viewer Token

/api/v1/stream/sessions/:sessionId/viewer-token

Generate 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.

Request
{
  "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
}
Response
{
  "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": ""
}
POST

Start Broadcast

/api/v1/stream/sessions/:sessionId/start

Transition the stream session from 'created' to 'live' status. Call this when the host taps 'Go Live'. Fires a stream.session.started webhook.

Request
// No body required
// Headers
Authorization: Bearer hb_live_xxx
Response
{
  "session_id": "stream_uuid",
  "status": "live",
  "started_at": "2026-03-10T10:00:00Z"
}
POST

Stream Host Heartbeat

/api/v1/stream/sessions/:sessionId/heartbeat

Hosts 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.

Request
// No body required
// Headers
Authorization: Bearer hb_live_xxx
Response
{ "ok": true }
POST

End Broadcast

/api/v1/stream/sessions/:sessionId/end

Mark the stream as ended, calculate duration, and record usage. Fires a stream.session.ended webhook. Production streams are billed per minute.

Request
// No body required
// Headers
Authorization: Bearer hb_live_xxx
Response
{
  "session_id": "stream_uuid",
  "status": "ended",
  "duration_seconds": 3600,
  "ended_at": "2026-03-10T11:00:00Z"
}

Translation API

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.

POST

Translate Text

/api/v1/translate/text

Translate 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.

Request
{
  "text": "안녕하세요",
  "target_language": "en",
  "source_language": "auto"   // optional, default: auto-detect
}
Response
{
  "translated_text": "Hello",
  "source_language": "ko",
  "target_language": "en",
  "char_count": 5
}
POST

Translate to Multiple Languages

/api/v1/translate/batch

Translate 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).

Request
{
  "text": "안녕하세요",
  "target_languages": ["en", "ja", "fr", "zh"],
  "source_language": "auto"   // optional
}
Response
{
  "translations": {
    "en": "Hello",
    "ja": "こんにちは",
    "fr": "Bonjour",
    "zh": "你好"
  },
  "char_count": 5,
  "successful_languages": ["en", "ja", "fr", "zh"]
}
POST

Detect Language

/api/v1/translate/detect

Detect the language of a given text string. Returns the detected language code (BCP-47) and a confidence score (0.0–1.0).

Request
{
  "text": "안녕하세요"
}
Response
{
  "detected_language": "ko",
  "confidence": 0.99
}
GET

Supported Languages

/api/v1/translate/languages

Get 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.

Request
// GET — no body
// Headers
Authorization: Bearer hb_live_xxx
Response
{
  "languages": [
    { "code": "ko", "name": "Korean" },
    { "code": "en", "name": "English" },
    { "code": "ja", "name": "Japanese" },
    { "code": "zh", "name": "Chinese (Simplified)" },
    { "code": "fr", "name": "French" }
  ],
  "count": 195
}

Speech Translation API

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.

At a glance

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.

Plan allowance & metering

Paid plans include a monthly allowance of speech-translation minutes, and usage is metered per minute a speech-translation stream is active — from the moment the stream connects until it ends, rounded up to the whole minute per stream — not per call and not per participant. Two speakers captioned for the same 10-minute call consume 20 minutes.

Silence still counts while the stream is active. A stream goes idle on its own after about 60 seconds without any audio and stops accruing until audio arrives again — so during long pauses, stop sending audio frames (or send EOS to end the stream). A client that keeps sending silent frames keeps the stream active.

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.

How it works

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.

WebSocket Protocol

Connection query parameters

ParameterRequiredDescription
tokenYesEnd-user customer access JWT (from /customer/issue-token).
langYesSpoken language code — any stt_lang_cd from GET /api/v1/stt/languages.
translateNoTarget language code for live translation. Omit for transcription-only (no *_tr messages).
session_idOne ofActive video call session id (the session.id returned by POST /api/v1/video/sessions). The connecting user must be a participant.
room_idOne ofAlternative to session_id for room-scoped calls created through the United Chat call flow.

Client → server

FrameMeaning
binaryRaw 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.

Server → client (JSON text frames)

kindMeaning
readyRecognition engine connected — start streaming audio. Sent once per connection.
partialLive hypothesis of the current utterance (original language). Cumulative — each partial replaces the previous one.
finalUtterance complete (detected at natural pauses) — a clean, stable segment. Ideal unit for display history or downstream processing.
partial_tr / final_trTranslation track — same semantics as partial/final, in the translate target language. Only sent when translate was set.
keepaliveHeartbeat during audio gaps — ignore it (but treat unknown kinds as ignorable too, for forward compatibility).
errorSomething 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).

Languages

GET

Supported Spoken Languages

/api/v1/stt/languages

The 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.

Request
// GET — no body
// Headers (either works)
Authorization: Bearer hb_live_xxx        // org API key
Authorization: Bearer      // end-user token
Response
{
  "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
    }
  ]
}

Errors & Limits

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.

Statuserror.codeMeaning
401—Missing/invalid/expired customer JWT.
403subscription_inactiveThe organization has no active subscription.
403stt_not_enabledSpeech translation is not included in the org's plan.
403quota_exceededFree-plan monthly speech allocation exhausted (paid plans continue into metered overage instead).
402spend_cap_reachedThe organization reached its monthly spend limit, so no new billable session may start. Raise the limit in Console → Subscription to resume.
409no_active_callNo active video call session found with this user as a participant — check session_id/room_id and that the call hasn't ended.
429rate_limitedToo many session opens (per-user, per-minute). Back off and retry.
429concurrency_limitToo 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.

Storage API

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.

🗂️ Automatic Category by Extension

🖼️ 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.

📏 Per-file Size Limit

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.

POST

Step 1 — Request Presigned Upload URL

/api/v1/storage/presign

Pre-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. channelId must be a room / channel id (UUID) — any other value is rejected with `400 { error: { code: 'invalid_request' } }`. 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.

Request
// application/json
{
  "filename": "profile_photo.jpg",
  "mimeType": "image/jpeg",
  "fileSize": 524288,          // bytes
  "channelId": "",  // for chat uploads — a room / channel id (UUID)
  "folder": "uploads"          // for API uploads (default: "uploads")
}

// Headers
Authorization: Bearer hb_live_xxx
Response
// 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)." } }

// 400 — channelId is not a room / channel id (UUID)
{ "error": { "code": "invalid_request", "message": "channelId must be a room id" } }

// 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.

PUT {upload_url} Content-Type: image/jpeg // must match mimeType from Step 1 (raw file binary)
POST

Step 3 — Confirm Upload

/api/v1/storage/confirm

After 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.

Request
// application/json
{
  "key": "/chat//images/...",   // from presign response
  "originalName": "profile_photo.jpg",

  // Optional — honoured for API-key (hb_live_ / hb_test_) and console calls only:
  //  • channel_id: otherwise extracted from the key path (chat uploads only)
  //  • uploader_id: otherwise left empty
  // With a customer JWT both are ignored — the channel always comes from the
  // key path and the uploader is always the token's end-user.
  "channel_id":  "",
  "uploader_id": "alice-uuid"
}

// Headers
Authorization: Bearer 
Response
// 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"
  }
}

// 403 — key not in your organization ("Key not in this org"), or — with a customer
// JWT — not issued by /storage/presign (only user-content/ and chat/ keys)
{ "error": { "code": "forbidden", "message": "Key was not issued by presign" } }

// 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": "" }
→ { "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"
    }
  }
}
GET

Get Signed Download URL

/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.

Request
// GET — no body
// Header: Authorization: Bearer hb_live_xxx  or JWT
// :key = full file path from upload response
Response
{
  "url": "https://storage.hyperbabel.com/...?Expires=900&Signature=...",
  "expires_in": 900
}