Skip to content
MeridFlow AiFlow v8.x • self-hosted

Team management

Authentication briefly covers logging in to get an admin session token. This page is the full reference: the complete login and profile surface, plus the full admin-user management API for inviting teammates, changing roles, and deactivating accounts.

Authentication

Every endpoint on this page takes an admin session token as a bearer token (Authorization: Bearer <token>), except POST /api/v1/auth/login itself. That endpoint takes no credential at all: it's how you obtain the token in the first place. None of these endpoints accept an API key.

Log in: POST /api/v1/auth/login

No authentication required. Exchanges an admin's email and password for a session token.

Request

Field Type Required Description
email string Yes Must be a syntactically valid email address.
password string Yes No length or format constraint is enforced on login itself (constraints apply when the password is first set, see Invite an admin below).
curl -X POST https://api.your-domain.com/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email": "owner@client-domain.com", "password": "a-strong-password"}'
import httpx

response = httpx.post(
    "https://api.your-domain.com/api/v1/auth/login",
    json={"email": "owner@client-domain.com", "password": "a-strong-password"},
)
response.raise_for_status()
login = response.json()
admin_token = login["access_token"]
const response = await fetch("https://api.your-domain.com/api/v1/auth/login", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    email: "owner@client-domain.com",
    password: "a-strong-password",
  }),
});
const login = await response.json();
const adminToken = login.access_token;

A successful login returns 200 OK:

Field Type Description
access_token string A JWT bearer token. Send it as Authorization: Bearer <access_token> on every subsequent admin request.
token_type string Always the literal string "bearer".
expires_in integer Token lifetime in seconds from the moment of this response. 8 hours (28800) by default; the deployment operator can change this.
{
  "access_token": "eyJhbGciOiJIUzI1NiIs...",
  "token_type": "bearer",
  "expires_in": 28800
}

An unknown email, a wrong password, or a deactivated account (is_active is false, see Deactivate an admin below) all return the same 401 Unauthorized with {"detail": "Invalid email or password"}. That's deliberate: it keeps a caller from using this endpoint to enumerate which email addresses exist on the deployment.

There is no refresh-token flow and no server-side session table: the token is a stateless, self-contained JWT. To stay logged in past expires_in seconds, log in again for a fresh token. There's no way to extend an existing one.

Get your own profile: GET /api/v1/auth/me

Requires a valid session token. Any role (Owner, Admin, Editor, or Viewer) can call this endpoint, and it returns only the caller's own record.

curl https://api.your-domain.com/api/v1/auth/me \
  -H "Authorization: Bearer $ADMIN_TOKEN"
import httpx

response = httpx.get(
    "https://api.your-domain.com/api/v1/auth/me",
    headers={"Authorization": f"Bearer {admin_token}"},
)
response.raise_for_status()
me = response.json()
const response = await fetch("https://api.your-domain.com/api/v1/auth/me", {
  headers: { Authorization: `Bearer ${adminToken}` },
});
const me = await response.json();
{
  "id": 1,
  "email": "owner@client-domain.com",
  "role": "owner",
  "is_active": true
}
Field Type Description
id integer This admin's id.
email string This admin's email address.
role string One of owner, admin, editor, viewer. See Roles below.
is_active boolean Always true when returned from this endpoint, a deactivated account can't authenticate at all, so it can never successfully call /me to see is_active: false about itself.

Roles

AiFlow has four deployment-wide admin roles:

Role Capabilities
owner Full control of the deployment, including creating, role-changing, and deactivating other admins, and the only role that can act on another Owner account or grant the Owner role. Also the only role permitted to call Data retention's deletion endpoint.
admin Manages agents, tools, Triggers, and API keys. Can also call every endpoint on this page below (list, invite, change role, deactivate) for non-Owner accounts, see the note under Invite an admin.
editor Can edit agent content, context documents, and Triggers (Triggers create/update/delete/test all require Editor or above). Cannot manage admin users, API keys, or webhook subscriptions.
viewer Read-only: can view logs, dashboards, and call transcripts (Calls, Event log list/detail endpoints all permit Viewer), but cannot create, edit, delete, or test anything.

owner and admin see and act on every agent in the deployment, always. editor and viewer are scoped per agent: each needs an explicit grant to a specific agent before they can see or touch it at all (see Per-agent access below). This wasn't always true. A deployment created before per-agent access shipped had every Editor or Viewer see every agent, and upgrading preserved that: every Editor or Viewer account was automatically granted every agent that existed at upgrade time. Only agents created after upgrading require an explicit grant.

List admin users: GET /api/v1/admin-users

Requires Owner or Admin. Returns every admin account on the deployment, ordered by id, with no pagination.

curl https://api.your-domain.com/api/v1/admin-users \
  -H "Authorization: Bearer $ADMIN_TOKEN"
import httpx

response = httpx.get(
    "https://api.your-domain.com/api/v1/admin-users",
    headers={"Authorization": f"Bearer {admin_token}"},
)
response.raise_for_status()
admins = response.json()
const response = await fetch("https://api.your-domain.com/api/v1/admin-users", {
  headers: { Authorization: `Bearer ${adminToken}` },
});
const admins = await response.json();
[
  {
    "id": 1,
    "email": "owner@client-domain.com",
    "role": "owner",
    "is_active": true
  },
  {
    "id": 2,
    "email": "support@client-domain.com",
    "role": "viewer",
    "is_active": true
  }
]

Each entry has the same four fields documented under GET /api/v1/auth/me above: id (integer), email (string), role (string), is_active (boolean).

Invite an admin: POST /api/v1/admin-users

Requires Owner or Admin.

Request

Field Type Required Default Description
email string Yes Must be a syntactically valid email address. Must not already belong to an existing admin.
password string Yes Minimum 8 characters. There is no server-side "temporary password, must change on first login" flow, whatever you set here is the account's real password until changed.
role string Yes (no default) One of owner, admin, editor, viewer. There is no default; every invite must state a role.
curl -X POST https://api.your-domain.com/api/v1/admin-users \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -d '{
        "email": "support@client-domain.com",
        "password": "a-temporary-password",
        "role": "viewer"
      }'
import httpx

response = httpx.post(
    "https://api.your-domain.com/api/v1/admin-users",
    headers={"Authorization": f"Bearer {admin_token}"},
    json={
        "email": "support@client-domain.com",
        "password": "a-temporary-password",
        "role": "viewer",
    },
)
response.raise_for_status()
new_admin = response.json()
const response = await fetch("https://api.your-domain.com/api/v1/admin-users", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${adminToken}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    email: "support@client-domain.com",
    password: "a-temporary-password",
    role: "viewer",
  }),
});
const newAdmin = await response.json();

Returns 201 Created with the new admin's id, email, role, and is_active: true. An account is active immediately on creation: there's no separate activation step.

Only an Owner can grant the Owner role

An Admin-role caller can invite a new admin, editor, or viewer account through this endpoint: that's within the Owner-or-Admin bar the route itself enforces. But if the request body's role is "owner", the request is also checked against the caller's own role. If the caller isn't an Owner, this returns 403 Forbidden with {"detail": "Only an Owner can grant the Owner role"}, even though the caller cleared the endpoint's base Owner-or-Admin requirement.

If email already belongs to an existing admin, this returns 409 Conflict with {"detail": "An admin with email '<email>' already exists"}.

Change an admin's role: PATCH /api/v1/admin-users/{admin_user_id}/role

Requires Owner or Admin.

Request

Field Type Required Description
role string Yes One of owner, admin, editor, viewer, the account's new role.
curl -X PATCH https://api.your-domain.com/api/v1/admin-users/2/role \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -d '{"role": "editor"}'
import httpx

response = httpx.patch(
    "https://api.your-domain.com/api/v1/admin-users/2/role",
    headers={"Authorization": f"Bearer {admin_token}"},
    json={"role": "editor"},
)
response.raise_for_status()
updated = response.json()
const response = await fetch(
  "https://api.your-domain.com/api/v1/admin-users/2/role",
  {
    method: "PATCH",
    headers: {
      Authorization: `Bearer ${adminToken}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ role: "editor" }),
  },
);
const updated = await response.json();

Returns 200 OK with the updated admin record on success. admin_user_id not matching any existing admin returns 404 Not Found.

Two guardrails apply, independent of the base Owner-or-Admin check:

  • Owner involvement requires an Owner caller. If the target account's current role is owner, or the new role being set is owner, the caller must be an Owner. Otherwise this returns 403 Forbidden with {"detail": "Only an Owner can grant or modify the Owner role"}. An Admin can freely move an account between admin, editor, and viewer, but cannot touch an Owner account in either direction.
  • The deployment can never be left with no active Owner or Admin. Demoting someone to editor or viewer is rejected with 409 Conflict when no other active owner or admin would remain. The response body is {"detail": "This would leave the deployment with no active Owner or Admin"}. Only a demotion out of that tier can trip this. Promoting someone, or moving between owner and admin, never does.

Deactivate an admin: PATCH /api/v1/admin-users/{admin_user_id}/deactivate

Requires Owner or Admin. No request body.

curl -X PATCH https://api.your-domain.com/api/v1/admin-users/2/deactivate \
  -H "Authorization: Bearer $ADMIN_TOKEN"
import httpx

response = httpx.patch(
    "https://api.your-domain.com/api/v1/admin-users/2/deactivate",
    headers={"Authorization": f"Bearer {admin_token}"},
)
response.raise_for_status()
deactivated = response.json()
const response = await fetch(
  "https://api.your-domain.com/api/v1/admin-users/2/deactivate",
  { method: "PATCH", headers: { Authorization: `Bearer ${adminToken}` } },
);
const deactivated = await response.json();

Returns 200 OK with the admin record, now showing is_active: false. There is no "reactivate" endpoint. If an account needs access restored, that has to happen directly at the database level: this API only exposes one-way deactivation.

The same two guardrail categories apply as for role changes:

  • If the target account's role is owner, the caller must be an Owner. Otherwise this returns 403 Forbidden with {"detail": "Only an Owner can deactivate an Owner"}.
  • If deactivating this account would leave zero other active owner-or-admin accounts, it returns 409 Conflict with {"detail": "This would leave the deployment with no active Owner or Admin"}. Unlike the role-change guardrail, this check applies unconditionally here: there's no carve-out for a role that's still owner or admin, deactivation always removes the account from the active pool.

admin_user_id not matching any existing admin returns 404 Not Found.

Deactivating yourself

Nothing prevents an Owner or Admin from deactivating their own account. Only the "zero active Owner or Admin" and "Owner-only" guardrails above apply, and both are keyed on the target account, not on whether the target and the caller are the same admin. If you're the deployment's only Owner, deactivating yourself is blocked by the zero-Owner-or-Admin guardrail. If there's at least one other active Owner or Admin, it isn't blocked: this endpoint will let you lock yourself out of your own account.

Per-agent access

Only meaningful for editor or viewer accounts. owner and admin accounts already see every agent unconditionally (see Roles above).

Get an admin's granted agents: GET /api/v1/admin-users/{admin_user_id}/agent-permissions

Requires Owner or Admin.

curl https://api.your-domain.com/api/v1/admin-users/2/agent-permissions \
  -H "Authorization: Bearer $ADMIN_TOKEN"
{ "agent_ids": [1, 3] }

Set an admin's granted agents: PUT /api/v1/admin-users/{admin_user_id}/agent-permissions

Requires Owner or Admin. Replaces the admin's entire granted-agent set with the list given. This isn't additive: omitting an agent id that was previously granted revokes it.

Field Type Required Description
agent_ids array[integer] Yes The complete set of agent ids this admin should now see.
curl -X PUT https://api.your-domain.com/api/v1/admin-users/2/agent-permissions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -d '{"agent_ids": [1, 3]}'
import httpx

response = httpx.put(
    "https://api.your-domain.com/api/v1/admin-users/2/agent-permissions",
    headers={"Authorization": f"Bearer {admin_token}"},
    json={"agent_ids": [1, 3]},
)
response.raise_for_status()
{ "agent_ids": [1, 3] }

Any id in agent_ids that doesn't match an existing agent returns 422 Unprocessable Entity. Granting an owner or admin account access this way is harmless but pointless: they never consult it.

Every change here is audited

Invitations, role changes, and deactivations all write an audit log entry (resource_type: "admin_user"). Setting an admin's per-agent access writes one too (resource_type: "agent_permissions"). Who changed whose access, and when, is always reconstructable later.

Next

  • SSO: let your team sign in with your identity provider instead of an AiFlow-specific password.
  • Audit log: review every admin-user change made through this page's endpoints.
  • API keys: the other credential type, for machine integrations rather than people.