RTCDeveloper Docs Flyan Cloud
中文

Quickstart

Create a project, get credentials, sign your first token and join a channel (curl + Node).

Version v1.1.0 · Updated 2026-10-26 · revision 1

This page walks the whole path from obtaining credentials → signing a token → joining a channel. The api.your-domain.example host in the commands is a stand-in: when running locally, replace it with the address printed by pnpm dev:api; in production, use your API domain.

Prerequisites

  • Node.js ≥ 22 and pnpm 10 (the server runtime).
  • Dependencies are installed and built once: pnpm install && pnpm build (workspace library packages are consumed from their build output; running pnpm dev:api after install alone fails with ERR_MODULE_NOT_FOUND for a missing dist).
  • The control plane is running: run pnpm dev:api in the server source directory (see Self-hosted deployment) and use its printed listener address as api.your-domain.example below. If the default port is busy, override it with PORT=8890 pnpm dev:api (PORT is also listed in the self-hosted deployment environment table).

Step 1: Create a project and get the App ID and credentials

A project is the tenant boundary. It holds appId (public to clients), customerId + customerSecret (server-side Basic credentials) and appCertificate (used only to sign and verify tokens, and never sent to a client).

The console UI is available: run pnpm dev:console and open the local address it prints (in production use https://console.your-domain.example; the Docker stack uses the console container's public address). Demo account owner@example.com / change-me-now. The two command-line paths below are equivalent:

A. Use the demo project directly (recommended for local verification)

bash
pnpm dev:api

# `pnpm dev:api` writes the demo credentials into the server data directory (path relative to the repository root):
cat the control plane class="tok-flag">-credentials.json
json
{
  "appId": "…32 hexadecimal characters…",
  "customerId": "cid_…",
  "customerSecret": "…(written once at creation; each restart re-seeds the project and rewrites this file)",
  "appCertificate": "…(used on the server only)"
}

B. Create a project through the console BFF REST API (equivalent to "new project" in the console)

bash
# 1) Sign in to the console (demo account shown here; override with CONSOLE_SEED_EMAIL / CONSOLE_SEED_PASSWORD)
SESSION=$(curl -s -X POST "$NIMEET_API_BASE/console/auth/login" \
  -H 'Content-Type: application/json' \
  -d '{"email":"owner@example.com","password":"change-me-now"}' \
  | node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>process.stdout.write(JSON.parse(s).token))")

# 2) Create the project: appCertificate and customerSecret are returned only in this response
curl -s -X POST "$NIMEET_API_BASE/console/projects" \
  -H "Authorization: Bearer $SESSION" \
  -H 'Content-Type: application/json' \
  -d '{"name":"my-first-project","services":{"recording":true,"streaming":true}}'

project.appId / project.customerId / customerSecret / appCertificate in the response are the four values every later call needs. customerSecret is stored as a hash only, so if you lose it you can only rotate it (POST /console/projects/{projectId}/keys/rotate).

Step 2: Call the token service (equivalent to Agora's token service)

Tokens are signed by the control plane, and clients only ever receive an opaque string. The request body only requires channelName; everything else can be omitted:

FieldTypeDescription
channelName / channelstringChannel name, 1–128 characters from [A-Za-z0-9_-]; channel is an alias
uidinteger1..2147483647; derived from userId when omitted, randomly derived when neither is given
userIdstringIM user id; once supplied it establishes the uid ↔ userId mapping (used by /v1/uids:resolve)
rolehost \audienceDefaults to host; an audience token only carries join_channel + subscribe
ttlSecondsinteger60–86400, ultimately clamped by the project's tokenTtlSeconds
wildcardbooleanWhen true, issues a wildcard token whose channel is * (demo and integration testing only)
bash
#!/usr/bin/env bash
# 签发 / 校验 / 续期 Token(等价声网 Token 服务)
# 运行前提:已执行 pnpm dev:api(演示凭证写在服务端数据目录)。
set -euo pipefail

: "${NIMEET_API_BASE:=http://127.0.0.1:8787}"
CRED_FILE=$([ -f the control plane class="tok-flag">-credentials.json ] && echo the control plane class="tok-flag">-credentials.json || echo data/demo-credentials.json)
CRED=$(node -e "const c=JSON.parse(require('fs').readFileSync(process.argv[1],'utf8'));process.stdout.write(Buffer.from(c.customerId+':'+c.customerSecret).toString('base64'))" "$CRED_FILE")

# 1) 签发:host 角色,频道 weekly-sync,uid 10001
TOKEN_RESPONSE=$(curl -s -X POST "$NIMEET_API_BASE/v1/tokens" \
  -H "Authorization: Basic $CRED" \
  -H 'Content-Type: application/json' \
  -d '{"channelName":"weekly-sync","uid":10001,"role":"host","ttlSeconds":3600}')
echo "$TOKEN_RESPONSE"

# 2) 校验:channelName 会与 Token 内的频道绑定
TOKEN=$(node -e "process.stdout.write(JSON.parse(process.argv[1]).rtcToken)" "$TOKEN_RESPONSE")
curl -s -G "$NIMEET_API_BASE/v1/tokens/verify" \
  -H "Authorization: Basic $CRED" \
  --data-urlencode "token=$TOKEN" \
  --data-urlencode 'channelName=weekly-sync'

# 3) 续期:保持 channel / uid / role / 权限不变,只刷新有效期
curl -s -X POST "$NIMEET_API_BASE/v1/tokens/renew" \
  -H "Authorization: Basic $CRED" \
  -H 'Content-Type: application/json' \
  -d "{\"token\":\"$TOKEN\"}"

# 4) uid -> IM userId(部分结果语义:未知 uid 省略)
curl -s -X POST "$NIMEET_API_BASE/v1/uids:resolve" \
  -H "Authorization: Basic $CRED" \
  -H 'Content-Type: application/json' \
  -d '{"rtcUids":[10001,10002,2147483647]}'

Node version (all examples are directly executable ESM scripts):

javascript
import { createApi, summarize } from './_lib.mjs';

export const meta = {
  id: '01-issue-token',
  title: '签发频道 Token',
  safe: true,
  needsApi: true,
};

/**
 * POST /v1/tokens
 * - 显式 uid + host 角色
 * - audience 角色(权限自动降为 join_channel + subscribe)
 * - wildcard Token(频道为 `*`)
 * - userId 派生 uid(同一 userId 稳定得到同一 rtcUid)
 */
export async function run(ctx) {
  const api = ctx.api;

  const host = await api('POST', '/v1/tokens', {
    body: { channelName: 'weekly-sync', uid: 10001, role: 'host', ttlSeconds: 3600 },
  });
  const audience = await api('POST', '/v1/tokens', {
    body: { channelName: 'weekly-sync', uid: 10002, role: 'audience' },
  });
  const wildcard = await api('POST', '/v1/tokens', { body: { wildcard: true, uid: 10003 } });
  const first = await api('POST', '/v1/tokens', { body: { channelName: 'weekly-sync', userId: 'user_alice' } });
  const second = await api('POST', '/v1/tokens', { body: { channelName: 'weekly-sync', userId: 'user_alice' } });

  const hostBody = host.json;
  const audienceBody = audience.json;
  const wildcardBody = wildcard.json;
  const firstBody = first.json;
  const secondBody = second.json;

  ctx.assert(hostBody.appId === ctx.credentials.appId, 'appId 应与项目 App ID 一致');
  ctx.assert(hostBody.rtcUid === 10001, '显式 uid 应被保留');
  ctx.assert(
    Array.isArray(hostBody.privileges) &&
      hostBody.privileges.includes('publish_video') &&
      hostBody.privileges.includes('join_channel'),
    'host 应包含发布权限',
  );
  ctx.assert(
    Array.isArray(audienceBody.privileges) &&
      !audienceBody.privileges.includes('publish_audio') &&
      audienceBody.privileges.includes('subscribe'),
    'audience 不应有发布权限',
  );
  ctx.assert(wildcardBody.channelName === '*', 'wildcard Token 的频道应为 *');
  ctx.assert(firstBody.rtcUid === secondBody.rtcUid, '同一 userId 的 rtcUid 应稳定');
  ctx.assert(hostBody.rtcToken.startsWith('nrt1.'), 'Token 前缀应为 nrt1.,SDK 侧必须当作不透明字符串');

  return {
    summary:
      `签发 5 个 Token;host uid=${hostBody.rtcUid} 权限=${hostBody.privileges.join(',')};` +
      `audience 权限=${audienceBody.privileges.join(',')};userId 派生 uid=${firstBody.rtcUid}(稳定)`,
    details: {
      host: summarize('host', {
        appId: hostBody.appId,
        channelName: hostBody.channelName,
        rtcUid: hostBody.rtcUid,
        role: hostBody.role,
        expireAt: hostBody.expireAt,
        privilegeExpireAt: hostBody.privilegeExpireAt,
      }),
      tokenPrefix: hostBody.rtcToken.slice(0, 24) + '…',
    },
  };
}

Key points:

  • The returned rtcToken looks like nrt1.<base64url(payload)>.<base64url(hmac-sha256)> and SDKs must treat it as an opaque string; do not parse it and do not concatenate it.
  • privilegeExpireAt may be earlier than expireAt: the privilege window (pexp) is separate from the token lifetime (exp), matching Agora AccessToken2 semantics.
  • 30 seconds before the privilege expires the SDK emits token-privilege-will-expire; calling /v1/tokens/renew at that point is enough. The server also emits a callback with event code 203 (token_expiring).
  • A uid banned by a join_channel rule gets 403 banned here and never receives a usable credential.

Step 3: Join your first channel

Browsers and clients must not hold customerSecret. The correct flow is:

  1. Your business backend calls POST /v1/tokens with the Basic credentials (or integrates the IM RTC Provider API, see Token authentication);
  2. The frontend only receives { appId, rtcToken, rtcUid };
  3. The frontend SDK initializes with them and joins channelName.

Using the public Web SDK API (Web SDK API Reference), client code looks like this:

javascript
import AgoraRTC from '@nimeet/rtc-web';

// 1) Initialize with the App ID (equivalent to Agora AgoraRTC.createClient)
const client = AgoraRTC.createClient({ mode: 'rtc', codec: 'vp8' });

// 2) Subscribe to remote streams: on user-published, subscribe and render as needed
client.on('user-published', async (user, mediaType) => {
  await client.subscribe(user, mediaType);
  if (mediaType === 'video') user.videoTrack?.play('remote-player');
  if (mediaType === 'audio') user.audioTrack?.play();
});

// 3) Join the channel: the token comes from your backend calling POST /v1/tokens
const uid = await client.join('<your App ID>', 'weekly-sync', '<rtcToken>', 10001);

// 4) Capture and publish local audio and video
const [micTrack, cameraTrack] = await AgoraRTC.createMicrophoneAndCameraTracks();
cameraTrack.play('local-player');
await client.publish([micTrack, cameraTrack]);

// 5) Leave and release the capture devices
await client.leave();
micTrack.close();
cameraTrack.close();

The page needs two containers: <div id="local-player"></div> and <div id="remote-player"></div>. The SDK resolves the media edge address in the following order:

  1. surl in the token payload (decided by the issuer; the control plane does not write this field, leaving it to deployments that have an IM side);
  2. window.__NIMEET_SFU_URL__ (a deployment bootstrap variable supplied by the page, for example the WebSocket URL of your own SFU edge);
  3. same-origin /rtc (reverse-proxying the LiveKit WebSocket onto the same origin, so the browser needs no configuration at all).

Without a browser environment you can still run an offline self-check of the same public path from the documentation examples directory: node examples/16-sdk-core-flow.mjs.

Step 4: Run two-person audio/video locally (Docker required)

The first three steps only use the API. The default in-memory SFU simulator verifies tokens, rooms, callbacks and error codes only: there is no real media path between browsers, so two windows will not see each other. To get real two-person audio/video, bring up the repository's full stack (API, media plane and meeting demo together):

bash
# From the repository root: build and start the full stack (the first build takes a few minutes)
docker compose -f deploy/compose.yml up -d --build
  • The meeting service is published on host port 8083: open http://<your-host>:8083 in the browser (loopback address or a LAN IP both work).
  • Open the same address in two incognito windows, enter the same channel name (for example weekly-sync), allow camera and microphone, and you should see each other.
  • To verify tokens, rooms and callbacks only (no real media): run docker compose -f deploy/compose.yml up -d api, or use pnpm dev:api from Steps 1–3 (in-memory simulator).
  • If the API runs as a local process but you still want real media, start it with NIMEET_SFU_ADAPTER=livekit (see Self-hosted deployment); the media address is supplied through VITE_SFU_URL.

End-to-end self-check

With the API running, run the docs example runner from the documentation examples directory (it reads demo-credentials.json from the server data directory, or the NIMEET_CUSTOMER_ID / NIMEET_CUSTOMER_SECRET environment variables):

bash
node examples/run-all.mjs --only=0

Sample output:

text
ni-meet docs example runner
  API:         <api-base-url> (ni-meet-api v0.1.0, uptime 3s)
  Credentials: file data/demo-credentials.json
  Mode:        safe examples only (read-only / token / telemetry)
  ✓ 00-health.mjs                       12ms  GET /healthz → ok · ni-meet-api v0.1.0
  ✓ 01-issue-token.mjs                  38ms  issued 5 tokens; host uid=10001 …

Common failures and how to handle them

SymptomcodeHandling
401missing_authorizationThe Authorization: Basic base64(customerId:customerSecret) header is missing
401invalid_credentialsThe secret does not match; make sure it is not the value from before a rotation
401invalid_appidcustomerId does not exist, or the appid in the /dev/v1 path does not belong to this credential pair
403bannedThis uid matches a join_channel ban rule; lift the rule first
403service_not_enabledRecording or media push is not enabled for the project; enable it through /console/projects/{projectId}/services
400invalid_channel_nameThe channel name contains illegal characters or is outside the length range
415unsupported_media_typeWrite requests must send Content-Type: application/json
Step 4 fails to start: Bind for 0.0.0.0:3478 failed: port is already allocated (or 7880, 50000-50100)—another media stack already holds the port on this host; see “Ports already in use” below

For the complete error-code table and the SDK error numbers see Error codes.

Ports already in use and multiple checkouts

When another media stack (or another checkout of the same orchestration) already holds the host ports, the docker compose up -d --build from step 4 fails while creating containers:

text
Error response from daemon: failed to set up container networking:
Bind for 0.0.0.0:3478 failed: port is already allocated

1. Find the owner (either command works)

bash
lsof -nP -i :3478 -i :7880                  # macOS / Linux: which process holds the port
docker ps | grep -E '3478|7880'

2. Pick one of two fixes

  1. Tear the old stack down (recommended) — this repository's compose project name is fixed, so down

collects everything:

bash
   docker compose -f deploy/compose.yml down
  1. Run both stacks side by side:
    • Give the second one its own project name (the default comes from the deploy/ directory name, so two

checkouts collide):

bash
     docker compose -p nimeet-b -f deploy/compose.yml up -d --build

For a persistent setting, put COMPOSE_PROJECT_NAME=nimeet-b in deploy/.env (template: deploy/.env.example).

  • Move the host ports: API_PORT / EGRESS_PORT in deploy/.env are easy to change; the media ports

(3478 / 7880 / 7881 / 50000-50100) are fixed values in deploy/compose.yml, so use an override file that only remaps the host side (Compose v2.24+ supports !override):

yaml
     # deploy/compose.extra-ports.yml
     services:
       livekit:
         ports: !override
           - '17880:7880'
           - '17881:7881'
           - '51000-51100:50000-50100/udp'
           - '13478:3478/udp'
       api:
         environment:
           # the address the browser dials for media must follow
           NIMEET_DEMO_SERVER_URL: ws://<your-host>:17880
bash
     docker compose -f deploy/compose.yml -f deploy/compose.extra-ports.yml up -d --build

3. Verify — every row of docker compose -f deploy/compose.yml ps reads healthy (services without a healthcheck read running); curl -s -o /dev/null -w '%{http_code}' http://<your-host>:8083 returns 200; then repeat step 4 with two incognito windows in the same channel — if both sides see the remote video, you are done.

When several checkouts run side by side, always pass -p <name> or set COMPOSE_PROJECT_NAME. Otherwise the second checkout's up silently takes over and recreates the first checkout's containers (volumes and ports move with it), which shows up as “the other window's video suddenly dropped”. Either pin one project name per checkout, or down before switching.

Next steps

Generated by the documentation build pipeline · 2026-11-05 14:43:28 UTC · Documentation center v1.1.0.

Content follows the public API definitions.

ni-meet is a self-hosted RTC PaaS with a LiveKit-based media plane (Apache-2.0) and an Agora-compatible API.