Manage operators and their permissions. See the Permissions guide for available permissions and templates.
const TOKEN = "hv_..."; // your API keyconst headers = { Authorization: `Bearer ${TOKEN}` };const TOKEN = "hv_..."; // your API keyconst headers = { Authorization: `Bearer ${TOKEN}` };let token = "hv_..."; // your API keyconst token = "hv_..." // your API key# All examples assume $TOKEN is an API key (hv_...)AUTH="Authorization: Bearer $TOKEN"Add an operator
Section titled “Add an operator”POST /admin/usersRequires users:create permission. You cannot grant permissions you don’t have yourself (escalation guard).
interface User { id: string; did: string; is_super: boolean; permissions: string[];}
interface CreatedUser { id: string; // The resolved DID, even when a handle was sent. did: string;}
const response = await fetch("http://127.0.0.1:3000/admin/users", { method: "POST", headers: { ...headers, "Content-Type": "application/json", }, body: JSON.stringify({ did: "did:plc:newuser", template: "maintainer", }),});const data: CreatedUser = await response.json();const response = await fetch("http://127.0.0.1:3000/admin/users", { method: "POST", headers: { ...headers, "Content-Type": "application/json", }, body: JSON.stringify({ did: "did:plc:newuser", template: "maintainer", }),});const data = await response.json();let response = client .post("http://127.0.0.1:3000/admin/users") .bearer_auth(token) .json(&serde_json::json!({ "did": "did:plc:newuser", "template": "maintainer" })) .send() .await?;let data: serde_json::Value = response.json().await?;body := bytes.NewBufferString(`{ "did": "did:plc:newuser", "template": "maintainer"}`)req, _ := http.NewRequest("POST", "http://127.0.0.1:3000/admin/users", body)req.Header.Set("Authorization", "Bearer "+token)req.Header.Set("Content-Type", "application/json")resp, err := http.DefaultClient.Do(req)if err != nil { log.Fatal(err)}defer resp.Body.Close()curl -X POST http://127.0.0.1:3000/admin/users \ -H "$AUTH" \ -H "Content-Type: application/json" \ -d '{ "did": "did:plc:newuser", "template": "maintainer" }'| Field | Type | Required | Description |
|---|---|---|---|
did | string | yes | The DID or handle of the operator to add. A leading @ is fine. A handle is resolved to a DID |
template | string | no | Permission template: viewer, maintainer, manager, or full_access. operator, the old name for maintainer, is still accepted |
permissions | string[] | no | Explicit list of permissions to grant. When present, template is ignored |
If neither template nor permissions is provided, the operator gets the viewer template. Names in permissions are not checked here, so a misspelt one is stored and grants nothing.
Returns 400 Bad Request if a handle can’t be resolved, 403 Forbidden if you grant a permission you don’t hold, and 409 Conflict if the operator already exists.
Response: 201 Created
{ "id": "550e8400-e29b-41d4-a716-446655440000", "did": "did:plc:newuser"}List operators
Section titled “List operators”GET /admin/usersRequires users:read permission. Operators are ordered by created_at, and each permissions array is sorted alphabetically.
interface UserWithTimestamps { id: string; did: string; is_super: boolean; permissions: string[]; created_at: string; last_used_at: string | null;}
const response = await fetch("http://127.0.0.1:3000/admin/users", { headers,});const data: UserWithTimestamps[] = await response.json();const response = await fetch("http://127.0.0.1:3000/admin/users", { headers,});const data = await response.json();let response = client .get("http://127.0.0.1:3000/admin/users") .bearer_auth(token) .send() .await?;let data: serde_json::Value = response.json().await?;req, _ := http.NewRequest("GET", "http://127.0.0.1:3000/admin/users", nil)req.Header.Set("Authorization", "Bearer "+token)resp, err := http.DefaultClient.Do(req)if err != nil { log.Fatal(err)}defer resp.Body.Close()curl http://127.0.0.1:3000/admin/users -H "$AUTH"Response: 200 OK
[ { "id": "550e8400-e29b-41d4-a716-446655440000", "did": "did:plc:admin", "is_super": true, "permissions": ["api-keys:create", "api-keys:delete", "api-keys:read", "backfill:create", "backfill:read", "events:read", "lexicons:create", "lexicons:delete", "lexicons:read", "records:delete", "records:delete-collection", "records:read", "script-variables:create", "script-variables:delete", "script-variables:read", "stats:read", "users:create", "users:delete", "users:read", "users:update"], "created_at": "2025-01-01T00:00:00Z", "last_used_at": "2025-01-02T12:00:00Z" }]Get an operator
Section titled “Get an operator”GET /admin/users/{id}Requires users:read permission.
const response = await fetch( "http://127.0.0.1:3000/admin/users/550e8400-e29b-41d4-a716-446655440000", { headers },);const data: UserWithTimestamps = await response.json();const response = await fetch( "http://127.0.0.1:3000/admin/users/550e8400-e29b-41d4-a716-446655440000", { headers },);const data = await response.json();let response = client .get("http://127.0.0.1:3000/admin/users/550e8400-e29b-41d4-a716-446655440000") .bearer_auth(token) .send() .await?;let data: serde_json::Value = response.json().await?;req, _ := http.NewRequest("GET", "http://127.0.0.1:3000/admin/users/550e8400-e29b-41d4-a716-446655440000", nil)req.Header.Set("Authorization", "Bearer "+token)resp, err := http.DefaultClient.Do(req)if err != nil { log.Fatal(err)}defer resp.Body.Close()curl http://127.0.0.1:3000/admin/users/550e8400-e29b-41d4-a716-446655440000 -H "$AUTH"Response: 200 OK with the same shape as a single item from the list response.
Update an operator’s permissions
Section titled “Update an operator’s permissions”PATCH /admin/users/{id}/permissionsRequires users:update permission. You cannot grant permissions you don’t have yourself, change your own permissions, or modify the permissions of an operator with superadmin; each returns 403 Forbidden. An unrecognized permission name in grant or revoke returns 400 Bad Request, and an unknown operator 404 Not Found.
const response = await fetch( "http://127.0.0.1:3000/admin/users/550e8400-e29b-41d4-a716-446655440000/permissions", { method: "PATCH", headers: { ...headers, "Content-Type": "application/json", }, body: JSON.stringify({ grant: ["lexicons:create", "lexicons:delete"], revoke: ["records:delete"], }), },);if (!response.ok) throw new Error(`update failed: ${response.status}`); // 204 No Contentconst response = await fetch( "http://127.0.0.1:3000/admin/users/550e8400-e29b-41d4-a716-446655440000/permissions", { method: "PATCH", headers: { ...headers, "Content-Type": "application/json", }, body: JSON.stringify({ grant: ["lexicons:create", "lexicons:delete"], revoke: ["records:delete"], }), },);if (!response.ok) throw new Error(`update failed: ${response.status}`); // 204 No Contentlet response = client .patch("http://127.0.0.1:3000/admin/users/550e8400-e29b-41d4-a716-446655440000/permissions") .bearer_auth(token) .json(&serde_json::json!({ "grant": ["lexicons:create", "lexicons:delete"], "revoke": ["records:delete"] })) .send() .await? .error_for_status()?; // 204 No Contentbody := bytes.NewBufferString(`{ "grant": ["lexicons:create", "lexicons:delete"], "revoke": ["records:delete"]}`)req, _ := http.NewRequest("PATCH", "http://127.0.0.1:3000/admin/users/550e8400-e29b-41d4-a716-446655440000/permissions", body)req.Header.Set("Authorization", "Bearer "+token)req.Header.Set("Content-Type", "application/json")resp, err := http.DefaultClient.Do(req)if err != nil { log.Fatal(err)}defer resp.Body.Close()curl -X PATCH http://127.0.0.1:3000/admin/users/550e8400-e29b-41d4-a716-446655440000/permissions \ -H "$AUTH" \ -H "Content-Type: application/json" \ -d '{ "grant": ["lexicons:create", "lexicons:delete"], "revoke": ["records:delete"] }'| Field | Type | Required | Description |
|---|---|---|---|
grant | string[] | no | Permissions to add |
revoke | string[] | no | Permissions to remove |
Response: 204 No Content. Fetch the operator again to see their permissions.
Transfer superadmin
Section titled “Transfer superadmin”POST /admin/users/transfer-superOnly an operator with superadmin can call this endpoint; anyone else gets 403 Forbidden. It gives superadmin to another existing operator, grants them every permission, and takes superadmin from the caller, who keeps their stored permissions. All three changes happen in one transaction, and an unknown target_user_id returns 404 Not Found with nothing changed.
The dashboard’s Users page marks the operator with superadmin as Owner, and offers this endpoint as Transfer Ownership. PUT /admin/platform/super-user is reserved for a hosting platform’s key and returns 403 Forbidden to an operator.
const response = await fetch("http://127.0.0.1:3000/admin/users/transfer-super", { method: "POST", headers: { ...headers, "Content-Type": "application/json", }, body: JSON.stringify({ target_user_id: "550e8400-e29b-41d4-a716-446655440000", }),});const response = await fetch("http://127.0.0.1:3000/admin/users/transfer-super", { method: "POST", headers: { ...headers, "Content-Type": "application/json", }, body: JSON.stringify({ target_user_id: "550e8400-e29b-41d4-a716-446655440000", }),});let response = client .post("http://127.0.0.1:3000/admin/users/transfer-super") .bearer_auth(token) .json(&serde_json::json!({ "target_user_id": "550e8400-e29b-41d4-a716-446655440000" })) .send() .await?;body := bytes.NewBufferString(`{ "target_user_id": "550e8400-e29b-41d4-a716-446655440000" }`)req, _ := http.NewRequest("POST", "http://127.0.0.1:3000/admin/users/transfer-super", body)req.Header.Set("Authorization", "Bearer "+token)req.Header.Set("Content-Type", "application/json")resp, err := http.DefaultClient.Do(req)if err != nil { log.Fatal(err)}defer resp.Body.Close()curl -X POST http://127.0.0.1:3000/admin/users/transfer-super \ -H "$AUTH" \ -H "Content-Type: application/json" \ -d '{ "target_user_id": "550e8400-e29b-41d4-a716-446655440000" }'| Field | Type | Required | Description |
|---|---|---|---|
target_ | string | yes | The ID of the operator to receive superadmin |
Response: 204 No Content
Delete an operator
Section titled “Delete an operator”DELETE /admin/users/{id}Requires users:delete permission. You cannot delete an operator with superadmin, or yourself; either returns 403 Forbidden. Deleting an operator also revokes their API keys.
const response = await fetch( "http://127.0.0.1:3000/admin/users/550e8400-e29b-41d4-a716-446655440000", { method: "DELETE", headers, },);const response = await fetch( "http://127.0.0.1:3000/admin/users/550e8400-e29b-41d4-a716-446655440000", { method: "DELETE", headers, },);let response = client .delete("http://127.0.0.1:3000/admin/users/550e8400-e29b-41d4-a716-446655440000") .bearer_auth(token) .send() .await?;req, _ := http.NewRequest("DELETE", "http://127.0.0.1:3000/admin/users/550e8400-e29b-41d4-a716-446655440000", nil)req.Header.Set("Authorization", "Bearer "+token)resp, err := http.DefaultClient.Do(req)if err != nil { log.Fatal(err)}defer resp.Body.Close()curl -X DELETE http://127.0.0.1:3000/admin/users/550e8400-e29b-41d4-a716-446655440000 \ -H "$AUTH"Response: 204 No Content
© HappyProto

