Admin API
Target reader: developers building the management backend, developer console, or integration tests.
The admin API uses contracts version 0.4.0-alpha.1 and is served locally at http://localhost:4100. It uses developer JWT bearer auth, not project keys. Current Alpha supports developer registration and login, organization/project setup, project keys, invitations, feedback list/detail, attachments, status and priority updates, comments, and audit logs.
The Developer Console and all management clients use /admin/v1/*. Removed Admin /v1/* compatibility paths return 404; the Public API is a separate service whose /v1/* routes remain canonical.
A seeded or bootstrapped system administrator has exactly one additional system-wide capability: the read-only /admin/v1/system/users list of users' basic profile fields. system_admin is an account user_type; it never bypasses organization or project authorization. Every tenant API uses the caller's real membership role in the target organization or project (owner, admin, developer, qa, or viewer). Without that membership, targeted tenant requests return 403, and collections and the Developer Graph omit the tenant. Creating a new organization atomically gives its creator an owner membership.
Accounts now require email verification before tenant access. Access tokens bind a real server session; logout revokes the current session, while logout-all, password change/reset, disable and deletion revoke all applicable sessions. Old Alpha identities and data are not migrated. See Account and security for recovery, deletion, SMTP and retained-data boundaries.
Route Map
/admin/v1/* is the only management namespace.
| Area | Canonical routes |
|---|---|
| Data lifecycle | POST /admin/v1/feedback/reports/{report_id}/deletion, POST /admin/v1/projects/{project_id}/deletion, POST /admin/v1/organizations/{organization_id}/deletion, GET /admin/v1/data-deletions/{deletion_id}, POST /admin/v1/projects/{project_id}/export |
| Liveness and readiness | GET /healthz, GET /readyz |
| Auth | POST /admin/v1/auth/register, POST /admin/v1/auth/login, POST /admin/v1/auth/logout, GET /admin/v1/auth/me , POST /admin/v1/auth/verification/request, POST /admin/v1/auth/verification/confirm, POST /admin/v1/auth/password/reset/request, POST /admin/v1/auth/password/reset/confirm, POST /admin/v1/auth/password/change, POST /admin/v1/auth/logout-all, POST /admin/v1/auth/account/delete |
| Developer graph | GET /admin/v1/developer-graph |
| Organizations | GET|POST /admin/v1/organizations, GET|PATCH /admin/v1/organizations/{organization_id}, GET /admin/v1/organizations/{organization_id}/members, PATCH|DELETE /admin/v1/organizations/{organization_id}/members/{user_id} |
| Organization invitations | GET|POST /admin/v1/organizations/{organization_id}/developer-invitations, POST /admin/v1/organizations/{organization_id}/developer-invitations/{invitation_id}/revoke, POST /admin/v1/invitations/accept |
| Projects | GET|POST /admin/v1/projects, GET|PATCH /admin/v1/projects/{project_id}, GET /admin/v1/projects/{project_id}/overview |
| Project members and invitations | GET /admin/v1/projects/{project_id}/members, PATCH|DELETE /admin/v1/projects/{project_id}/members/{user_id}, GET|POST /admin/v1/projects/{project_id}/invitations, POST /admin/v1/projects/{project_id}/invitations/{invitation_id}/revoke |
| Project keys | GET|POST /admin/v1/projects/{project_id}/keys, POST /admin/v1/projects/{project_id}/keys/{key_id}/revoke |
| Feedback | GET /admin/v1/feedback/reports, GET|PATCH /admin/v1/feedback/reports/{report_id} |
| Feedback comments | GET|POST /admin/v1/feedback/reports/{report_id}/comments |
| Attachments | GET /admin/v1/feedback/reports/{report_id}/attachments, GET /admin/v1/feedback/reports/{report_id}/attachments/{attachment_id}/download-url |
| Audit and users | GET /admin/v1/audit-logs, GET /admin/v1/system/users |
The attachment download endpoint returns the exact top-level object { "url": "https://...", "expires_at": "<RFC3339>" }; it does not wrap the signed URL.
Path names use the current implementation identifier report_id; it is the same feedback report ID referred to as feedback_id in product-level GOAL text.
Project key creation returns { "key": { ...masked metadata... }, "plaintext_key": "enb_pk_..." }. Copy plaintext_key once; list and revoke responses return only the masked key object and never use project_key as a response field.
Every current Key contains its stored four-character last_four. Old suffix-less data is not supported. Comment and audit history expose author_deleted and actor_deleted; deleted-account identity fields are empty and the UI displays “Deleted user”. Cleared invitation recipient mailboxes are empty only in non-pending history.
Organization owners can change any member role and grant owner; organization admins can change or remove non-owner members but cannot grant owner. Demoting or removing the last owner returns 409 admin.organization_member.last_owner. A same-role PATCH is idempotent. Removing an organization membership does not remove a direct project membership held by that user. Successful role changes and removals write organization_member.role_updated or organization_member.removed audit entries; rejected and no-op requests do not.
Pagination and stable ordering
All 12 canonical list GET operations accept limit and offset and return the exact top-level { items, offset, limit, total, has_more } envelope. They do not return cursor, next_cursor, nested pagination, count, or next_offset fields.
{
"items": [],
"offset": 0,
"limit": 50,
"total": 0,
"has_more": false
}
Omitting limit uses 50; valid values are 1 through 100. Omitting offset uses 0, and it cannot be negative. An explicit empty value, duplicate parameter, non-integer, or out-of-range value returns 400 admin.request.invalid. total counts the tenant-authorized, business-filtered result before offset/limit; has_more says only whether that filtered result has another page. A valid offset beyond total returns 200 with empty items, not an error.
| List GET | Fixed order |
|---|---|
/admin/v1/organizations |
name ASC, id ASC |
/admin/v1/organizations/{organization_id}/members |
email ASC, user_id ASC |
/admin/v1/organizations/{organization_id}/developer-invitations |
created_at DESC, id DESC |
/admin/v1/projects |
name ASC, id ASC |
/admin/v1/projects/{project_id}/members |
email ASC, user_id ASC |
/admin/v1/projects/{project_id}/invitations |
created_at DESC, id DESC |
/admin/v1/projects/{project_id}/keys |
created_at DESC, id DESC |
/admin/v1/feedback/reports |
received_at DESC, id DESC |
/admin/v1/feedback/reports/{report_id}/attachments |
created_at ASC, id ASC |
/admin/v1/feedback/reports/{report_id}/comments |
created_at ASC, id ASC |
/admin/v1/audit-logs |
created_at DESC, id DESC |
/admin/v1/system/users |
email ASC, id ASC |
The feedback list supports only project_id, status, category, and case-insensitive search filters; search matches title/description. The Developer Graph is a navigation aggregate that returns the current user, visible organizations, projects, and memberships together. It is not one of these 12 list GET operations and does not accept pagination parameters.
Organization settings
PATCH /admin/v1/organizations/{organization_id} accepts exactly one JSON object containing the required name field. It updates only that display name; missing, unknown, duplicate, or extra fields, including slug, return 400 admin.organization.invalid. The organization ID and slug are immutable.
The original name is limited to 120 Unicode code points before surrounding Unicode whitespace is trimmed. The normalized name must not be empty. Case and internal whitespace are preserved, and organization names are not unique: another organization may use the same normalized name without causing a 409.
Only a caller with an explicit owner or admin membership in this organization may rename it. developer, qa, and viewer members receive 403; a system_admin account has no tenant bypass and also needs an explicit owner or admin membership. A normalized no-op returns 200 with the complete current { "organization": ... }, leaves updated_at unchanged, and writes no audit event. A real change updates the organization and writes exactly one organization.name_updated event atomically. The event targets the organization, has no project_id, and its metadata is exactly { "old_name": ..., "new_name": ... }.
Keep the bearer token and organization ID in local environment variables instead of placing secrets in the command or the document:
ADMIN_API="${ADMIN_API:-http://localhost:4100}"
: "${ACCESS_TOKEN:?export ACCESS_TOKEN first}"
: "${ORG_ID:?export ORG_ID first}"
curl --fail-with-body -X PATCH "$ADMIN_API/admin/v1/organizations/$ORG_ID" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-H "Accept-Language: en" \
-d '{"name":"Echo Nebula Studio"}'
Project settings
PATCH /admin/v1/projects/{project_id} updates name, default_locale, default_environment, and sdk_features. The request must contain at least one of those fields, rejects every other top-level field, and preserves each omitted value. name and default_environment are trimmed while preserving case. default_locale must be exactly zh-CN, en, or ja and is not trimmed or case-normalized.
When sdk_features is present, it is a complete replacement object: screenshot, recent_logs, breadcrumbs, offline_queue, and contact must all be present as booleans. A partial feature object is invalid. slug is read-only, is not accepted by PATCH, and does not change when the project name changes.
Only a caller whose effective project role is owner or admin can update settings: an organization owner/admin or a direct project admin. system_admin alone grants no access. Developer, QA, and viewer roles receive 403. Invalid, empty, or unknown fields return 400 admin.project.invalid, while a missing project returns 404 admin.project.not_found; this operation has no 409 case.
A normalized no-op returns 200 with the current complete { "project": ... }, leaves updated_at unchanged, and writes no audit event. One or more real changes are atomic and write one project.updated event. Its metadata contains canonically ordered changed_fields plus before and after objects containing only changed fields; an SDK feature change records the complete five-flag object on both sides.
curl -X PATCH "http://localhost:4100/admin/v1/projects/proj_demo" \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-H "Accept-Language: en" \
-d '{
"name": "Echo Arena",
"default_locale": "en",
"default_environment": "Production",
"sdk_features": {
"screenshot": true,
"recent_logs": false,
"breadcrumbs": true,
"offline_queue": true,
"contact": false
}
}'
Liveness, readiness, and browser access
GET /healthz proves only that the Admin API process can answer. GET /readyz is unauthenticated and checks PostgreSQL plus the configured object-storage bucket within the bounded readiness timeout.
| HTTP | Overall | PostgreSQL | Object storage | Meaning |
|---|---|---|---|---|
200 |
ready |
ready |
ready |
Tenant data and attachment operations are ready. |
200 |
degraded |
ready |
degraded |
Text management remains available; attachment operations may fail. |
503 |
unavailable |
unavailable |
ready or degraded |
The Admin API cannot serve tenant data. |
Every readiness response uses Cache-Control: no-store and returns only status, service, and the two component statuses; it does not expose dependency errors, endpoints, bucket names, or credentials.
Browser access is controlled by the exact-origin ADMIN_CORS_ALLOWED_ORIGINS list. An allowed origin is echoed in Access-Control-Allow-Origin; a different, wildcard-like, or null origin receives 403. Responses include Vary: Origin, never enable credentialed CORS, and requests without an Origin header remain available to non-browser clients. Bearer tokens still travel in the Authorization header.
Minimal example
curl -X POST "http://localhost:4100/admin/v1/auth/register" \
-H "Content-Type: application/json" \
-H "Accept-Language: en" \
-d '{
"email": "new-developer@example.test",
"password": "change-me-123",
"display_name": "Demo Developer",
"preferred_locale": "en"
}'
curl -X POST "http://localhost:4100/admin/v1/auth/verification/confirm" \
-H "Content-Type: application/json" \
-d '{"email":"new-developer@example.test","password":"change-me-123","code":"<8-digit email code>"}'
curl -X POST "http://localhost:4100/admin/v1/auth/login" \
-H "Content-Type: application/json" \
-d '{
"email": "new-developer@example.test",
"password": "change-me-123"
}'
curl "http://localhost:4100/admin/v1/feedback/reports?project_id=proj_demo&status=new&category=bug&search=stuck&limit=50&offset=0" \
-H "Authorization: Bearer <access_token>"
curl -X POST "http://localhost:4100/admin/v1/feedback/reports/fbr_demo/comments" \
-H "Authorization: Bearer <access_token>" \
-H "Content-Type: application/json" \
-d '{"body":"QA confirmed the screenshot context."}'
curl "http://localhost:4100/admin/v1/audit-logs?project_id=proj_demo" \
-H "Authorization: Bearer <access_token>"
curl "http://localhost:4100/admin/v1/audit-logs?organization_id=org_demo" \
-H "Authorization: Bearer <access_token>"
Contracted feedback statuses are new, reviewing, resolved, ignored, and archived.
Contracted feedback priorities are unset, low, medium, high, and urgent.
Common mistakes
- Do not expose project key plaintext in admin list responses.
- Do not authenticate admin routes with
X-Project-Key. - Do not treat the
system_adminuser type as a tenant role or cross-tenant pass. - Do not bypass organization or project membership checks when reading feedback, comments, attachments, or audit logs.
- Do not remove the last organization owner or assume organization removal revokes direct project access.
- Do not send
slug, ID, or any field other thannamewhen renaming an organization. - Do not include
accept_tokenortoken_hashin audit metadata. - Do not treat project keys as admin credentials.
- Do not send a partial
sdk_featuresobject or try to update the read-only project slug. - Do not send cursor parameters or read legacy nested pagination fields from list responses.
Next step
Review the Developer user graph and invitations.
Resource limits and startup configuration policy: Troubleshooting.