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
| Method | Path | Description |
|---|---|---|
GET | /api/admin/users | List users |
POST | /api/admin/users/{id}/approve | Approve a user |
POST | /api/admin/users/{id}/reject | Reject a user |
PUT | /api/admin/users/{id}/role | Change a user's role |
PUT | /api/admin/users/{id}/status | Enable/disable a user |
GET | /api/admin/workspaces | List all workspaces |
POST | /api/admin/workspaces/{workspace_id}/recover | Recover a workspace |
DELETE | /api/admin/workspaces/{workspace_id} | Delete a workspace |
GET | /api/admin/metrics | Platform 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 state | Returned status |
|---|---|
status == "rejected" | rejected |
approved == true | approved |
| anything else | pending |
There is no "disabled" value this endpoint can ever return — see
Set user status for why.
| Status | Reason |
|---|---|
| 403 | Caller 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:
- DynamoDB:
approved → true. - Cognito:
AdminUpdateUserAttributes(custom:approved=true)+AdminEnableUser. - Slack: DM the user "Account Approved... You can now log in" (looks up their Slack user ID by email first).
| Status | Reason |
|---|---|
| 403 | Caller is not an admin |
| 404 | No such user_id |
| 5xx | The 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.
| Status | Reason |
|---|---|
| 403 | Caller is not an admin |
| 404 | No such user_id |
| 5xx | The 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.
| Status | Reason |
|---|---|
| 400 | role missing or not admin/member |
| 403 | Caller is not an admin |
| 404 | No such user_id |
Set user status
PUT /api/admin/users/{id}/status// Request
{ "enabled": false }enabled is required (missing/null → 400). 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.
| Status | Reason |
|---|---|
| 400 | enabled missing/null |
| 403 | Caller is not an admin |
| 404 | No 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.
| Status | Reason |
|---|---|
| 403 | Caller 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.
| Status | Reason |
|---|---|
| 400 | Workspace isn't currently deleted |
| 403 | Caller is not an admin |
| 404 | No 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.
| Status | Reason |
|---|---|
| 400 | Workspace is already deleted |
| 403 | Caller is not an admin |
| 404 | No 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.
| Status | Reason |
|---|---|
| 403 | Caller is not an admin |