nimeet Docs
中文

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).
  • Server dependencies are installed (pnpm install).
  • 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.

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:
cat data/demo-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.

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

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

Next steps

Generated by the documentation build pipeline · 2026-10-31 01:24:02 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.