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 — 1,000
requests per minute per organization, plus a 20-per-second burst cap
on the same window, so every API key in your org draws on one shared
budget. (These routes are REST, but they are metered against the same
org-keyed bucket as MCP tools/call traffic.) Responses include
X-RateLimit-Limit,
X-RateLimit-Remaining, and X-RateLimit-Reset headers. Exceeding the
limit returns 429 with a Retry-After header.
Separately — and not applicable to the user-management endpoints on this
page — the partner-submission MCP tools (admin_submit_*,
admin_validate_submission, admin_test_submission, admin_request_review,
admin_get_submission, admin_list_submissions) carry an additional
per-API-key cap of 60 requests/minute, reads included. See
partner troubleshooting for those limits.
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 1,000 requests in 1 minute (or 20 in 1 second) for this organization, shared across all of its API keys. |
| 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 1,000 requests in 1 minute (or 20 in 1 second) for this organization, shared across all of its API keys. |
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": 39,
"byTool": [
{ "toolName": "company_firmographic", "callCount": 30, "credits": 3 },
{ "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.