Node.js server SDK
@applooma/server-sdk is a thin, dependency-free wrapper over the AppLooma RTC REST API. It runs on your server, holds your API secret, and issues the join tokens your client apps need.
Install#
npm install @applooma/server-sdk
The package uses the global fetch function, so it requires Node.js 18 or newer. It ships CommonJS and ESM builds plus TypeScript types, and has no runtime dependencies.
Configure credentials#
Read your key pair from the environment. Never write the secret into source control.
APPLOOMA_API_KEY=ak_your_keyAPPLOOMA_API_SECRET=as_your_secret
import { AppServerClient } from '@applooma/server-sdk';export const applooma = new AppServerClient({apiKey: process.env.APPLOOMA_API_KEY,apiSecret: process.env.APPLOOMA_API_SECRET,});
Every request is sent to https://api.applooma.dev/v1 with the headers x-applooma-key and x-applooma-secret. Pass baseUrl to the constructor to point at a different region or a self-hosted deployment.
The constructor throws a plain Error if apiKey or apiSecret is empty, so a misconfigured deployment fails at startup rather than on the first user request.
A token endpoint in Express#
This is the only endpoint most integrations need. Authenticate your own user first, then decide the identity and role you are willing to grant.
import express from 'express';import { AppServerClient, AppApiError } from '@applooma/server-sdk';const app = express();app.use(express.json());const applooma = new AppServerClient({apiKey: process.env.APPLOOMA_API_KEY,apiSecret: process.env.APPLOOMA_API_SECRET,});app.post('/api/rtc-token', async (req, res) => {// 1. Authenticate YOUR user. This endpoint is the gate to your quota.const user = await authenticate(req);if (!user) return res.status(401).json({ error: 'Sign in first' });// 2. Decide the channel and role yourself. Do not take the role// from the request body unless you have checked it.const channel = String(req.body.channel || '');const role = user.id === (await hostOf(channel)) ? 'host' : 'audience';try {const result = await applooma.createToken({roomName: channel, // [a-zA-Z0-9_-], 1-64 charactersidentity: user.id, // unique per participantrole, // 'host' | 'cohost' | 'audience'metadata: { name: user.displayName },});res.json({ token: result.token, wsUrl: result.wsUrl });} catch (err) {if (err instanceof AppApiError) {console.error('AppLooma', err.status, err.code, err.message);if (err.code === 'QUOTA_EXCEEDED') {return res.status(503).json({ error: 'Calling is temporarily unavailable' });}return res.status(502).json({ error: 'Could not start the call' });}throw err;}});app.listen(3001);
Two participants must never share an identity
Identity is the primary key for a participant. If a second connection joins with an identity that is already in the channel, it replaces the first one.
Method reference#
All methods are asynchronous and return a promise. Every one of them rejects with an AppApiError when the API answers with a status of 400 or above.
createToken
POST /v1/token — issues a join token for one participant.
createToken(params: CreateTokenParams): Promise<TokenResponse>
| Parameter | Type | Required | Notes |
|---|---|---|---|
roomName | string | Yes | Must match [a-zA-Z0-9_-] and be 1-64 characters. A 400 is returned otherwise. |
identity | string | Yes | 1-128 characters. Unique per participant in the channel. |
role | 'host' | 'cohost' | 'audience' | No | Defaults to 'host'. host can publish and administer the room, cohost can publish, audience can only subscribe. |
metadata | Record<string, unknown> | No | A plain object under 2048 bytes when serialised. It is embedded in the token and visible to every participant, so do not put secrets in it. |
Resolves to:
| Field | Type | Description |
|---|---|---|
token | string | The join token. Valid for 6 hours. |
wsUrl | string | The realtime endpoint your client SDK connects to. |
roomName | string | The channel name you asked for, echoed back. |
expiresAt | string | ISO 8601 timestamp of token expiry, 6 hours after issue. |
metadata.name becomes the display name
If metadata contains a name field it is also used as the participant display name. The service overwrites appId and role inside metadata with its own values, so a caller cannot claim a role it was not granted.
listRooms
GET /v1/rooms — lists the channels of your project that currently have participants.
listRooms(): Promise<AppRoom[]>
Takes no arguments. Resolves to an array; the method unwraps the rooms field of the response for you.
| Field | Type | Description |
|---|---|---|
roomName | string | The channel name as you created it. |
participantCount | number | People currently connected. |
maxParticipants | number | Capacity of the channel. 0 means the project default applies. |
createdAt | string | ISO 8601 timestamp of when the channel opened. |
listParticipants
GET /v1/rooms/{roomName}/participants — lists who is in one channel right now.
listParticipants(roomName: string): Promise<{ identity: string; name?: string; joinedAt: string | number }[]>
| Field | Type | Description |
|---|---|---|
identity | string | The identity you issued the token with. |
name | string | undefined | Display name, if metadata.name was set. |
joinedAt | string | ISO 8601 timestamp of when the participant connected. |
The room name is URL-encoded for you. An invalid name returns 400, and a channel that has already closed returns 500.
getGifts
GET /v1/gifts — the active gift catalogue for your project, as configured in the console.
getGifts(): Promise<AppGift[]>
| Field | Type | Description |
|---|---|---|
id | string | Gift identifier. Pass this to sendGift. |
name | string | Display name. |
imageUrl | string | HTTPS URL of the artwork. |
coinPrice | number | Price in your own coin unit. |
sendGift
POST /v1/gifts/send — records a gift and broadcasts an applooma.gift data message to everyone in the channel.
sendGift(params: SendGiftParams): Promise<{ txId: string; coinTotal: number; sentAt: string }>
| Parameter | Type | Required | Notes |
|---|---|---|---|
roomName | string | Yes | Must match [a-zA-Z0-9_-]{1,64}. |
giftId | string | Yes | An active gift in your catalogue. 404 if not found. |
sender | string | Yes | Identity of the sender. |
receiver | string | Yes | Identity of the receiver. |
quantity | number | No | Integer 1-9999. Defaults to 1. |
AppLooma RTC does not move money
Debit the sender in your own coin system before calling sendGift. We record the ledger entry, broadcast the animation event, and fire a gift.sent webhook. Crediting the receiver is your side of the transaction.
The broadcast is best effort. If the channel closed between the debit and the call, the ledger entry is still written and the call still succeeds.
giftLeaderboard
GET /v1/gifts/leaderboard — top senders and receivers, project-wide or for one channel.
giftLeaderboard(roomName?: string): Promise<{topSenders: { identity: string; coins: number; gifts: number }[];topReceivers: { identity: string; coins: number; gifts: number }[];}>
Omit roomName for a project-wide leaderboard. Each list holds at most 20 entries, ordered by coins descending.
Error handling#
Failed requests reject with AppApiError. It carries the HTTP status and, where the API supplies one, a machine-readable code.
class AppApiError extends Error {name: 'AppApiError';status: number; // HTTP statusmessage: string; // human-readable message from the APIcode?: string; // machine-readable code, when present}
import { AppApiError } from '@applooma/server-sdk';try {await applooma.createToken({ roomName: 'lobby', identity: user.id });} catch (err) {if (!(err instanceof AppApiError)) throw err;switch (err.code) {case 'QUOTA_EXCEEDED':// Out of minutes for the month. Alert your team and upgrade the plan.return res.status(503).json({ error: 'Calling is temporarily unavailable' });case 'ACCOUNT_SUSPENDED':// Billing or policy issue. Only the account owner can resolve it.return res.status(503).json({ error: 'Calling is temporarily unavailable' });default:if (err.status === 400) return res.status(400).json({ error: err.message });if (err.status === 401) throw new Error('AppLooma credentials are wrong');return res.status(502).json({ error: 'Upstream error' });}}
| Status | code | Meaning | What to do |
|---|---|---|---|
| 400 | — | Invalid roomName, identity, role or metadata. | Fix the request. Retrying the same body will not help. |
| 401 | — | Missing, wrong, or deactivated API key or secret. | Check your environment variables. If the key was rotated, deploy the new pair. |
| 403 | ACCOUNT_SUSPENDED | The account that owns the project is suspended. | Resolve the billing or policy issue in the console. No retry will succeed. |
| 404 | — | Gift not found, or not active in your catalogue. | Check the gift id against getGifts(). |
| 429 | QUOTA_EXCEEDED | The monthly minute allowance is spent. | Upgrade the plan. Existing calls continue; new tokens are refused. |
| 429 | — | Rate limited. 600 token requests and 1200 other requests per minute, per project. | Back off and retry with jitter. Read the standard RateLimit headers. |
| 500 | — | Server-side failure. | Retry once with backoff. If it persists, contact support with your App ID and the time. |
Not every error carries a code
Only QUOTA_EXCEEDED and ACCOUNT_SUSPENDED are emitted today. For everything else err.code is undefined, so branch on err.status as well.
What is not wrapped#
The Node SDK covers tokens, rooms and gifts. Live streaming ingest, RTMP push and cloud recording are not wrapped yet. Call them with fetch until they are.
const BASE = 'https://api.applooma.dev/v1';const headers = {'content-type': 'application/json','x-applooma-key': process.env.APPLOOMA_API_KEY,'x-applooma-secret': process.env.APPLOOMA_API_SECRET,};// Create an RTMP ingest for OBSconst res = await fetch(`${BASE}/streams/ingest`, {method: 'POST',headers,body: JSON.stringify({ roomName: 'lobby', name: 'obs-ingest', type: 'rtmp' }),});if (!res.ok) throw new Error(`ingest failed: ${res.status}`);const { ingestId, url, streamKey } = await res.json();// Give url + streamKey to the broadcaster to paste into OBS.
The Python and PHP SDKs do not wrap gifts. If you are writing a polyglot backend, keep the gifting calls in the Node service or call the REST endpoints directly.
Security#
- The API secret lives on your server only. It never belongs in a browser bundle, a mobile binary, a mobile app config file, or a repository.
- Anyone holding the secret can issue unlimited tokens against your project and spend your quota.
- Your token endpoint must authenticate the caller. An open token endpoint is the same as publishing your secret.
- Decide the role on the server. Do not copy
rolestraight from the request body, or any audience member can promote themselves to host. - If a secret leaks, rotate the key pair in the console immediately. The old secret stops working as soon as you deactivate it, so deploy the new pair first, then deactivate the old.
- Do not log the secret or full tokens. Log the status and code from
AppApiErrorinstead.