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 — 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"
}
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 1,000 requests in 1 minute (or 20 in 1 second) for this organization, shared across all of its API keys.
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 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

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": 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

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