For the Flutter client

Voice Room API Reference

Every endpoint: exactly what to send, exactly what comes back, and which calls need the bearer token.

Start here

  1. Log in once — keep the JWT, send it on every later call.
  2. Join when the user taps Join — the server replies with the Agora channel, uid and token.
  3. Pass those three straight to Agora — don't invent any of them.

Base URL

https://agora-web-app.azurewebsites.net        production
https://localhost:7058                        local development

Headers

Content-Type: application/json          on every request that has a body
Authorization: Bearer <jwt>             on every endpoint marked "token required"

Which endpoints need the bearer token

Endpoint Bearer token Must be admin Request body
POST /api/auth/registerNoNoYes
POST /api/auth/loginNoNoYes
POST /api/auth/logoutYesNoNone
GET /api/auth/meYesNoNone
POST /api/auth/promoteYesYesYes
POST /api/auth/force-logoutYesYesYes
GET /api/meetings/currentYesNoNone
POST /api/meetings/startYesYesYes
POST /api/meetings/joinYesNoNone
POST /api/meetings/endYesYesNone
POST /api/meetings/kickYesYesYes

Only register and login work without a token. Everything else returns 401 without one, and the four admin-only endpoints return 403 if the signed-in user is a normal user.

!
There are two different tokens. Don't mix them up. The JWT from /api/auth/login goes in the Authorization header of calls to this API. The Agora token from /api/meetings/join goes to joinChannel. The JWT never goes to Agora, and the Agora token never goes in a header.

Accounts

POST /api/auth/register No token

Creates an account and signs it in. Always creates a normal user — there is no way to register as an admin.

Send

{
  "username": "bob",           // required, 3-50 chars, letters digits . _ - only
  "password": "BobPass123",    // required, 8-128 chars
  "deviceId": "abc-123"        // optional, any stable string. See note 01.
}

Returns 200

{
  "token": "eyJhbGciOiJIUzI1NiIs...",   // the JWT
  "userId": 2,                          // also the Agora uid
  "username": "bob",
  "role": "User",
  "expiresAt": "2026-09-12T13:17:15"
}

Errors

409{ "message": "Username is already taken." }
400Validation failed. Body is the ASP.NET problem format with an errors object.
POST /api/auth/login No token

Signs in and starts a session. An account can only be signed in on one device at a time.

Send

{
  "username": "bob",           // required
  "password": "BobPass123",    // required
  "deviceId": "abc-123"        // optional but strongly recommended
}

Returns 200

{
  "token": "eyJhbGciOiJIUzI1NiIs...",
  "userId": 2,
  "username": "bob",
  "role": "User",               // "User" or "Admin"
  "expiresAt": "2026-09-12T13:17:15"
}

Errors

401{ "message": "Invalid username or password." }
409{ "message": "This account is already signed in on another device. Sign out there, or try again later.", "blockedUntil": "2026-09-12T13:17:15" }
POST /api/auth/logout Token required

Ends the session. The JWT stops working immediately and the account is free to sign in elsewhere.

Send

No body. Just the Authorization header.

Returns 200

{ "message": "Signed out." }
{ "message": "Already signed out." }     // if the session had already ended
GET /api/auth/me Token required

Who the caller is. Use it on app start to check a stored token is still valid.

Send

No body.

Returns 200

{ "userId": 2, "username": "bob", "role": "User" }
POST /api/auth/promote Admin only

Makes someone an admin, or takes it away. The affected user is signed out and must log in again for the new role to apply.

Send

{
  "username": "bob",     // required
  "role": "Admin"        // required, "Admin" or "User"
}

Returns 200

{
  "message": "bob is now Admin and must sign in again.",
  "username": "bob",
  "role": "Admin"
}

Errors

400{ "message": "Role must be one of: Admin, User." } or { "message": "Cannot demote the only remaining admin." }
404{ "message": "User not found." }
403Caller is not an admin. Empty body.
POST /api/auth/force-logout Admin only

Ends someone else's session. This is the fix when a user loses their phone and is locked out by the one-device rule.

Send

{ "username": "bob" }     // required

Returns 200

{ "message": "bob can now sign in again.", "sessionsEnded": 1 }

Errors

404{ "message": "User not found." }
403Caller is not an admin.

Meetings

GET /api/meetings/current Token required

Is a meeting running? Poll this while in a call to notice the admin ending it.

Send

No body.

Returns 200

{ "active": true,  "meetingId": 1,    "endsAt": "2026-08-13T14:17:42" }
{ "active": false, "meetingId": null, "endsAt": null }
POST /api/meetings/start Admin only

Opens the meeting and returns the admin's own join details. Safe to call twice — if a meeting is already running you get that one back rather than a second being created.

Send

{ "durationInSeconds": 3600 }    // optional field, 60 to 86400, defaults to 3600
{}                               // also valid - uses the default

Send at least {}. A completely empty body is rejected with 400.

Returns 200

{
  "meetingId": 1,
  "channelName": "m-4b0882c949754b3f8b07055b59e1e220",
  "uid": 1,
  "token": "007eJxTYNBj6Y...",      // the AGORA token, not a JWT
  "endsAt": "2026-08-13T14:17:42"
}

Errors

400durationInSeconds outside 60–86400, or empty body.
403Caller is not an admin.
POST /api/meetings/join Token required

Everything needed to join the call. The channel, uid and token are all chosen by the server.

Send

No body at all. Just the Authorization header.

Returns 200

{
  "meetingId": 1,
  "channelName": "m-4b0882c949754b3f8b07055b59e1e220",
  "uid": 2,
  "token": "007eJxTYNBj6Y...",      // the AGORA token
  "endsAt": "2026-08-13T14:17:42"
}

Errors

400{ "message": "There is no meeting in progress. Please wait for an admin to start one." }
400{ "message": "You were removed from this meeting and cannot rejoin yet.", "retryAfter": "2026-08-13T13:30:00" }
401No token, expired token, or the session has ended.
POST /api/meetings/end Admin only

Ends the meeting for everyone. Agora ejects every participant, and their app receives a banned-by-server connection change.

Send

No body.

Returns 200

{ "message": "Meeting ended.", "meetingId": 1 }

Errors

400{ "message": "There is no meeting in progress." }
502{ "message": "The meeting is closed, but Agora did not confirm removing the people still connected.", "detail": "..." } — the meeting is closed either way.
POST /api/meetings/kick Admin only

Removes one participant and stops them rejoining for a while. userId is the same number that user joined Agora with.

Send

{
  "userId": 2,                 // required, the user's id from login
  "restrictionSeconds": 300    // optional, 1 to 86400, defaults to 300
}

Returns 200

{
  "message": "bob was removed from the meeting.",
  "userId": 2,
  "retryAfter": "2026-08-13T13:30:00"
}

Errors

400{ "message": "There is no meeting in progress." } or { "message": "You cannot remove yourself." }
404{ "message": "User not found." }
502{ "message": "Agora refused to remove that participant, so nothing was changed.", "detail": "..." }

Joining Agora

Take the three values from join and pass them straight through. Don't hardcode a channel name, and don't pass 0 as the uid.

// once, at login
final auth = await api.login(username, password, deviceId);
// auth.token  -> JWT, send on every API call
// auth.userId -> this user's Agora uid

// each time the user joins
final join = await api.joinMeeting();     // JWT in header, no body

await engine.joinChannel(
  token: join.token,               // the Agora token
  channelId: join.channelName,     // server-chosen, changes every meeting
  uid: join.uid,                   // must match, or the admin can't remove them
  options: const ChannelMediaOptions(),
);

Detecting a removal or an ended meeting

engine.registerEventHandler(RtcEngineEventHandler(
  onConnectionStateChanged: (conn, state, reason) {
    if (reason == ConnectionChangedReasonType.connectionChangedBannedByServer) {
      // removed by an admin, or the meeting was ended for everyone
      leaveCallAndShowMessage();
    }
  },
));

Errors you'll see everywhere

StatusMeaningWhat the app should do
401No token, expired token, or the session ended — by logging out elsewhere, an admin, or a role changeSend the user back to the login screen
403Signed in, but not an admin. Body is empty.Hide admin controls; treat as a bug if it happens
400Validation failed, or the action isn't valid right nowShow message from the body
409Username taken, or already signed in elsewhereShow message; for login also show blockedUntil
502Agora rejected a moderation callShow message; usually a server config problem, not the user's fault

Error bodies are always { "message": "..." }, sometimes with an extra field such as retryAfter or blockedUntil. Validation failures (400) instead use the standard ASP.NET problem format:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": { "Password": ["The field Password must be a string or array type with a minimum length of '8'."] }
}

Things that will bite you

01
Always send deviceId Any stable string, generated once and stored. Without it, reinstalling the app or clearing its data locks the user out for up to 30 days, because their old session is still live and they can't log out of it. With it, signing in from the same device just replaces the old session.
02
The channel name changes every meeting Never cache or hardcode it. A token from a previous meeting is useless for the next one — that's deliberate, and it's what makes ending a meeting final.
03
The uid must be the userId from the server If the app joins with 0 or any other number, Agora knows the user by a different id than the server does, and removing them will silently do nothing.
04
A 401 can arrive mid-session Sessions can end while the app is running — an admin forces a logout, or changes their role. Handle 401 on any call, not just at startup.
05
Tokens expire when the meeting does Join near the end of a meeting and the Agora token is only valid for the remaining time. Request a fresh one via join rather than reusing an old response.
06
All timestamps are UTC expiresAt, endsAt, retryAfter and blockedUntil are UTC with no timezone suffix. Convert to local time before showing them.