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

Next step

Review the Developer user graph and invitations.

Resource limits and startup configuration policy: Troubleshooting.

Data lifecycle