Users
Endpoints for managing user accounts (listing, creating, updating and deleting users, avatars, API keys and 2FA), user groups, and invitations.
Endpoints for managing user accounts (listing, creating, updating and deleting users, avatars, API keys and 2FA), user groups, and invitations.
List Users
/users
Parameters
| Name | Type | Description |
|---|---|---|
| page optional | integer | Page number for pagination (default: 1) |
| per_page optional | integer | Number of results per page (default: 20, max: 1000, both set in the plugin's `pagination` config) |
| search optional | string | Filter by a free-text query across username, email, and fullname. |
| access optional | string | Filter to users with effective access to a permission (e.g. `admin.login`, `api.super`). `permission` is accepted as an alias. |
| group optional | string | Filter to members of a single account group. |
| filter optional | string | Active Users-tab id (see `GET /users/filters`). Fires `onApiUserListFilter` so a plugin can narrow the listing before pagination. Works with both the Flex and the plain file account storage. Omit or use `all` for the unfiltered list. |
{
"data": [
{
"username": "editor",
"email": "[email protected]",
"fullname": "Jane Editor",
"title": "",
"state": "enabled",
"language": "",
"content_editor": "",
"access": { "api": { "access": true, "pages": { "read": true, "write": true } } },
"groups": [],
"avatar_url": null,
"twofa_enabled": false,
"twofa_secret": false,
"created": "2026-09-01T10:00:00+00:00",
"modified": "2026-09-01T10:00:00+00:00"
}
],
"meta": {
"pagination": { "page": 1, "per_page": 20, "total": 1, "total_pages": 1 }
},
"links": {
"self": "/api/v1/users?page=1&per_page=20"
}
}
Response Codes
Each user row also carries the values of any custom fields the site added to the account blueprint, and an extra map when a plugin supplies column values for it. twofa_secret is a boolean that only says whether a secret has been generated; the secret itself is never returned.
List User Tabs
/users/filters
{
"data": {
"tabs": [
{ "id": "all", "plugin": "api", "label": "All Users" },
{ "id": "active", "plugin": "my-plugin", "label": "Active", "icon": "fa-bolt", "priority": 10 }
],
"defaultFilter": "all",
"showAll": true
}
}
Response Codes
The response data is an object with three keys:
tabs— the ordered tab row. Each tab carries anid(sent back as?filter=<id>;allis reserved for the built-in tab), apluginslug, and a plain-textlabel. Tabs may also includeicon,priority(higher sorts earlier), andbadge/badgeEndpointfor a live count.defaultFilter— the tab the client should open on when nofilteris present in the URL. Defaults toall; a plugin can nominate another tab via$event['defaultFilter'](it is honoured only when it maps to a tab the caller can see, otherwise it falls back to the first tab).showAll— whether the built-in "All Users" tab is present. It istrueby default; a plugin can set$event['showAll'] = falseto drop it when showing every account isn't a useful (or safe) landing view. "All Users" is only dropped once the plugin has contributed at least one authorized tab, so the row is never empty.
A plugin filter can only narrow the listing: it runs after search, and the access and group filters and pagination are applied by the core endpoint after it, so a tab can never widen visibility or change the response envelope. Tabs work with both account backends. With Flex accounts the event's collection is a Flex collection; with plain file accounts it is an ArrayCollection of users keyed by username, and only accounts already in the list are kept from whatever the plugin hands back. See the API Events page for the onApiUserListFilters and onApiUserListFilter contracts.
List User Columns
/users/columns
{
"data": {
"columns": [
{
"id": "my-plugin-valid-till",
"plugin": "my-plugin",
"label": "Valid until",
"field": "subscription.valid_till",
"formatter": "datetime",
"sortable": false,
"priority": 50
}
]
}
}
Response Codes
The response data.columns is the ordered list of plugin-declared columns. Each column carries:
id— unique column id.plugin— the owning plugin slug.label— the plain-text column header.field— the key to read from each user'sextramap onGET /users.formatter— one oftext,link,date,datetime,boolean,number,badge. The server validates this against the whitelist and falls back totextfor anything else; the client renders it, so no markup or renderer crosses the wire.sortable— optional; whether the column offers a client-side sort of the current page.priority— optional; higher sorts earlier.
Columns are plugin-owned but safe by construction: the values in each user's extra map are scalars only, an authorize check is re-run server-side (and stripped from this response), and the data event is isolated so a misbehaving plugin degrades to missing values rather than breaking the listing. Column values are resolved only for the accounts on the current page — never for every account.
See the API Events page for the onApiUserListColumns and onApiUserListColumnData contracts.
Get User
/users/{username}
Parameters
| Name | Type | Description |
|---|---|---|
| username required | string | The username to retrieve |
{
"data": {
"username": "editor",
"email": "[email protected]",
"fullname": "Jane Editor",
"title": "",
"state": "enabled",
"language": "",
"content_editor": "",
"access": { "api": { "access": true, "pages": { "read": true, "write": true } } },
"groups": [],
"avatar_url": null,
"twofa_enabled": false,
"twofa_secret": false,
"created": "2026-09-01T10:00:00+00:00",
"modified": "2026-09-01T10:00:00+00:00",
"twofa_global_enabled": true
}
}
Response Codes
The response carries an ETag header; send it back as If-Match on Update User to guard against overwriting a concurrent change. twofa_secret only says whether a secret has been generated, and twofa_global_enabled says whether the Login plugin's 2FA support is installed, so a client knows whether to offer 2FA setup. Any custom fields the site added to the account blueprint are included too.
Create User
/users
Parameters
| Name | Type | Description |
|---|---|---|
| username required | string | Unique username, 3 to 64 characters. It cannot start with a period or contain `..`, and cannot contain \ / ? * : ; { } or a line break. |
| password required | string | User password. It must match the site's `system.pwd_regex` when one is set, and be at least 8 characters when none is. |
| email required | string | User email address |
| fullname optional | string | Full display name |
| title optional | string | Title shown with the account |
| state optional | string | Account state, `enabled` (default) or `disabled` |
| access optional | object | Permission access object. Only a super admin can grant `admin.super` or `api.super`. |
| groups optional | array | Group names. Applied only when the caller is a super admin; ignored otherwise. |
{"username": "editor", "password": "SecurePass123!", "email": "[email protected]", "fullname": "Jane Editor"}
{"data": {"username": "editor", "email": "[email protected]", "fullname": "Jane Editor", "title": "", "state": "enabled", "language": "", "content_editor": "", "access": {}, "groups": [], "avatar_url": null, "twofa_enabled": false, "twofa_secret": false, "created": "2026-09-21T10:00:00+00:00", "modified": "2026-09-21T10:00:00+00:00"}}
Response Codes
Any custom fields the site added to the account blueprint (user/blueprints/user/account.yaml) can be sent in the body too, and are validated against that blueprint.
Update User
/users/{username}
Parameters
| Name | Type | Description |
|---|---|---|
| username required | string | The username to update (path parameter) |
| email optional | string | Updated email address |
| fullname optional | string | Updated full display name |
| title optional | string | Updated title |
| language optional | string | Preferred admin language |
| content_editor optional | string | Preferred content editor |
| state optional | string | Updated account state (`enabled` or `disabled`). Requires `api.users.write`. |
| access optional | object | Updated permission access object. Requires `api.users.write`; only a super admin can grant super access. |
| groups optional | array | Updated group names. Super admins only. |
| password optional | string | New password. It must match `system.pwd_regex` when set, and be at least 8 characters when not. |
{"fullname": "Jane Q. Editor", "password": "N3wSecurePass!"}
{"data": {"username": "editor", "email": "[email protected]", "fullname": "Jane Q. Editor", "title": "", "state": "enabled", "language": "", "content_editor": "", "access": {}, "groups": [], "avatar_url": null, "twofa_enabled": false, "twofa_secret": false, "created": "2026-09-01T10:00:00+00:00", "modified": "2026-09-21T10:00:00+00:00", "twofa_global_enabled": true}}
Response Codes
Supports optimistic concurrency control via the If-Match header. Include the ETag from your last GET request to prevent overwriting concurrent changes.
twofa_enabled and twofa_secret are ignored here, so a client can send back the whole user object without error. Two-factor authentication only changes through the Enable 2FA and Disable 2FA endpoints, which check a code when you change your own account.
Changing the password or setting state to disabled invalidates every API token already issued for the account. Custom fields the site added to the account blueprint can be sent as well.
Delete User
/users/{username}
Parameters
| Name | Type | Description |
|---|---|---|
| username required | string | The username to delete |
Response Codes
Upload Avatar
/users/{username}/avatar
Parameters
| Name | Type | Description |
|---|---|---|
| username required | string | The username to upload the avatar for |
| avatar required | file | Image file (PNG, JPEG, or WebP, up to 8 MB). The field may also be named `file`. The file contents are checked, not the declared type. |
{"data": {"username": "admin", "email": "[email protected]", "fullname": "Site Admin", "title": "", "state": "enabled", "language": "", "content_editor": "", "access": {"admin": {"super": true}}, "groups": [], "avatar_url": "/api/v1/thumbnails/3f2a9c1b.jpg", "twofa_enabled": false, "twofa_secret": false, "created": "2026-09-01T10:00:00+00:00", "modified": "2026-09-01T10:00:00+00:00"}}
Response Codes
Delete Avatar
/users/{username}/avatar
Parameters
| Name | Type | Description |
|---|---|---|
| username required | string | The username to remove the avatar for |
{"data": {"username": "admin", "email": "[email protected]", "fullname": "Site Admin", "title": "", "state": "enabled", "language": "", "content_editor": "", "access": {"admin": {"super": true}}, "groups": [], "avatar_url": null, "twofa_enabled": false, "twofa_secret": false, "created": "2026-09-01T10:00:00+00:00", "modified": "2026-09-01T10:00:00+00:00"}}
Response Codes
Generate 2FA Secret
/users/{username}/2fa
Parameters
| Name | Type | Description |
|---|---|---|
| username required | string | The username to generate 2FA for |
{"data": {"secret": "JBSW Y3DP EHPK 3PXP", "qr_code": "data:image/png;base64,..."}}
Response Codes
List API Keys
/users/{username}/api-keys
Parameters
| Name | Type | Description |
|---|---|---|
| username required | string | The username to list API keys for |
{"data": [{"id": "9f86d081884c7d65", "name": "CI Deploy Key", "prefix": "grav_1a2b3c4...", "scopes": [], "active": true, "created": 1710000000, "last_used": null, "expires": 1717776000}]}
Response Codes
created, last_used and expires are Unix timestamps; expires is null for a key that never expires.
Create API Key
/users/{username}/api-keys
Parameters
| Name | Type | Description |
|---|---|---|
| username required | string | The username to create the API key for |
| name optional | string | A descriptive name for the API key (default: `API Key`) |
| scopes optional | array | List of permission scopes for the key. An empty list means the key carries the account's full access. |
| expiry_days optional | integer | Number of days until the key expires. Omit or set to null for no expiry. |
{"name": "CI Deploy Key", "scopes": ["api.pages.read"], "expiry_days": 90}
{"data": {"id": "9f86d081884c7d65", "name": "CI Deploy Key", "prefix": "grav_1a2b3c4...", "scopes": ["api.pages.read"], "active": true, "created": 1710000000, "last_used": null, "expires": 1717776000, "api_key": "grav_1a2b3c4d5e6f..."}}
Response Codes
When the request is authenticated with a scoped API key, the new key must be scoped too, and every scope it asks for must be within the calling key's own scopes.
Revoke API Key
/users/{username}/api-keys/{keyId}
Parameters
| Name | Type | Description |
|---|---|---|
| username required | string | The username who owns the API key |
| keyId required | string | The ID of the API key to revoke |
Response Codes
Enable 2FA
/users/{username}/2fa/enable
Parameters
| Name | Type | Description |
|---|---|---|
| username required | string | Username (path param). |
| code required | string | Current 6-digit TOTP code from the authenticator app. |
{"code": "123456"}
{"data": {"twofa_enabled": true}}
Response Codes
Disable 2FA
/users/{username}/2fa/disable
Parameters
| Name | Type | Description |
|---|---|---|
| username required | string | Username (path param). |
| code optional | string | Current TOTP code. Required for self-disable without `api.users.write`; ignored for admin-forced disable. |
{"code": "123456"}
{"data": {"twofa_enabled": false}}
Response Codes
List User Row Actions
/users/row-actions
{
"data": {
"actions": [
{
"id": "impersonate-user",
"plugin": "impersonate",
"label": "Impersonate",
"icon": "fa-user-secret",
"action": "start",
"confirm": "Impersonate this user?",
"priority": 80
}
]
}
}
Response Codes
The response data.actions is the ordered list of plugin-declared row actions. Each action carries:
id— unique action id; the key posted to the execution endpoint.plugin— the owning plugin slug.label— the plain-text button label (also its accessible name and tooltip).icon— optional Font Awesome icon class.action— optional verb passed back to the handler; opaque to the client.confirm— optional prompt shown in a confirmation dialog before the action runs.priority— optional; higher sorts earlier.
Actions are plugin-owned but safe by construction: no markup or renderer crosses the wire, the list is capped, and the authorize check is re-run server-side and stripped from this response. The authorize on a declaration gates only which buttons render — it is not a security boundary; the execution endpoint re-authorizes independently.
See the API Events page for the onApiUserListRowActions and onApiUserListRowAction contracts.
Execute User Row Action
/users/{username}/row-action
Parameters
| Name | Type | Description |
|---|---|---|
| username required | The target account the action runs against. | |
| id required | string | The id of a declared row action (from `GET /users/row-actions`). |
{
"id": "impersonate-user"
}
{
"data": {
"status": "success",
"message": "Impersonating alice",
"url": "/admin/impersonate/alice"
}
}
Response Codes
Post the id of a declared row action. The API confirms the caller may list users (api.users.read), loads the target account (only a super admin may act on a super admin account), and re-checks the action's own authorize — an unknown or unauthorized id is an indistinguishable 404, so nothing leaks. It then fires onApiUserListRowAction with the resolved username; the handler must guard on $event['plugin'], re-authorize against the target, and set $event['result'].
The result is normalized to a fixed shape:
status—successorerror; drives whether Admin2 shows a success or error toast.message— optional text shown as a toast (length-capped).url— optional redirect. Only a root-relative path (not the protocol-relative//hostform) or a same-origin absolute URL survives validation; ajavascript:/data:scheme or a cross-origin URL is dropped. Admin2 opens the surviving URL in a new tab withnoopener.
A handler that throws degrades to a 200 with status: error and a generic message rather than breaking the Users list; a thrown ForbiddenException propagates as a 403.
See the API Events page for the onApiUserListRowAction contract.
List Groups
/groups
Parameters
| Name | Type | Description |
|---|---|---|
| page optional | integer | Page number for pagination (default: 1) |
| per_page optional | integer | Number of results per page (default: 20, max: 1000) |
| search optional | string | Filter by a free-text query across the group name, readable name and description. |
{
"data": [
{
"groupname": "editors",
"readableName": "Editors",
"description": "Can edit site content",
"icon": "users",
"enabled": true,
"access": { "api": { "access": true, "pages": { "read": true, "write": true } } }
}
],
"meta": {
"pagination": { "page": 1, "per_page": 20, "total": 1, "total_pages": 1 }
},
"links": {
"self": "/api/v1/groups?page=1&per_page=20"
}
}
Response Codes
Groups are always read from, and saved to, user/config/groups.yaml. Groups that exist only in an environment overlay are not listed, so every group this endpoint returns can also be edited or deleted.
Get Group
/groups/{name}
Parameters
| Name | Type | Description |
|---|---|---|
| name required | string | The group name (path parameter) |
{"data": {"groupname": "editors", "readableName": "Editors", "description": "Can edit site content", "icon": "users", "enabled": true, "access": {"api": {"access": true, "pages": {"read": true, "write": true}}}}}
Response Codes
The response carries an ETag header; send it back as If-Match on Update Group to guard against overwriting a concurrent change.
Create Group
/groups
Parameters
| Name | Type | Description |
|---|---|---|
| groupname required | string | Group name, 1 to 200 letters, numbers, hyphens or underscores. It is the storage key and cannot be changed later. |
| readableName optional | string | Display name |
| description optional | string | Description |
| icon optional | string | Icon name |
| enabled optional | boolean | Whether the group is active (default: true) |
| access optional | object | Permission access object granted to members |
{"groupname": "editors", "readableName": "Editors", "description": "Can edit site content", "access": {"api": {"access": true, "pages": {"read": true, "write": true}}}}
{"data": {"groupname": "editors", "readableName": "Editors", "description": "Can edit site content", "icon": "", "enabled": true, "access": {"api": {"access": true, "pages": {"read": true, "write": true}}}}}
Response Codes
Update Group
/groups/{name}
Parameters
| Name | Type | Description |
|---|---|---|
| name required | string | The group name (path parameter). Groups cannot be renamed. |
| readableName optional | string | Display name |
| description optional | string | Description |
| icon optional | string | Icon name |
| enabled optional | boolean | Whether the group is active |
| access optional | object | Permission access object. Replaces the existing one. |
{"description": "Can edit and publish site content", "enabled": true}
{"data": {"groupname": "editors", "readableName": "Editors", "description": "Can edit and publish site content", "icon": "users", "enabled": true, "access": {"api": {"access": true, "pages": {"read": true, "write": true}}}}}
Response Codes
Supports optimistic concurrency control via the If-Match header. Include the ETag from your last GET request to prevent overwriting concurrent changes.
Delete Group
/groups/{name}
Parameters
| Name | Type | Description |
|---|---|---|
| name required | string | The group name (path parameter) |
Response Codes
Accounts that list the deleted group in their groups keep the entry; it simply no longer grants anything.
List Invitations
/invitations
{"data": {"invitations": [{"token": "b7c1e0f4a9d2...", "email": "[email protected]", "fullname": "New Editor", "groups": [], "created": 1758448800, "created_by": "admin", "created_by_name": "Site Admin", "expires": 1759053600, "expired": false}]}}
Response Codes
created and expires are Unix timestamps. The access an invitation grants is not included in the listing.
Create Invitation
/invitations
Parameters
| Name | Type | Description |
|---|---|---|
| email required | string | Address to invite. Any pending invitation for the same address is replaced. |
| fullname optional | string | Name to prefill on the accept form |
| access optional | object | Permission access object the new account gets. `admin.super` and `api.super` are stripped unless the caller is a super admin. |
| groups optional | array | Groups the new account joins. Applied only when the caller is a super admin; ignored otherwise. |
| expiration optional | integer | Seconds until the invitation expires. Defaults to `plugins.api.invitations.expiration` (7 days). Values under 300 fall back to the default, and the maximum is 30 days (2592000). |
| message optional | string | Personal note included in the email |
| admin_base_url optional | string | Admin base URL to build the invite link from, when the admin is served somewhere other than the default |
{"email": "[email protected]", "fullname": "New Editor", "access": {"api": {"access": true, "pages": {"read": true}}}, "expiration": 604800}
{"data": {"token": "b7c1e0f4a9d2...", "email": "[email protected]", "fullname": "New Editor", "groups": [], "created": 1758448800, "created_by": "admin", "created_by_name": "Site Admin", "expires": 1759053600, "expired": false, "link": "https://example.com/admin/invite?token=b7c1e0f4a9d2...", "email_sent": false, "warning": "Email is not configured, so no invitation email was sent. Share the link manually."}}
Response Codes
The invitation is created even when the email can't be sent. In that case email_sent is false and warning explains why, so the link can be shared by hand. The recipient opens the link, and the accept form uses GET /auth/invite/{token} and POST /auth/invite/{token} to create the account.
Revoke Invitation
/invitations/{token}
Parameters
| Name | Type | Description |
|---|---|---|
| token required | string | The invitation token (path parameter) |
Response Codes
Resend Invitation
/invitations/{token}/resend
Parameters
| Name | Type | Description |
|---|---|---|
| token required | string | The invitation token (path parameter) |
| message optional | string | Personal note included in the email |
| admin_base_url optional | string | Admin base URL to build the invite link from |
{"message": "Reminder: your invitation is waiting."}
{"data": {"token": "b7c1e0f4a9d2...", "email": "[email protected]", "fullname": "New Editor", "groups": [], "created": 1758448800, "created_by": "admin", "created_by_name": "Site Admin", "expires": 1759053600, "expired": false, "link": "https://example.com/admin/invite?token=b7c1e0f4a9d2...", "email_sent": true}}