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 same three operations 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 | Authorization: Bearer <admin_key> or x-api-key |
| MCP tools | admin_invite_user, admin_remove_user, admin_get_consumption | 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, and view_consumption.
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_…"
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 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.
See also
- Per-tool reference pages:
admin_invite_user,admin_remove_user,admin_get_consumption - Admin operations overview
- Authentication