Skip to content

Users

Manage operators and their permissions. See the Permissions guide for available permissions and templates.

const TOKEN = "hv_..."; // your API key
const headers = { Authorization: `Bearer ${TOKEN}` };
POST /admin/users

Requires 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();
FieldTypeRequiredDescription
didstringyesThe DID or handle of the operator to add. A leading @ is fine. A handle is resolved to a DID
templatestringnoPermission template: viewer, maintainer, manager, or full_access. operator, the old name for maintainer, is still accepted
permissionsstring[]noExplicit 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"
}
GET /admin/users

Requires 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();

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 /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();

Response: 200 OK with the same shape as a single item from the list response.

PATCH /admin/users/{id}/permissions

Requires 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 Content
FieldTypeRequiredDescription
grantstring[]noPermissions to add
revokestring[]noPermissions to remove

Response: 204 No Content. Fetch the operator again to see their permissions.

POST /admin/users/transfer-super

Only 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",
}),
});
FieldTypeRequiredDescription
target_user_idstringyesThe ID of the operator to receive superadmin

Response: 204 No Content

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,
},
);

Response: 204 No Content