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
- Log in once — keep the JWT, send it on every later call.
- Join when the user taps Join — the server replies with the Agora channel, uid and token.
- 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/register | No | No | Yes |
POST /api/auth/login | No | No | Yes |
POST /api/auth/logout | Yes | No | None |
GET /api/auth/me | Yes | No | None |
POST /api/auth/promote | Yes | Yes | Yes |
POST /api/auth/force-logout | Yes | Yes | Yes |
GET /api/meetings/current | Yes | No | None |
POST /api/meetings/start | Yes | Yes | Yes |
POST /api/meetings/join | Yes | No | None |
POST /api/meetings/end | Yes | Yes | None |
POST /api/meetings/kick | Yes | Yes | Yes |
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.
/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
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." } |
| 400 | Validation failed. Body is the ASP.NET problem format with an errors object. |
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" } |
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
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" }
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." } |
| 403 | Caller is not an admin. Empty body. |
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." } |
| 403 | Caller is not an admin. |
Meetings
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 }
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
| 400 | durationInSeconds outside 60–86400, or empty body. |
| 403 | Caller is not an admin. |
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" } |
| 401 | No token, expired token, or the session has ended. |
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. |
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
| Status | Meaning | What the app should do |
|---|---|---|
| 401 | No token, expired token, or the session ended — by logging out elsewhere, an admin, or a role change | Send the user back to the login screen |
| 403 | Signed in, but not an admin. Body is empty. | Hide admin controls; treat as a bug if it happens |
| 400 | Validation failed, or the action isn't valid right now | Show message from the body |
| 409 | Username taken, or already signed in elsewhere | Show message; for login also show blockedUntil |
| 502 | Agora rejected a moderation call | Show 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
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.
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.
join rather than reusing an old response.
expiresAt, endsAt, retryAfter and blockedUntil are UTC with no timezone suffix. Convert to local time before showing them.