API Reference
Admin API

Admin API

Full REST reference for the admin domain: user approvals/management, platform-wide workspace oversight, and aggregate metrics. All routes are under /api/admin, require a Cognito JWT (Authorization: Bearer <token>), and are backed by services/backend/lambda/admin_handler.py / admin_service.py. See specs/api/admin.md in the repo for the underlying spec this page is based on, and the Admin feature guide for the product-level behavior.

⚠️

Every endpoint on this page requires the caller's JWT custom:role claim to be admin — checked fresh on every call, server-side, with no partial permissions. Anything else gets 403 {"error": "Admin access required"}.

All request/response bodies on this domain are snake_case — no camelCase exceptions. The four /api/admin/backups* routes are served by this same handler but documented separately on the Backups API page.

Endpoints at a glance

MethodPathDescription
GET/api/admin/usersList users
POST/api/admin/users/{id}/approveApprove a user
POST/api/admin/users/{id}/rejectReject a user
PUT/api/admin/users/{id}/roleChange a user's role
PUT/api/admin/users/{id}/statusEnable/disable a user
GET/api/admin/workspacesList all workspaces
POST/api/admin/workspaces/{workspace_id}/recoverRecover a workspace
DELETE/api/admin/workspaces/{workspace_id}Delete a workspace
GET/api/admin/metricsPlatform metrics

List users

GET /api/admin/users
GET /api/admin/users?status=pending
GET /api/admin/users?status=approved
// 200 Response
{
  "users": [
    {
      "user_id": "user_123",
      "email": "jane@example.com",
      "name": "Jane Doe",
      "role": "member",
      "status": "pending",
      "created_at": "2026-07-10T09:00:00+00:00",
      "last_login": null
    }
  ]
}

?status= accepts pending or approved; anything else (including rejected, a typo, or omitting it) returns every user rather than erroring.

⚠️

last_login is always null. No code anywhere in the backend currently writes a last_login attribute to a user record — the field exists in the response shape but carries no real data yet.

status is derived, not stored as-is:

Stored stateReturned status
status == "rejected"rejected
approved == trueapproved
anything elsepending

There is no "disabled" value this endpoint can ever return — see Set user status for why.

StatusReason
403Caller is not an admin

Approve user

POST /api/admin/users/{id}/approve
// 200 Response
{ "status": "approved", "user_id": "user_123" }

Three side effects happen in sequence, and all three must succeed for the call to succeed:

  1. DynamoDB: approved → true.
  2. Cognito: AdminUpdateUserAttributes(custom:approved=true) + AdminEnableUser.
  3. Slack: DM the user "Account Approved... You can now log in" (looks up their Slack user ID by email first).
StatusReason
403Caller is not an admin
404No such user_id
5xxThe DynamoDB write, either Cognito call, or the Slack notification failed — errors are re-raised, not swallowed, and there is no rollback of steps that already succeeded

Reject user

POST /api/admin/users/{id}/reject
// 200 Response
{ "status": "rejected", "user_id": "user_123" }

Sets approved → false, status → "rejected" in DynamoDB, and calls Cognito AdminDisableUser.

No notification is sent to the rejected user — there is no rejection email or Slack DM in the current implementation.

StatusReason
403Caller is not an admin
404No such user_id
5xxThe DynamoDB write or Cognito call failed

Set user role

PUT /api/admin/users/{id}/role
// Request
{ "role": "admin" }

role must be admin or member. Updates both the DynamoDB role attribute and the Cognito custom:role attribute in one call.

// 200 Response
{ "status": "updated", "user_id": "user_123", "role": "admin" }
⚠️

There is no self-demotion guard — an admin can set their own role to member through this endpoint and it will succeed. The change also doesn't force a logout; it takes effect for the target user on their next token refresh.

StatusReason
400role missing or not admin/member
403Caller is not an admin
404No such user_id

Set user status

PUT /api/admin/users/{id}/status
// Request
{ "enabled": false }

enabled is required (missing/null400). Calls Cognito AdminEnableUser or AdminDisableUser accordingly.

// 200 Response
{ "status": "updated", "user_id": "user_123", "enabled": false }
⚠️

This endpoint only talks to Cognito — it never writes to the DynamoDB user record. List users has no way to reflect a user's real enabled/disabled state as a result; see the Admin feature guide for the resulting UI quirk.

StatusReason
400enabled missing/null
403Caller is not an admin
404No such user_id

List workspaces

GET /api/admin/workspaces
// 200 Response
{
  "workspaces": [
    {
      "workspace_id": "ws_123",
      "name": "Acme Marketing",
      "description": "",
      "owner_id": "user_123",
      "status": "active",
      "created_at": "2026-06-01T09:00:00+00:00",
      "updated_at": "2026-06-01T09:00:00+00:00",
      "owner_name": "Jane Doe",
      "owner_email": "jane@example.com"
    }
  ]
}

Includes soft-deleted workspaces (no status filter on the scan). A soft-deleted workspace additionally has a deleted_at field. If the owner's user record is missing, owner_name/owner_email come back as "" rather than the row erroring out.

⚠️

There is no member_count or post_count in this response — those attributes are neither stored on the workspace item nor computed here, even though the current frontend expects a memberCount field.

StatusReason
403Caller is not an admin

Recover workspace

POST /api/admin/workspaces/{workspace_id}/recover
// 200 Response
{ "workspace_id": "ws_123", "status": "active" }

Requires the workspace to currently be status: "deleted". Flips it back to active and removes deleted_at. Does not restore or touch any members/posts/ideas — those were never removed by the delete in the first place.

StatusReason
400Workspace isn't currently deleted
403Caller is not an admin
404No such workspace

Delete workspace

DELETE /api/admin/workspaces/{workspace_id}
// 200 Response
{ "workspace_id": "ws_123", "status": "deleted" }

Soft-deletes an active workspace (status → "deleted", deleted_at stamped). Returns 200 with a body, not 204.

StatusReason
400Workspace is already deleted
403Caller is not an admin
404No such workspace

Metrics

GET /api/admin/metrics
// 200 Response
{
  "total_users": 42,
  "pending_users": 3,
  "total_workspaces": 8,
  "total_posts": 0,
  "total_ideas": 0,
  "posts_published_last_30_days": 17,
  "active_users_last_7_days": 0
}
⚠️

total_posts, total_ideas, and active_users_last_7_days are hardcoded to 0 in the current implementation — not derived from any query. Only total_users, pending_users, total_workspaces, and posts_published_last_30_days reflect real data. Don't build dashboards or alerts on the three zeroed fields expecting them to change.

StatusReason
403Caller is not an admin