User management
The user-management endpoints let an org admin invite users, remove them, and pull consumption data from a script, automation, or AI agent — without logging into the Phoenix webapp.
All operations require an admin-scoped API key. User-
scoped keys receive 403 forbidden_admin_scope on every endpoint and
tool listed here.
Surfaces
The operations below are exposed three ways:
| Surface | Path / tool name | Auth |
|---|---|---|
| REST (admin facade) | POST /api/admin/users/invite, DELETE /api/admin/users/{userId}, GET /api/admin/users/consumption, GET /api/admin/users, GET /api/admin/api-keys, GET /api/admin/api-keys/consumption | Authorization: Bearer <admin_key> or x-api-key |
| MCP tools | admin_invite_user, admin_remove_user, admin_get_consumption, admin_list_users, admin_list_api_keys, admin_get_consumption_by_api_key | Admin-scoped API key over /api/mcp (Bearer) or /api/ai/{key}/mcp |
| Power Automate REST facade | POST /api/powerautomate/admin_invite_user (etc.) | Authorization: Bearer <admin_key> |
The REST and Power Automate paths share the same wire shape; the MCP
path wraps each call in the JSON-RPC tools/call envelope. Underlying
business logic is the same — see
webapp/src/server/api/admin/user-management.ts.
Common headers
Authorization: Bearer phx_<your-admin-key>
Content-Type: application/json
x-api-key: phx_<your-admin-key> is also accepted (preferred for Azure
APIM and Power Automate connectors).
All Phoenix API keys share the
phx_prefix; admin scope is set on the registry record, not derived from the key string. There is no visible "admin" suffix in the key itself — the Admin badge in the webapp keys table is the canonical marker.
Org isolation
The organization is always derived from the API key
(webapp_api_keys_registry.organization_slug). It is never accepted
from the request body, query, or path. An admin key for org A cannot
read or modify org B's data; cross-org probes return 404 user_not_found
or 403 forbidden_admin_scope.
Auditing
Every successful mutation writes a row to webapp_org_admin_audit_log
(see overview). For user-management, you'll
see actions invite_user, remove_user, view_consumption,
view_users, view_api_keys, and view_consumption_by_api_key.
The three view_* reads (issue #1177) are audited because consumption
data and key inventories can be sensitive in some accounts. Failed
attempts (403, 404, 409, 400) are NOT audited — the audit log records
intent only after the operation completes.
Rate limiting
All admin endpoints share the standard MCP-tool rate limit (500 requests
per minute per API key). Responses include X-RateLimit-Limit,
X-RateLimit-Remaining, and X-RateLimit-Reset headers. Exceeding the
limit returns 429 with a Retry-After header.
POST /api/admin/users/invite
Send an invitation email to a user. The recipient follows the magic-link to set a password and join the org. Does not create a user record directly.
Request body
{
"email": "newhire@acme.com",
"role": "member",
"name": "Jordan Lee"
}
| Field | Type | Required | Description |
|---|---|---|---|
email | string (RFC 5322) | Yes | Email of the invitee. Lower-cased server-side. Max 254 chars. |
role | "member" | "admin" | Yes | Role granted upon accepting the invitation. |
name | string | No | Display name (1–255 chars). |
Successful response (200)
{
"invitationId": "inv_01HXY…",
"email": "newhire@acme.com",
"role": "member",
"expiresAt": "2026-05-05T16:00:00.000Z"
}
Idempotency
If an active invitation already exists for that email × default team,
the existing invitation is returned with status 200. The audit log
records the call with metadata.idempotent = true. No duplicate row is
created and no second email is sent.
Error codes
| Status | error | Trigger |
|---|---|---|
| 400 | invalid_request | Body failed Zod validation. |
| 400 | disposable_email | Email domain is on the disposable-email blocklist. |
| 401 | unauthorized | Missing or invalid API key. |
| 403 | forbidden_admin_scope | Key is user-scoped, or the user is no longer an org admin. |
| 409 | already_member | A user with this email is already an active member of this org. |
| 429 | rate_limit_exceeded | More than 500 requests in 1 minute for this key. |
| 500 | org_misconfigured | Org has no default team (should never happen — file a bug). |
Example
curl -sS -X POST https://phoenix.hginsights.com/api/admin/users/invite \
-H "Authorization: Bearer phx_…" \
-H "Content-Type: application/json" \
-d '{"email":"newhire@acme.com","role":"member"}'
DELETE /api/admin/users/{userId}
Remove a user from the calling org. Hard-deletes all of the user's team memberships in the org and revokes their access:
- Deletes tenant
apiKeysrows for the user. - Deletes
webapp_api_keys_registryrows for(userId, organizationSlug). - Deletes
oauth_tokensrows for(userId, organizationSlug). - Invalidates the org-access cache and MCP org-context cache.
The user's public.users record is not deleted — historical
attribution (created by, audit log target, etc.) is preserved.
Path parameters
| Name | Type | Description |
|---|---|---|
userId | UUID | ID of the user to remove. |
Successful response (200)
{
"userId": "9a3a…",
"removedAt": "2026-04-29T16:42:11.000Z",
"removedMembershipsCount": 1
}
Concurrency safety
The count + delete pair runs inside a tenant transaction with a
Postgres advisory lock (pg_advisory_xact_lock(hashtext(slug))).
Concurrent removal attempts serialize on this lock, so the org-wide
last-admin guard cannot be bypassed by a race.
Error codes
| Status | error | Trigger |
|---|---|---|
| 400 | invalid_user_id | Path parameter is not a UUID. |
| 400 | cannot_remove_self | userId matches the calling user. |
| 400 | cannot_remove_owner | userId is the org owner (transfer ownership first). |
| 400 | last_admin | Removing the user would leave the org with zero active admins. |
| 401 | unauthorized | Missing or invalid API key. |
| 403 | forbidden_admin_scope | Key is user-scoped, or the user is no longer an org admin. |
| 404 | user_not_found | No active membership for that userId in this org (also returned for cross-org userId values — do not rely on this to test for user existence in other orgs). |
| 429 | rate_limit_exceeded | More than 500 requests in 1 minute for this key. |
Example
curl -sS -X DELETE https://phoenix.hginsights.com/api/admin/users/9a3a-… \
-H "Authorization: Bearer phx_…"
GET /api/admin/users/consumption
Read consumption (credits + tool calls) for the calling org.
Query parameters
| Name | Type | Required | Description |
|---|---|---|---|
userId | UUID | No | If provided, returns a per-user breakdown. Otherwise returns the org-wide ConsumptionStatus. |
from | ISO datetime | No | Window start (default: org's current billing period start). |
to | ISO datetime | No | Window end (default: org's current billing period end). |
The [from, to] window must not exceed 366 days.
Org-wide response (200, no userId)
Same shape as the existing webapp consumption view (ConsumptionStatus).
{
"organizationSlug": "acme",
"organizationName": "Acme",
"planId": "plan_growth",
"planName": "Growth",
"billingPeriod": {
"start": "2026-04-01T00:00:00.000Z",
"end": "2026-04-30T23:59:59.000Z"
},
"credits": {
"used": 1234.5,
"limit": 10000,
"remaining": 8765.5,
"percentUsed": 12.35
},
"overage": { "amount": 0, "cost": 0 },
"enforcementMode": "soft",
"isOverLimit": false,
"isCustomPricing": false
}
Per-user response (200, with userId)
{
"users": [
{
"userId": "9a3a…",
"email": "alice@acme.com",
"name": "Alice",
"callCount": 42,
"credits": 87.5,
"byTool": [
{ "toolName": "company_firmographic", "callCount": 30, "credits": 30 },
{ "toolName": "company_spend", "callCount": 12, "credits": 36 }
]
}
],
"from": "2026-04-01T00:00:00.000Z",
"to": "2026-04-30T23:59:59.000Z"
}
Cross-org isolation
When userId is provided, the user must be an active member of the
caller's org. Otherwise the response is 404 user_not_found. This
prevents an admin in org A from probing org B's user IDs.
Error codes
| Status | error | Trigger |
|---|---|---|
| 400 | invalid_request | Query params failed Zod validation. |
| 400 | invalid_range | from > to. |
| 400 | range_too_large | Window exceeds 366 days. |
| 401 | unauthorized | Missing or invalid API key. |
| 403 | forbidden_admin_scope | Key is user-scoped, or the user is no longer an org admin. |
| 404 | user_not_found | userId provided but not an active member of this org. |
| 404 | org_not_found | Calling org's slug no longer resolves (should never happen — file a bug). |
Example
# Org-wide
curl -sS "https://phoenix.hginsights.com/api/admin/users/consumption" \
-H "Authorization: Bearer phx_…"
# Per-user, last 7 days
curl -sS "https://phoenix.hginsights.com/api/admin/users/consumption?userId=9a3a-…&from=2026-04-22T00:00:00Z&to=2026-04-29T00:00:00Z" \
-H "Authorization: Bearer phx_…"
GET /api/admin/users
List org members and unexpired invitations. Supports filtering and
cursor-based pagination. See admin_list_users
for the full reference.
Query parameters
| Name | Type | Description |
|---|---|---|
role | member | admin | Filter by role. |
status | active | invited | Filter by membership status. |
limit | int (1-500, default 100) | Page size. |
cursor | string | Opaque cursor from a prior nextCursor. |
Successful response (200)
{
"users": [
{
"userId": "9a3a9b40-3a6f-4f0a-9f8e-1b7f0b2c0d10",
"email": "alice@acme.com",
"name": "Alice",
"role": "admin",
"status": "active",
"createdAt": "2026-04-17T02:10:09.740Z",
"apiKeyCount": 2,
"lifetimeCredits": 1234
}
],
"nextCursor": null
}
apiKeyCount excludes system-managed keys; lifetimeCredits is
all-time. Invited rows have userId: null.
Error codes
| HTTP | Code | Trigger |
|---|---|---|
| 400 | unknown_query_params | Unrecognized query keys. |
| 400 | duplicate_query_params | Same key passed multiple times. |
| 400 | invalid_request | Args failed Zod validation. |
| 400 | invalid_cursor | Cursor is malformed, oversized, wrong version, or from a different action. |
| 403 | forbidden_admin_scope | Key is user-scoped, or the user is no longer an org admin. |
Example
curl -sS "https://phoenix.hginsights.com/api/admin/users?role=admin" \
-H "Authorization: Bearer phx_…"
GET /api/admin/api-keys
Inventory all API keys in the org with the 12-character prefix only
— never the raw key. See admin_list_api_keys
for the full reference.
Query parameters
| Name | Type | Description |
|---|---|---|
userId | string | Filter by owner. |
scope | user | admin | Filter by scope. When omitted, all scopes are returned. |
includeSystemManaged | bool (default false) | Include OAuth/onboarding keys. |
limit | int (1-500, default 100) | Page size. |
cursor | string | Opaque cursor from a prior nextCursor. |
Successful response (200)
{
"apiKeys": [
{
"id": "snrq6up60g6ozkza7hm0z61v",
"name": "ops-script",
"keyPrefix": "phx_43b6c30f",
"scope": "admin",
"userId": "41edc4be-8ece-4e8a-98e6-ab12fadc0774",
"userEmail": "alice@acme.com",
"userName": "Alice",
"isSystemManaged": false,
"createdAt": "2026-05-02T15:32:52.116Z",
"lastUsedAt": "2026-05-02T15:35:02.900Z"
}
],
"nextCursor": null
}
keyPrefix is always exactly 12 characters. Pre-#1150 rows with
scope = NULL are coerced to "user" here.
lastUsedAt reflects the most recent successful tool invocation
(MCP tools/call) or agent run (REST /api/agents/{id}/invoke)
attributed to the key. Authentication-only events — tools/list
handshakes, OAuth probes, rate-limited requests — do NOT bump
lastUsedAt. A key that has never invoked a tool returns null. See
ADR 2026_05_ApiKeyActivityFromConsumption
(issue #1200) for rationale.
Error codes
Same as GET /api/admin/users above plus:
| HTTP | Code | Trigger |
|---|---|---|
| 403 | forbidden_admin_scope | Key is user-scoped, or the user is no longer an org admin. |
Example
curl -sS "https://phoenix.hginsights.com/api/admin/api-keys?scope=admin" \
-H "Authorization: Bearer phx_…"
GET /api/admin/api-keys/consumption
Per-key credit consumption with per-tool breakdown. Includes
deleted/rotated keys with deleted: true for incident-response use
cases. See admin_get_consumption_by_api_key
for the full reference.
Query parameters
| Name | Type | Description |
|---|---|---|
apiKeyId | string | Restrict to a single key. |
from | ISO datetime | Window start (default: org's billing-period start). |
to | ISO datetime | Window end (default: org's billing-period end). |
days | int (1-366) | Lookback window. Mutually exclusive with from/to. |
The resolved [from, to] window must not exceed 366 days.
Successful response (200)
{
"apiKeys": [
{
"apiKeyId": "qwhe1eobfnecz7rr5y9gtuyf",
"apiKeyName": "ops-script",
"apiKeyPrefix": "phx_10cc12a6",
"creatorEmail": "alice@acme.com",
"authMethod": "apikey",
"oauthClientId": null,
"oauthClientName": null,
"deleted": false,
"callCount": 96,
"credits": 297,
"byTool": [
{ "toolName": "company_install_time_series", "callCount": 21, "credits": 204 }
]
}
],
"from": "2026-04-01T00:00:00.000Z",
"to": "2026-05-01T00:00:00.000Z"
}
callCount is billable calls (cache hits excluded). Sum of per-key
credits ≤ org-wide admin_get_consumption total — unattributed
metering rows (no metadata.apiKeyId) are excluded by design.
Error codes
| HTTP | Code | Trigger |
|---|---|---|
| 400 | validation_error | Args failed Zod validation, including the days ↔ from/to mutual exclusion or from <= to rule. |
| 400 | range_too_large | Window exceeds 366 days. |
| 404 | key_not_found | apiKeyId provided but no consumption attributed in this org. |
| 403 | forbidden_admin_scope | Key is user-scoped, or the user is no longer an org admin. |
Example
curl -sS "https://phoenix.hginsights.com/api/admin/api-keys/consumption?days=30" \
-H "Authorization: Bearer phx_…"
MCP tool calls
The same operations through the MCP tools/call envelope:
{
"jsonrpc": "2.0",
"id": "1",
"method": "tools/call",
"params": {
"name": "admin_invite_user",
"arguments": { "email": "newhire@acme.com", "role": "member" }
}
}
Tool names use the admin_ prefix:
admin_invite_user— input:{ email, role, name? }admin_remove_user— input:{ user_id }(snake_case to match MCP convention)admin_get_consumption— input:{ user_id?, from?, to? }admin_list_users— input:{ role?, status?, limit?, cursor? }admin_list_api_keys— input:{ user_id?, scope?, include_system_managed?, limit?, cursor? }admin_get_consumption_by_api_key— input:{ api_key_id?, from?, to?, days? }
Admin tools are only listed in tools/list for admin-scoped keys.
A user-scoped key gets the standard tool catalog with no admin_*
entries. The public tool catalog at /api/mcp/spec also omits admin
tools.
Power Automate
Use the standard Power Automate REST facade — the same allowlist that
exposes company_firmographic etc. is extended with the three admin
tools. They show up in the connector's actions list, but calling them
with a user-scoped key still returns 403 at request time. Discovery
does not imply authorization.