All routes live under https://. Scopes: members:read to read, members:write to write. Invitations and access codes have their own page: Invitations and access codes.
A key never acts as the owner: it cannot promote, demote, suspend or remove an admin, and it never acts on its own creator (403 ).
The member objectLink to the The member object section
{
"user_id": "5b1e7c2a-3d4f-4e6a-8b9c-0d1e2f3a4b5c",
"role": "member",
"status": "active",
"joined_at": 1767225600,
"name": "Camille Durand",
"email": "camille.durand@ecole.fr",
"profile_image_url": "/user.png",
"last_active_at": 1798200000,
"never_active": false,
"groups": [
{ "id": "2d4c6e8a-1b3d-4f5a-9c7e-0a2b4c6d8e1f", "name": "2nde 3", "kind": "static", "position": 0, "is_manager": false, "source": "import", "via": "manual" }
],
"labels": [{ "id": "9b8a7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d", "key": "promo", "value": "2026", "color": "blue", "kind": "member" }],
"roles": [],
"role_sources": [],
"effective_roles": [],
"license": { "pool_id": "4e5f6a7b-8c9d-4e0f-a1b2-c3d4e5f6a7b8", "plan_id": "<plan id>", "plan_name": "<plan name>", "source": "rule" }
}| Field | Description |
|---|---|
role | Base role: owner, admin or member. |
status | active, suspended or pending. |
email | null when the key cannot see email addresses (its creator only reads the members of the groups they manage). |
never_active | The account has never been used. |
groups | The member’s groups; via is manual (added by hand) or label (matched by the rule of a dynamic group). |
roles | Roles given directly; role_sources lists each role with its origin (direct, group or label); effective_roles is their union. |
license | The member’s license (pool_id, plan_id, plan_name, source), or null. Only included in the list. |
List membersLink to the List members section
GET /members · members:read
| Parameter | Type | Description |
|---|---|---|
q | string | Searches the name and, if the key can see email addresses, the email. 128 characters. |
group_id | string | Members of a group (added by hand or matched by its rule). |
label_id | string | Members who carry a label. |
role | string | owner, admin or member (base role), or manager, billing, custom:<id> (a role received directly, through a group or through a label). |
status | string | active, suspended or pending. |
no_group | boolean | true: members of no group. |
never_active | boolean | true: accounts never used. |
license | string | The ID of a license pool, or none (no license). |
sort | string | name (default), last_active_at or joined_at. |
dir | string | asc or desc; defaults to asc for the name, desc otherwise. |
page, limit | integer | Pagination. |
The response also includes emails_visible, license_pools (the pools: id, plan_id, plan_name, name, seats, used; empty for a single-license workspace), licenses_available, sort and dir. An unknown filter value returns 400 ; license on an organization without pools returns 400 .
curl "https://chat.realmoon.ai/api/v1/orgs/$ORG_ID/members?status=active&never_active=true&limit=200" \
-H "Authorization: Bearer $MOONAI_ADMIN_KEY"import os
import time
import requests
BASE = f"https://chat.realmoon.ai/api/v1/orgs/{os.environ['ORG_ID']}"
HEADERS = {"Authorization": f"Bearer {os.environ['MOONAI_ADMIN_KEY']}"}
def all_members(**filters):
page = 1
while True:
r = requests.get(f"{BASE}/members", headers=HEADERS,
params={**filters, "page": page, "limit": 200}, timeout=30)
if r.status_code == 429:
time.sleep(int(r.headers.get("Retry-After", "5")))
continue
r.raise_for_status()
body = r.json()
yield from body["items"]
if page * 200 >= body["total"]:
break
page += 1
for m in all_members(status="active", never_active="true"):
print(m["user_id"], m["email"])const BASE = `https://chat.realmoon.ai/api/v1/orgs/${process.env.ORG_ID}`;
const HEADERS = { Authorization: `Bearer ${process.env.MOONAI_ADMIN_KEY}` };
async function* allMembers(filters = {}) {
for (let page = 1; ; page++) {
const params = new URLSearchParams({ ...filters, page: String(page), limit: '200' });
const r = await fetch(`${BASE}/members?${params}`, { headers: HEADERS });
if (!r.ok) throw new Error(await r.text());
const body = await r.json();
yield* body.items;
if (page * 200 >= body.total) break;
}
}
for await (const m of allMembers({ status: 'active', never_active: 'true' })) console.log(m.user_id, m.email);Read a member’s recordLink to the Read a member’s record section
GET / · members:read
{
"member": {
"user_id": "5b1e7c2a-3d4f-4e6a-8b9c-0d1e2f3a4b5c",
"name": "Camille Durand",
"email": "camille.durand@ecole.fr",
"role": "member",
"status": "active",
"joined_at": 1767225600,
"last_active_at": 1798200000,
"never_active": false,
"invited_by": { "user_id": "a11f2b3c-4d5e-4f6a-8b7c-9d0e1f2a3b4c", "name": "Fabien Marie" }
},
"base_role_name": null,
"roles": [{ "key": "manager", "via": "group", "via_id": "7c8d9e0f-1a2b-4c3d-8e4f-5a6b7c8d9e0f", "via_name": "Professeurs", "name": "Enseignant", "removable": false }],
"groups": [],
"labels": [],
"license": null,
"license_pools": [],
"usage": { "files": 14, "monthly_percent": 12.5, "week_percent": 30.0, "session_percent": 0.0, "period_end": 1800000000, "tier": "paid" },
"activity": null,
"emails_visible": true,
"blocked": null,
"can": { "suspend": true, "remove": true, "base_role": false, "admin_role": false, "groups": false, "labels": false, "roles": false, "license": false, "rules": false, "usage": false, "activity": false }
}| Field | Description |
|---|---|
usage | The member’s usage as a percentage of their limits (month, week, session) and their number of files; null without the right to read usage. |
activity | The last 15 audit log actions that concern them; null without the right to read the log. |
blocked | Why the key cannot act on this member: self, owner, admin or stronger (they have more rights). |
can | What the key can do to this member. |
Suspend, reactivate or change the base roleLink to the Suspend, reactivate or change the base role section
PATCH / · members:write
| Field | Type | Required | Description |
|---|---|---|---|
status | string | no | suspended (for an active member) or active (for a suspended member). |
role | string | no | admin or member. Owner only: refused to a key. |
Send at least one field. Response: {"member": , the member after the change.
curl -X PATCH https://chat.realmoon.ai/api/v1/orgs/$ORG_ID/members/$USER_ID \
-H "Authorization: Bearer $MOONAI_ADMIN_KEY" \
-H "Content-Type: application/json" \
-d '{"status": "suspended"}'r = requests.patch(f"{BASE}/members/{user_id}", headers=HEADERS, json={"status": "suspended"}, timeout=30)
r.raise_for_status()
print(r.json()["member"]["status"])const r = await fetch(`${BASE}/members/${userId}`, {
method: 'PATCH',
headers: { ...HEADERS, 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'suspended' }),
});
if (!r.ok) throw new Error(await r.text());
console.log((await r.json()).member.status);Suspending a member signs them out of all their sessions and sends the member.suspended event. Reactivating a member takes a seat: on a workspace paid by card, the seat is paid first; the member.reactivated event is sent afterward.
| Status | code | Cause |
|---|---|---|
| 400 | invalid_request | Nothing to change; suspending a member who is not active; reactivating a member who is not suspended. |
| 402 | seat_payment_failed | The seat payment failed: the owner must update their payment method. |
| 403 | forbidden | The key’s own creator, the owner, an admin, or a member with more rights than the key. |
| 404 | not_found | Not a member of the organization. |
| 409 | seats_full | No free seat left. |
| 409 | past_due | The workspace’s last payment failed. |
| 409 | payment_method_required | The new owner must add a payment method. |
Remove a memberLink to the Remove a member section
DELETE / · members:write
Removes the member from the organization; their Moon AI account remains. Their sessions in the organization are closed, and their groups, roles, labels and organization connections are deleted, along with the invitations, access codes and keys they created. The member.removed event is sent. Response: {"removed": . Same refusals as PATCH, plus 409 if the organization would be left without an active admin.
To hand over their resources first, use offboarding a member.
Act on several membersLink to the Act on several members section
POST / · members:write
| Field | Type | Required | Description |
|---|---|---|---|
action | string | yes | See below. |
user_ids | list | user_ids or filter | Up to 5,000 members. IDs that are not members are ignored. |
filter | object | user_ids or filter | The list filters: q, group_id, label_id, role, status (active or suspended), no_group, never_active, license. Up to 20,000 members. Not allowed with remove. |
value | string | depends on the action | The target group, label or role. |
is_manager | boolean | no | With add_group: make them managers. |
pool_id | string | with license | A pool, "none" (no license) or null (back to the rules). |
action | value | Effect |
|---|---|---|
suspend, reactivate | none | Suspend, reactivate. On a workspace paid by card, up to 50 reactivations per request. |
set_base_role | admin or member | Owner only. |
add_group, remove_group | group ID | Add to the group, remove from it. |
add_label, remove_label | label ID | Put on or take off a member label. |
add_role, remove_role | manager, billing or custom:<id> | Give or remove a role (also requires the roles:write scope). |
license | none (pool_id) | Assign a license pool (also requires the licenses:write scope). |
remove | none | Remove from the organization. |
{
"action": "suspend",
"matched": 42,
"done": 39,
"skipped": { "admin": 1, "already": 2 },
"failed": [],
"failed_count": 0
}skipped counts the members left out, by reason: self, owner, admin, stronger, already, not_member, out_of_scope, not_active. failed gives the first 50 failures (user_id, code, message). There is no overall transaction: what is done stays done if a later member fails.
Export membersLink to the Export members section
GET / · members:read
Accepts the list filters (except pagination and sorting) and lang=en for English column headers. Returns a CSV file (UTF-8 with BOM, ; separator), sorted by name, up to 50,000 lines:
email;nom;groupes;étiquettes;rôle de base;rôles;licence;statut;arrivé le;dernière activité
camille.durand@ecole.fr;Camille Durand;2nde 3;promo=2026;member;;<plan>;active;2026-01-01 00:00;2026-12-23 08:40Multiple values are separated by |, and dates are in UTC. A cell that starts with =, +, - or @ is prefixed with an apostrophe.
Import a CSV fileLink to the Import a CSV file section
POST / · members:write
Imports up to 5,000 lines (2 MB): each line invites a person, adds them directly (email address on a verified domain, organization not paid by card) or updates a member (groups, labels, role and license are added; nothing is ever removed).
| Parameter | Type | Description |
|---|---|---|
dry_run | boolean | true: simulation, nothing is written. |
create_missing | boolean | true: creates unknown groups and labels. |
You can send the file in three ways: multipart/ with a file field, application/ with {"csv": , or the raw CSV (text/csv).
| Column | Content |
|---|---|
email | Required. |
name | Display name. |
groups | Group names, separated by | or ;. |
labels | key=value pairs, separated by | or ;. |
role | member, admin (owner only), manager, billing, or the key of a custom role. |
license | The ID or plan of a pool. |
The header row is recognized in French or English (GET / returns the accepted names); without a header row, the columns are read in this order. The separator (,, ; or tab) is detected automatically.
curl "https://chat.realmoon.ai/api/v1/orgs/$ORG_ID/members/import?dry_run=true" \
-H "Authorization: Bearer $MOONAI_ADMIN_KEY" \
-F "file=@eleves.csv"with open("eleves.csv", "rb") as f:
r = requests.post(f"{BASE}/members/import", headers=HEADERS,
params={"dry_run": "true"}, files={"file": f}, timeout=120)
r.raise_for_status()
report = r.json()
print(report["summary"])
for line in report["lines"]:
if line["errors"]:
print(line["line"], line["email"], line["errors"])import fs from 'node:fs';
const form = new FormData();
form.append('file', new Blob([fs.readFileSync('eleves.csv')], { type: 'text/csv' }), 'eleves.csv');
const r = await fetch(`${BASE}/members/import?dry_run=true`, { method: 'POST', headers: HEADERS, body: form });
if (!r.ok) throw new Error(await r.text());
const report = await r.json();
console.log(report.summary);The response gives summary (lines per action, errors, seats left), to_create (groups and labels to create) and lines: for each line, action (invite, add_domain, update, already_member, already_invited or error), errors and warnings. An error on one line does not stop the import: the other lines are processed.
Outside a simulation, an import of up to 100 lines responds right away with the result and a job_id. Above that, it responds 202 with {"job_id", and continues in the background. Follow it with GET /, which returns status (running, done, failed or interrupted), total, done, phase and, at the end, result (the lines with errors or warnings, up to 2,000). A job’s progress is kept for 24 hours.
Each invitation sent by the import counts toward the invitation limits; the simulation does not check them.
See alsoLink to the See also section
- Invitations and access codes
- Member accounts
- Webhooks, to follow arrivals and departures