Skip to main content

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:

SurfacePath / tool nameAuth
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/consumptionAuthorization: Bearer <admin_key> or x-api-key
MCP toolsadmin_invite_user, admin_remove_user, admin_get_consumption, admin_list_users, admin_list_api_keys, admin_get_consumption_by_api_keyAdmin-scoped API key over /api/mcp (Bearer) or /api/ai/{key}/mcp
Power Automate REST facadePOST /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"
}
FieldTypeRequiredDescription
emailstring (RFC 5322)YesEmail of the invitee. Lower-cased server-side. Max 254 chars.
role"member" | "admin"YesRole granted upon accepting the invitation.
namestringNoDisplay 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

StatuserrorTrigger
400invalid_requestBody failed Zod validation.
400disposable_emailEmail domain is on the disposable-email blocklist.
401unauthorizedMissing or invalid API key.
403forbidden_admin_scopeKey is user-scoped, or the user is no longer an org admin.
409already_memberA user with this email is already an active member of this org.
429rate_limit_exceededMore than 500 requests in 1 minute for this key.
500org_misconfiguredOrg 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 apiKeys rows for the user.
  • Deletes webapp_api_keys_registry rows for (userId, organizationSlug).
  • Deletes oauth_tokens rows 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

NameTypeDescription
userIdUUIDID 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

StatuserrorTrigger
400invalid_user_idPath parameter is not a UUID.
400cannot_remove_selfuserId matches the calling user.
400cannot_remove_owneruserId is the org owner (transfer ownership first).
400last_adminRemoving the user would leave the org with zero active admins.
401unauthorizedMissing or invalid API key.
403forbidden_admin_scopeKey is user-scoped, or the user is no longer an org admin.
404user_not_foundNo 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).
429rate_limit_exceededMore 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

NameTypeRequiredDescription
userIdUUIDNoIf provided, returns a per-user breakdown. Otherwise returns the org-wide ConsumptionStatus.
fromISO datetimeNoWindow start (default: org's current billing period start).
toISO datetimeNoWindow 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

StatuserrorTrigger
400invalid_requestQuery params failed Zod validation.
400invalid_rangefrom > to.
400range_too_largeWindow exceeds 366 days.
401unauthorizedMissing or invalid API key.
403forbidden_admin_scopeKey is user-scoped, or the user is no longer an org admin.
404user_not_founduserId provided but not an active member of this org.
404org_not_foundCalling 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

NameTypeDescription
rolemember | adminFilter by role.
statusactive | invitedFilter by membership status.
limitint (1-500, default 100)Page size.
cursorstringOpaque 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

HTTPCodeTrigger
400unknown_query_paramsUnrecognized query keys.
400duplicate_query_paramsSame key passed multiple times.
400invalid_requestArgs failed Zod validation.
400invalid_cursorCursor is malformed, oversized, wrong version, or from a different action.
403forbidden_admin_scopeKey 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

NameTypeDescription
userIdstringFilter by owner.
scopeuser | adminFilter by scope. When omitted, all scopes are returned.
includeSystemManagedbool (default false)Include OAuth/onboarding keys.
limitint (1-500, default 100)Page size.
cursorstringOpaque 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:

HTTPCodeTrigger
403forbidden_admin_scopeKey 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

NameTypeDescription
apiKeyIdstringRestrict to a single key.
fromISO datetimeWindow start (default: org's billing-period start).
toISO datetimeWindow end (default: org's billing-period end).
daysint (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

HTTPCodeTrigger
400validation_errorArgs failed Zod validation, including the daysfrom/to mutual exclusion or from <= to rule.
400range_too_largeWindow exceeds 366 days.
404key_not_foundapiKeyId provided but no consumption attributed in this org.
403forbidden_admin_scopeKey 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.

See also