Quickstart
Create a project, get credentials, sign your first token and join a channel (curl + Node).
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; runningpnpm dev:apiafter install alone fails withERR_MODULE_NOT_FOUNDfor a missingdist). - The control plane is running: run
pnpm dev:apiin the server source directory (see Self-hosted deployment) and use its printed listener address asapi.your-domain.examplebelow. If the default port is busy, override it withPORT=8890 pnpm dev:api(PORTis 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)
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{
"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)
# 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:
| Field | Type | Description | |
|---|---|---|---|
channelName / channel | string | Channel name, 1–128 characters from [A-Za-z0-9_-]; channel is an alias | |
uid | integer | 1..2147483647; derived from userId when omitted, randomly derived when neither is given | |
userId | string | IM user id; once supplied it establishes the uid ↔ userId mapping (used by /v1/uids:resolve) | |
role | host \ | audience | Defaults to host; an audience token only carries join_channel + subscribe |
ttlSeconds | integer | 60–86400, ultimately clamped by the project's tokenTtlSeconds | |
wildcard | boolean | When true, issues a wildcard token whose channel is * (demo and integration testing only) |
#!/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):
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
rtcTokenlooks likenrt1.<base64url(payload)>.<base64url(hmac-sha256)>and SDKs must treat it as an opaque string; do not parse it and do not concatenate it. privilegeExpireAtmay be earlier thanexpireAt: 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/renewat that point is enough. The server also emits a callback with event code203(token_expiring). - A uid banned by a
join_channelrule gets403 bannedhere and never receives a usable credential.
Step 3: Join your first channel
Browsers and clients must not hold customerSecret. The correct flow is:
- Your business backend calls
POST /v1/tokenswith the Basic credentials (or integrates the IM RTC Provider API, see Token authentication); - The frontend only receives
{ appId, rtcToken, rtcUid }; - The frontend SDK initializes with them and joins
channelName.
Using the public Web SDK API (Web SDK API Reference), client code looks like this:
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:
surlin the token payload (decided by the issuer; the control plane does not write this field, leaving it to deployments that have an IM side);window.__NIMEET_SFU_URL__(a deployment bootstrap variable supplied by the page, for example the WebSocket URL of your own SFU edge);- 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):
# 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
meetingservice is published on host port8083: openhttp://<your-host>:8083in 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 usepnpm dev:apifrom 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 throughVITE_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):
node examples/run-all.mjs --only=0Sample output:
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
| Symptom | code | Handling |
|---|---|---|
| 401 | missing_authorization | The Authorization: Basic base64(customerId:customerSecret) header is missing |
| 401 | invalid_credentials | The secret does not match; make sure it is not the value from before a rotation |
| 401 | invalid_appid | customerId does not exist, or the appid in the /dev/v1 path does not belong to this credential pair |
| 403 | banned | This uid matches a join_channel ban rule; lift the rule first |
| 403 | service_not_enabled | Recording or media push is not enabled for the project; enable it through /console/projects/{projectId}/services |
| 400 | invalid_channel_name | The channel name contains illegal characters or is outside the length range |
| 415 | unsupported_media_type | Write 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:
Error response from daemon: failed to set up container networking:
Bind for 0.0.0.0:3478 failed: port is already allocated1. Find the owner (either command works)
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
- Tear the old stack down (recommended) — this repository's compose project name is fixed, so
down
collects everything:
docker compose -f deploy/compose.yml down- 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):
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_PORTindeploy/.envare 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):
# 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 docker compose -f deploy/compose.yml -f deploy/compose.extra-ports.yml up -d --build3. 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 setCOMPOSE_PROJECT_NAME. Otherwise the second checkout'supsilently 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, ordownbefore switching.
Next steps
- REST API reference: complete endpoints, parameters and curl.
- Error codes: locate failures by
code/ HTTP status. - FAQ: quotas, limits and troubleshooting.