From 1640f076c857a06b9f38435ae4351d6627dafd83 Mon Sep 17 00:00:00 2001 From: Daniel Sousa Date: Wed, 8 Jul 2026 13:12:18 -0300 Subject: [PATCH 01/13] docs: add Users API reference page Document the option-gated Users API exposed by the api_key-role `users/manage-programmatic` grant (label "Users: Manage"): list users, invite a user, list/revoke pending invites, update and delete a user, and manage a user's subaccount mappings. Covers the prerequisites (the `allow_user_management_via_api` account option enabled by support, plus an admin attaching the grant via the web app), the invite-based-only creation flow and treating the invite token as a credential, the update field deny-list (password, tfa_enabled, is_sso, email), and the role-ceiling / last-admin / key-revocation constraints. Registered under the Accounts category in the table of contents. Closes SparkPost/developers.sparkpost.com#584 Part of SparkPost/access#121 Do not merge/publish until the feature ships. Implementation PRs: SparkPost/access#124, SparkPost/accusers-api#1294, SparkPost/auth-api#319. Claude-Session: https://claude.ai/code/session_01AE1PyieVr2SdnCwF5A4xxU --- content/api/table-of-contents.json | 1 + content/api/users.apib | 425 +++++++++++++++++++++++++++++ 2 files changed, 426 insertions(+) create mode 100644 content/api/users.apib diff --git a/content/api/table-of-contents.json b/content/api/table-of-contents.json index 29480c5b..506efb6d 100644 --- a/content/api/table-of-contents.json +++ b/content/api/table-of-contents.json @@ -42,6 +42,7 @@ "pages": [ "account.apib", "subaccounts.apib", + "users.apib", "data-privacy.apib" ] }, diff --git a/content/api/users.apib b/content/api/users.apib new file mode 100644 index 00000000..f6836f7c --- /dev/null +++ b/content/api/users.apib @@ -0,0 +1,425 @@ +FORMAT: 1A +title: Users API +description: Programmatically manage the users on your SparkPost account with an API key. + +# Group Users + +The Users API lets you list, invite, update, and remove the users on your account, and manage each user's subaccount access, using an API key rather than the web app. + +Note: This API is available only to accounts that have user management via API keys enabled. See Prerequisites below. Contact SparkPost support to request access. + +### Prerequisites + +Two conditions must both be met before an API key can call the Users API: + +1. **The account option `allow_user_management_via_api` must be enabled.** This option is enabled only by SparkPost support; it cannot be toggled through the API or the web app. Contact support to request it for your account. +2. **An admin must attach the `Users: Manage` grant to the API key.** The grant can only be added by an **admin** user through the web app's [API Keys page](https://app.sparkpost.com/account/api-keys). It cannot be attached by another API key. Only API keys carry this grant — it is an `api_key`-role grant. + +Requests made with a key that lacks the `Users: Manage` grant, or made on an account where the option is not enabled, are rejected with `403 Forbidden`. + +The Users: Manage grant is intentionally narrower than the user management available in the web app. It does not cover two-factor authentication, email verification, SCIM provisioning, or password endpoints, and it does not allow direct password-based user creation (see Invite a User). + +### Roles + +When you invite or update a user you assign a **role** through the `access_level` field. The primary-account roles are: + +| Role | Description | +|-------------|-------------------------------------------------------------| +| `admin` | Full access to the account, including user and billing management. | +| `developer` | Access to sending and configuration APIs, without account administration. | +| `reporting` | Read-only access to reporting and analytics. | +| `templates` | Access limited to managing templates. | +| `custom` | A role whose permissions are defined by the `access_policies` you supply. | + +Subaccount access levels (used only in the `subaccounts` array and in the subaccount-mapping endpoints) are `subaccount_reporting` and `subaccount_developer`. + +### Constraints + +The following rules are enforced on every Users API request. They protect against privilege escalation and account lockout: + +* **Role ceiling.** You can never create or invite a user, or raise a user to a role, more privileged than the role of the user who owns the API key. An admin-owned key can assign up to `admin`; a key owned by a less-privileged user is bounded by that user's role. +* **Last admin protection.** The last `admin` on an account cannot be deleted or demoted through the API. Such a request is rejected with `400`. +* **Restricted fields on update.** `password`, `tfa_enabled`, `is_sso`, and `email` cannot be changed through the API key. Sending any of `password`, `tfa_enabled`, or `is_sso` on an update is rejected with `403 Forbidden`; `email` is silently ignored. +* **Key revocation on owner deletion.** If the admin user who owns a key holding the `Users: Manage` grant is deleted, that key is revoked rather than reassigned to another user. + +### User object + +Returned by [List Users](#header-list-users). + ++ Data Structure: User + + name (string) - The user's display name, derived from their first and last name. + + username (string) - Unique username that identifies the user. + + email (string) - The user's email address. + + access (enum) - The user's primary-account role. + + `admin` + + `developer` + + `reporting` + + `templates` + + `custom` + + access_policies (array) - The policies granted to the user when `access` is `custom`. + + is_sso (boolean) - Whether the user signs in via single sign-on. + + email_verified (boolean) - Whether the user has verified their email address. + + tfa_enabled (boolean) - Whether the user has two-factor authentication enabled. + + subaccount_id (number) - The subaccount the user is scoped to, if any. + + auth_migrated (boolean) - Internal flag indicating the user's authentication has been migrated. + + options (object) - User-level options, present only when set. + + link (object) - A link to the individual user resource. + + href (string) + + rel (string) + +### Invite object + +Returned by [List Pending Invites](#header-list-pending-invites). + ++ Data Structure: Invite + + id (string) - Unique ID for the pending invite. Use it to revoke the invite. + + email (string) - The email address the invite was sent to. + + from (string) - The email address of the user who created the invite. + +### List Users [GET /v1/users] + +Returns the users on your account. Superuser accounts are never included, and users with hidden roles are omitted. + +The user identified by the API key's owner determines which users are visible; the key owner must be a user on the account. + ++ Request + + + Headers + + Authorization: 14ac5499cfdd2bb2859e4476d2e5b1d2bad079bf + Accept: application/json + ++ Response 200 (application/json) + + { + "results": [ + { + "name": "Ada Lovelace", + "username": "ada", + "email": "ada@example.com", + "access": "admin", + "is_sso": false, + "email_verified": true, + "tfa_enabled": false, + "auth_migrated": false, + "link": { + "href": "https://api.sparkpost.com/api/v1/users/ada", + "rel": "urn:msys:user" + } + }, + { + "name": "Grace Hopper", + "username": "grace", + "email": "grace@example.com", + "access": "reporting", + "is_sso": false, + "email_verified": true, + "tfa_enabled": true, + "auth_migrated": false, + "link": { + "href": "https://api.sparkpost.com/api/v1/users/grace", + "rel": "urn:msys:user" + } + } + ] + } + ++ Response 403 (application/json) + + { + "errors": [ + { + "message": "Forbidden" + } + ] + } + +### Invite a User [POST /v1/users/invite] + +There is no direct, password-based user creation through the API — users are created by **invitation** only. This endpoint creates an invitation and emails the invitee a registration link; the invitee completes registration through that link and sets their own password. The invited user's role is set by `access_level` and is bounded by the [role ceiling](#header-constraints). + +Provide `access_level` for a primary-account user, or `subaccounts` to invite a subaccount-scoped user. When `subaccounts` is supplied, a top-level `access_level` is not required. + +The response contains the invite `token`. + +Treat the invite token as a credential. Anyone who holds the token can complete registration as the invited user. Invited users normally complete registration through the emailed link, so you do not need to handle the token yourself; if you do capture it, store and transmit it as securely as you would a password, and never log or share it. + ++ Data Structure + + email (string, required) - Email address of the person to invite. Max length: 512 characters. + + access_level (enum) - The primary-account role to assign. Required unless `subaccounts` is supplied. + + `admin` + + `developer` + + `reporting` + + `templates` + + `custom` + + access_policies (array) - The policies to grant. Only valid when `access_level` is `custom`. + + subaccounts (array) - Invite the user with access to one or more subaccounts instead of the primary account. Between 1 and 25 entries. + + (object) + + subaccount_id (number, required) - The subaccount ID. + + access_level (enum, required) - The subaccount access level. + + `subaccount_reporting` + + `subaccount_developer` + ++ Request (application/json) + + + Headers + + Authorization: 14ac5499cfdd2bb2859e4476d2e5b1d2bad079bf + + + Body + + { + "email": "newuser@example.com", + "access_level": "reporting" + } + ++ Response 200 (application/json) + + { + "results": { + "id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301", + "token": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6" + } + } + ++ Response 400 (application/json) + + { + "errors": [ + { + "message": "email is a required parameter", + "param": "email", + "value": null + } + ] + } + ++ Response 403 (application/json) + + { + "errors": [ + { + "message": "Forbidden" + } + ] + } + +### List Pending Invites [GET /v1/users/pending-invites/all] + +Returns the invitations on your account that have not yet been accepted. + ++ Request + + + Headers + + Authorization: 14ac5499cfdd2bb2859e4476d2e5b1d2bad079bf + Accept: application/json + ++ Response 200 (application/json) + + { + "results": [ + { + "id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301", + "email": "newuser@example.com", + "from": "ada@example.com" + } + ] + } + +### Revoke a Pending Invite [DELETE /v1/users/pending-invites/{id}] + +Revokes a pending invitation so its registration link can no longer be used. + ++ Parameters + + id (required, string, `3f2504e0-4f89-41d3-9a0c-0305e82c3301`) - The invite ID from [List Pending Invites](#header-list-pending-invites). + ++ Request + + + Headers + + Authorization: 14ac5499cfdd2bb2859e4476d2e5b1d2bad079bf + ++ Response 204 + ++ Response 404 (application/json) + + { + "errors": [ + { + "message": "User invite ID does not exist" + } + ] + } + +### Update a User [PUT /v1/users/{username}] + +Updates an existing user. You may change `first_name`, `last_name`, `access_level` (and, for a `custom` role, `access_policies`), and `options`. + +The following fields cannot be changed through an API key: password, tfa_enabled, is_sso, and email. Including password, tfa_enabled, or is_sso in the request is rejected with 403 Forbidden; email is ignored. + +Changing `access_level` is subject to the [role ceiling](#header-constraints), and demoting the account's last `admin` is rejected with `400`. + ++ Data Structure + + first_name (string) - The user's first name. + + last_name (string) - The user's last name. + + access_level (enum) - The primary-account role to assign. + + `admin` + + `developer` + + `reporting` + + `templates` + + `custom` + + access_policies (array) - The policies to grant. Only valid when `access_level` is `custom`. + + options (object) - User-level options. + ++ Parameters + + username (required, string, `grace`) - The username of the user to update. + ++ Request (application/json) + + + Headers + + Authorization: 14ac5499cfdd2bb2859e4476d2e5b1d2bad079bf + + + Body + + { + "first_name": "Grace", + "last_name": "Hopper", + "access_level": "developer" + } + ++ Response 200 (application/json) + + { + "results": { + "message": "Successfully modified user grace" + } + } + ++ Response 400 (application/json) + + { + "errors": [ + { + "message": "Cannot change the access level of the last admin user on the account." + } + ] + } + ++ Response 403 (application/json) + + { + "errors": [ + { + "message": "Forbidden" + } + ] + } + +### Delete a User [DELETE /v1/users/{username}] + +Deletes a user from your account. + +You cannot delete the user who owns the API key you are calling with, a user that belongs to a different account, or the account's last `admin`. + ++ Parameters + + username (required, string, `grace`) - The username of the user to delete. + ++ Request + + + Headers + + Authorization: 14ac5499cfdd2bb2859e4476d2e5b1d2bad079bf + ++ Response 204 + ++ Response 400 (application/json) + + { + "errors": [ + { + "message": "Cannot delete the last admin user on the account." + } + ] + } + ++ Response 403 (application/json) + + { + "errors": [ + { + "message": "Forbidden" + } + ] + } + ++ Response 404 (application/json) + + { + "errors": [ + { + "message": "User does not exist" + } + ] + } + +### Add a Subaccount Mapping [POST /v1/users/{username}/subaccounts] + +Grants a user access to a subaccount at the given access level. If the user already has access to the subaccount, the access level is updated. + ++ Data Structure + + subaccount_id (number, required) - The subaccount to grant access to. + + access_level (enum, required) - The access level to grant on the subaccount. + + `subaccount_reporting` + + `subaccount_developer` + ++ Parameters + + username (required, string, `grace`) - The username of the user. + ++ Request (application/json) + + + Headers + + Authorization: 14ac5499cfdd2bb2859e4476d2e5b1d2bad079bf + + + Body + + { + "subaccount_id": 123, + "access_level": "subaccount_reporting" + } + ++ Response 200 (application/json) + + { + "results": { + "message": "Subaccount access granted" + } + } + ++ Response 400 (application/json) + + { + "errors": [ + { + "message": "access_level must be one of: subaccount_reporting, subaccount_developer" + } + ] + } + +### Remove a Subaccount Mapping [DELETE /v1/users/{username}/subaccounts/{subaccountId}] + +Removes a user's access to a subaccount. + ++ Parameters + + username (required, string, `grace`) - The username of the user. + + subaccountId (required, number, `123`) - The subaccount to remove access to. + ++ Request + + + Headers + + Authorization: 14ac5499cfdd2bb2859e4476d2e5b1d2bad079bf + ++ Response 204 From dc60cb7c81e7b91dc13f65577eb03ec5db40d9d3 Mon Sep 17 00:00:00 2001 From: Daniel Sousa Date: Wed, 8 Jul 2026 16:13:57 -0300 Subject: [PATCH 02/13] docs: scrub internals and expand Users API page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove non-public details: the account option key, the internal auth_migrated user field, and the PUT restricted-fields enumeration. Prerequisites and the update endpoint are now worded positively (what is enabled / what is updatable) rather than naming internal config or forbidden fields. Add the invite lifecycle (3-day expiry, expired invites drop off the pending list automatically, no resend — re-invite instead, 429 when rate limited), the Retrieve a User and List a User's Subaccounts endpoints, and the canonical GET /v1/users/pending-invites listing (replacing the legacy /all variant). The Invite object now carries access_level and expires. Claude-Session: https://claude.ai/code/session_01AE1PyieVr2SdnCwF5A4xxU --- content/api/users.apib | 130 ++++++++++++++++++++++++++++++++--------- 1 file changed, 101 insertions(+), 29 deletions(-) diff --git a/content/api/users.apib b/content/api/users.apib index f6836f7c..7c36ae3b 100644 --- a/content/api/users.apib +++ b/content/api/users.apib @@ -12,10 +12,10 @@ The Users API lets you list, invite, update, and remove the users on your accoun Two conditions must both be met before an API key can call the Users API: -1. **The account option `allow_user_management_via_api` must be enabled.** This option is enabled only by SparkPost support; it cannot be toggled through the API or the web app. Contact support to request it for your account. +1. **User management via API keys must be enabled on your account.** This is enabled only by SparkPost support. Contact support to request access for your account. 2. **An admin must attach the `Users: Manage` grant to the API key.** The grant can only be added by an **admin** user through the web app's [API Keys page](https://app.sparkpost.com/account/api-keys). It cannot be attached by another API key. Only API keys carry this grant — it is an `api_key`-role grant. -Requests made with a key that lacks the `Users: Manage` grant, or made on an account where the option is not enabled, are rejected with `403 Forbidden`. +Requests made with a key that lacks the `Users: Manage` grant, or made on an account where the feature is not enabled, are rejected with `403 Forbidden`. The Users: Manage grant is intentionally narrower than the user management available in the web app. It does not cover two-factor authentication, email verification, SCIM provisioning, or password endpoints, and it does not allow direct password-based user creation (see Invite a User). @@ -39,12 +39,11 @@ The following rules are enforced on every Users API request. They protect agains * **Role ceiling.** You can never create or invite a user, or raise a user to a role, more privileged than the role of the user who owns the API key. An admin-owned key can assign up to `admin`; a key owned by a less-privileged user is bounded by that user's role. * **Last admin protection.** The last `admin` on an account cannot be deleted or demoted through the API. Such a request is rejected with `400`. -* **Restricted fields on update.** `password`, `tfa_enabled`, `is_sso`, and `email` cannot be changed through the API key. Sending any of `password`, `tfa_enabled`, or `is_sso` on an update is rejected with `403 Forbidden`; `email` is silently ignored. * **Key revocation on owner deletion.** If the admin user who owns a key holding the `Users: Manage` grant is deleted, that key is revoked rather than reassigned to another user. ### User object -Returned by [List Users](#header-list-users). +Returned by [List Users](#header-list-users) and [Retrieve a User](#header-retrieve-a-user). + Data Structure: User + name (string) - The user's display name, derived from their first and last name. @@ -61,11 +60,7 @@ Returned by [List Users](#header-list-users). + email_verified (boolean) - Whether the user has verified their email address. + tfa_enabled (boolean) - Whether the user has two-factor authentication enabled. + subaccount_id (number) - The subaccount the user is scoped to, if any. - + auth_migrated (boolean) - Internal flag indicating the user's authentication has been migrated. + options (object) - User-level options, present only when set. - + link (object) - A link to the individual user resource. - + href (string) - + rel (string) ### Invite object @@ -75,12 +70,24 @@ Returned by [List Pending Invites](#header-list-pending-invites). + id (string) - Unique ID for the pending invite. Use it to revoke the invite. + email (string) - The email address the invite was sent to. + from (string) - The email address of the user who created the invite. + + access_level (enum) - The role the invited user will be assigned when they register. + + `admin` + + `developer` + + `reporting` + + `templates` + + `custom` + + expires (number) - Unix timestamp, in epoch seconds, at which the invitation expires. -### List Users [GET /v1/users] +### Invite lifecycle + +* **Invitations expire after 3 days.** Once an invitation expires, its registration link no longer works. +* **Expired invitations are removed automatically.** They stop appearing in [List Pending Invites](#header-list-pending-invites) once they expire; you do not need to clean them up. +* **There is no resend.** To give someone another chance to register, [invite the same email address again](#header-invite-a-user). This creates a new, independent invitation with its own token and expiry; the earlier invitation keeps working until it expires or you [revoke it](#header-revoke-a-pending-invite). +* **Invite creation is rate limited.** If you create invitations too quickly, requests are rejected with `429 Too Many Requests`. Wait before retrying. -Returns the users on your account. Superuser accounts are never included, and users with hidden roles are omitted. +### List Users [GET /v1/users] -The user identified by the API key's owner determines which users are visible; the key owner must be a user on the account. +Returns the users on your account. + Request @@ -100,12 +107,7 @@ The user identified by the API key's owner determines which users are visible; t "access": "admin", "is_sso": false, "email_verified": true, - "tfa_enabled": false, - "auth_migrated": false, - "link": { - "href": "https://api.sparkpost.com/api/v1/users/ada", - "rel": "urn:msys:user" - } + "tfa_enabled": false }, { "name": "Grace Hopper", @@ -114,12 +116,7 @@ The user identified by the API key's owner determines which users are visible; t "access": "reporting", "is_sso": false, "email_verified": true, - "tfa_enabled": true, - "auth_migrated": false, - "link": { - "href": "https://api.sparkpost.com/api/v1/users/grace", - "rel": "urn:msys:user" - } + "tfa_enabled": true } ] } @@ -134,6 +131,44 @@ The user identified by the API key's owner determines which users are visible; t ] } +### Retrieve a User [GET /v1/users/{username}] + +Returns a single user by username. + ++ Parameters + + username (required, string, `grace`) - The username of the user to retrieve. + ++ Request + + + Headers + + Authorization: 14ac5499cfdd2bb2859e4476d2e5b1d2bad079bf + Accept: application/json + ++ Response 200 (application/json) + + { + "results": { + "name": "Grace Hopper", + "username": "grace", + "email": "grace@example.com", + "access": "reporting", + "is_sso": false, + "email_verified": true, + "tfa_enabled": true + } + } + ++ Response 404 (application/json) + + { + "errors": [ + { + "message": "User does not exist" + } + ] + } + ### Invite a User [POST /v1/users/invite] There is no direct, password-based user creation through the API — users are created by **invitation** only. This endpoint creates an invitation and emails the invitee a registration link; the invitee completes registration through that link and sets their own password. The invited user's role is set by `access_level` and is bounded by the [role ceiling](#header-constraints). @@ -204,9 +239,19 @@ The response contains the invite `token`. ] } -### List Pending Invites [GET /v1/users/pending-invites/all] ++ Response 429 (application/json) -Returns the invitations on your account that have not yet been accepted. + { + "errors": [ + { + "message": "Too many invite requests. Please try again later." + } + ] + } + +### List Pending Invites [GET /v1/users/pending-invites] + +Returns the invitations on your account that have not yet been accepted. Expired invitations are not included. + Request @@ -222,7 +267,9 @@ Returns the invitations on your account that have not yet been accepted. { "id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301", "email": "newuser@example.com", - "from": "ada@example.com" + "from": "ada@example.com", + "access_level": "reporting", + "expires": 1720656000 } ] } @@ -254,9 +301,7 @@ Revokes a pending invitation so its registration link can no longer be used. ### Update a User [PUT /v1/users/{username}] -Updates an existing user. You may change `first_name`, `last_name`, `access_level` (and, for a `custom` role, `access_policies`), and `options`. - -The following fields cannot be changed through an API key: password, tfa_enabled, is_sso, and email. Including password, tfa_enabled, or is_sso in the request is rejected with 403 Forbidden; email is ignored. +Updates an existing user. Through this endpoint you can change the following fields: `first_name`, `last_name`, `access_level` (and, when `access_level` is `custom`, `access_policies`), and `options`. Changing `access_level` is subject to the [role ceiling](#header-constraints), and demoting the account's last `admin` is rejected with `400`. @@ -364,6 +409,33 @@ You cannot delete the user who owns the API key you are calling with, a user tha ] } +### List a User's Subaccounts [GET /v1/users/{username}/subaccounts] + +Returns the subaccounts a user has been granted access to, along with their access level on each. + ++ Parameters + + username (required, string, `grace`) - The username of the user. + ++ Request + + + Headers + + Authorization: 14ac5499cfdd2bb2859e4476d2e5b1d2bad079bf + Accept: application/json + ++ Response 200 (application/json) + + { + "results": [ + { + "subaccount_id": 123, + "subaccount_name": "Joe's Garage", + "access_level": "subaccount_reporting", + "status": "active" + } + ] + } + ### Add a Subaccount Mapping [POST /v1/users/{username}/subaccounts] Grants a user access to a subaccount at the given access level. If the user already has access to the subaccount, the access level is updated. From 482e28a4f8141e834f8fd8c9cc44964f51d5e9f6 Mon Sep 17 00:00:00 2001 From: Daniel Sousa Date: Wed, 8 Jul 2026 17:55:43 -0300 Subject: [PATCH 03/13] docs: document Users: View grant and drop support-enablement step These docs publish at GA, after the account-option gate is removed, so there is no "enabled by SparkPost support" prerequisite at publish time; remove that wording and the contact-support banner. Document both grants: `Users: View` (read-only, GET endpoints) and `Users: Manage` (full management; required by every write endpoint). The only remaining prerequisite is that an admin attaches the grant to the key via the web app. Claude-Session: https://claude.ai/code/session_01AE1PyieVr2SdnCwF5A4xxU --- content/api/users.apib | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/content/api/users.apib b/content/api/users.apib index 7c36ae3b..e4797082 100644 --- a/content/api/users.apib +++ b/content/api/users.apib @@ -6,18 +6,16 @@ description: Programmatically manage the users on your SparkPost account with an The Users API lets you list, invite, update, and remove the users on your account, and manage each user's subaccount access, using an API key rather than the web app. -Note: This API is available only to accounts that have user management via API keys enabled. See Prerequisites below. Contact SparkPost support to request access. - ### Prerequisites -Two conditions must both be met before an API key can call the Users API: +To call the Users API, an API key must hold one of two grants, and an **admin** must attach the grant to the key through the web app's [API Keys page](https://app.sparkpost.com/account/api-keys). A grant cannot be attached by another API key. Both are `api_key`-role grants. -1. **User management via API keys must be enabled on your account.** This is enabled only by SparkPost support. Contact support to request access for your account. -2. **An admin must attach the `Users: Manage` grant to the API key.** The grant can only be added by an **admin** user through the web app's [API Keys page](https://app.sparkpost.com/account/api-keys). It cannot be attached by another API key. Only API keys carry this grant — it is an `api_key`-role grant. +* **`Users: View`** — read-only access, covering the `GET` endpoints only: [List Users](#header-list-users), [Retrieve a User](#header-retrieve-a-user), [List Pending Invites](#header-list-pending-invites), and [List a User's Subaccounts](#header-list-a-users-subaccounts). +* **`Users: Manage`** — full management. Every write endpoint (invite, update, delete, and the subaccount-mapping changes) requires this grant. -Requests made with a key that lacks the `Users: Manage` grant, or made on an account where the feature is not enabled, are rejected with `403 Forbidden`. +Requests made with a key that holds neither grant, or that attempt a write with only `Users: View`, are rejected with `403 Forbidden`. -The Users: Manage grant is intentionally narrower than the user management available in the web app. It does not cover two-factor authentication, email verification, SCIM provisioning, or password endpoints, and it does not allow direct password-based user creation (see Invite a User). +These grants are intentionally narrower than the user management available in the web app. They do not cover two-factor authentication, email verification, SCIM provisioning, or password endpoints, and they do not allow direct password-based user creation (see Invite a User). ### Roles From d034522b2011b8253d9e3e8745f2eb4b5baf44eb Mon Sep 17 00:00:00 2001 From: Daniel Sousa Date: Thu, 9 Jul 2026 13:12:49 -0300 Subject: [PATCH 04/13] docs(users): document embedded subaccounts array, drop subaccounts GET Claude-Session: https://claude.ai/code/session_01AE1PyieVr2SdnCwF5A4xxU --- content/api/users.apib | 65 ++++++++++++++++++++++++------------------ 1 file changed, 37 insertions(+), 28 deletions(-) diff --git a/content/api/users.apib b/content/api/users.apib index e4797082..048f3088 100644 --- a/content/api/users.apib +++ b/content/api/users.apib @@ -10,7 +10,7 @@ The Users API lets you list, invite, update, and remove the users on your accoun To call the Users API, an API key must hold one of two grants, and an **admin** must attach the grant to the key through the web app's [API Keys page](https://app.sparkpost.com/account/api-keys). A grant cannot be attached by another API key. Both are `api_key`-role grants. -* **`Users: View`** — read-only access, covering the `GET` endpoints only: [List Users](#header-list-users), [Retrieve a User](#header-retrieve-a-user), [List Pending Invites](#header-list-pending-invites), and [List a User's Subaccounts](#header-list-a-users-subaccounts). +* **`Users: View`** — read-only access, covering the `GET` endpoints only: [List Users](#header-list-users), [Retrieve a User](#header-retrieve-a-user), and [List Pending Invites](#header-list-pending-invites). * **`Users: Manage`** — full management. Every write endpoint (invite, update, delete, and the subaccount-mapping changes) requires this grant. Requests made with a key that holds neither grant, or that attempt a write with only `Users: View`, are rejected with `403 Forbidden`. @@ -58,6 +58,17 @@ Returned by [List Users](#header-list-users) and [Retrieve a User](#header-retri + email_verified (boolean) - Whether the user has verified their email address. + tfa_enabled (boolean) - Whether the user has two-factor authentication enabled. + subaccount_id (number) - The subaccount the user is scoped to, if any. + + subaccounts (array) - The subaccounts a subaccount-scoped user has access to, and their access level on each. Present only for subaccount-scoped users; when it is present, the top-level `access` and `subaccount_id` fields are omitted. + + (object) + + subaccount_id (number) - The subaccount ID. + + subaccount_name (string) - The subaccount's display name. + + access_level (enum) - The user's access level on the subaccount. + + `subaccount_reporting` + + `subaccount_developer` + + status (enum) - The subaccount's status. Returned only by [Retrieve a User](#header-retrieve-a-user). + + active + + suspended + + terminated + options (object) - User-level options, present only when set. ### Invite object @@ -133,6 +144,8 @@ Returns the users on your account. Returns a single user by username. +For a subaccount-scoped user, the response embeds a `subaccounts` array describing the subaccounts the user can access and their access level on each. When `subaccounts` is present, the top-level `access` and `subaccount_id` fields are omitted. + + Parameters + username (required, string, `grace`) - The username of the user to retrieve. @@ -157,6 +170,29 @@ Returns a single user by username. } } ++ Response 200 (application/json) + + A subaccount-scoped user. + + { + "results": { + "name": "Joe Mechanic", + "username": "joe", + "email": "joe@example.com", + "is_sso": false, + "email_verified": true, + "tfa_enabled": false, + "subaccounts": [ + { + "subaccount_id": 123, + "subaccount_name": "Joe's Garage", + "access_level": "subaccount_reporting", + "status": "active" + } + ] + } + } + + Response 404 (application/json) { @@ -407,33 +443,6 @@ You cannot delete the user who owns the API key you are calling with, a user tha ] } -### List a User's Subaccounts [GET /v1/users/{username}/subaccounts] - -Returns the subaccounts a user has been granted access to, along with their access level on each. - -+ Parameters - + username (required, string, `grace`) - The username of the user. - -+ Request - - + Headers - - Authorization: 14ac5499cfdd2bb2859e4476d2e5b1d2bad079bf - Accept: application/json - -+ Response 200 (application/json) - - { - "results": [ - { - "subaccount_id": 123, - "subaccount_name": "Joe's Garage", - "access_level": "subaccount_reporting", - "status": "active" - } - ] - } - ### Add a Subaccount Mapping [POST /v1/users/{username}/subaccounts] Grants a user access to a subaccount at the given access level. If the user already has access to the subaccount, the access level is updated. From 6c825772918d2b6c5461347aa6f22d6d337a1a92 Mon Sep 17 00:00:00 2001 From: Daniel Sousa Date: Thu, 9 Jul 2026 13:17:50 -0300 Subject: [PATCH 05/13] docs(users): tighten Retrieve a User lead-in Claude-Session: https://claude.ai/code/session_01AE1PyieVr2SdnCwF5A4xxU --- content/api/users.apib | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/api/users.apib b/content/api/users.apib index 048f3088..aaa443f0 100644 --- a/content/api/users.apib +++ b/content/api/users.apib @@ -144,7 +144,7 @@ Returns the users on your account. Returns a single user by username. -For a subaccount-scoped user, the response embeds a `subaccounts` array describing the subaccounts the user can access and their access level on each. When `subaccounts` is present, the top-level `access` and `subaccount_id` fields are omitted. +For a subaccount-scoped user, the response embeds the `subaccounts` array in place of the top-level `access` and `subaccount_id` fields. + Parameters + username (required, string, `grace`) - The username of the user to retrieve. From 98d338c57f63dc3e8c4e1986dbe9601d5fea36f5 Mon Sep 17 00:00:00 2001 From: Daniel Sousa Date: Thu, 9 Jul 2026 13:29:18 -0300 Subject: [PATCH 06/13] docs(users): split List vs Retrieve user shapes Retrieve a User returns first_name/last_name, access_level, created, updated, and an always-present subaccounts array (with status) - a different shape from the List Users item. Document them separately. Claude-Session: https://claude.ai/code/session_01AE1PyieVr2SdnCwF5A4xxU --- content/api/users.apib | 60 +++++++++++++++++++++++++++++++++--------- 1 file changed, 48 insertions(+), 12 deletions(-) diff --git a/content/api/users.apib b/content/api/users.apib index aaa443f0..350ead7b 100644 --- a/content/api/users.apib +++ b/content/api/users.apib @@ -41,13 +41,13 @@ The following rules are enforced on every Users API request. They protect agains ### User object -Returned by [List Users](#header-list-users) and [Retrieve a User](#header-retrieve-a-user). +Returned by [List Users](#header-list-users). + Data Structure: User + name (string) - The user's display name, derived from their first and last name. + username (string) - Unique username that identifies the user. + email (string) - The user's email address. - + access (enum) - The user's primary-account role. + + access (enum) - The user's primary-account role. Empty for subaccount-scoped users. + `admin` + `developer` + `reporting` @@ -58,18 +58,49 @@ Returned by [List Users](#header-list-users) and [Retrieve a User](#header-retri + email_verified (boolean) - Whether the user has verified their email address. + tfa_enabled (boolean) - Whether the user has two-factor authentication enabled. + subaccount_id (number) - The subaccount the user is scoped to, if any. - + subaccounts (array) - The subaccounts a subaccount-scoped user has access to, and their access level on each. Present only for subaccount-scoped users; when it is present, the top-level `access` and `subaccount_id` fields are omitted. + + subaccounts (array) - The subaccounts a subaccount-scoped user has access to, and their access level on each. Present only for subaccount-scoped users. + + (object) + + subaccount_id (number) - The subaccount ID. + + access_level (enum) - The user's access level on the subaccount. + + `subaccount_reporting` + + `subaccount_developer` + + subaccount_name (string) - The subaccount's display name. + + options (object) - User-level options, present only when set. + +### Retrieved user object + +Returned by [Retrieve a User](#header-retrieve-a-user). This is a richer shape than the [User object](#header-user-object) returned by List Users: it has `first_name` and `last_name` in place of `name`, uses `access_level` for the primary-account role, and adds `created`, `updated`, and an always-present `subaccounts` array. + ++ Data Structure: RetrievedUser + + username (string) - Unique username that identifies the user. + + first_name (string) - The user's first name. + + last_name (string) - The user's last name. + + email (string) - The user's email address. + + access_level (enum) - The user's primary-account role. Omitted for subaccount-scoped users. + + `admin` + + `developer` + + `reporting` + + `templates` + + `custom` + + access_policies (array) - The policies granted to the user when `access_level` is `custom`. + + subaccount_id (number) - The subaccount the user is scoped to, if any. Omitted when `subaccounts` is non-empty. + + is_sso (boolean) - Whether the user signs in via single sign-on. + + email_verified (boolean) - Whether the user has verified their email address. + + tfa_enabled (boolean) - Whether the user has two-factor authentication enabled. + + options (object) - User-level options, present only when set. + + created (string) - ISO 8601 timestamp of when the user was created. + + updated (string) - ISO 8601 timestamp of when the user was last updated. + + subaccounts (array) - The subaccounts the user has access to, and their access level on each. Always present; an empty array for a user with no subaccount mappings. When it is non-empty, the top-level `access_level` and `subaccount_id` fields are omitted. + (object) + subaccount_id (number) - The subaccount ID. + subaccount_name (string) - The subaccount's display name. + access_level (enum) - The user's access level on the subaccount. + `subaccount_reporting` + `subaccount_developer` - + status (enum) - The subaccount's status. Returned only by [Retrieve a User](#header-retrieve-a-user). + + status (enum) - The subaccount's status. + active + suspended + terminated - + options (object) - User-level options, present only when set. ### Invite object @@ -142,9 +173,7 @@ Returns the users on your account. ### Retrieve a User [GET /v1/users/{username}] -Returns a single user by username. - -For a subaccount-scoped user, the response embeds the `subaccounts` array in place of the top-level `access` and `subaccount_id` fields. +Returns a single user by username. The response uses the [Retrieved user object](#header-retrieved-user-object) shape, which differs from the [User object](#header-user-object) returned by List Users. + Parameters + username (required, string, `grace`) - The username of the user to retrieve. @@ -160,13 +189,17 @@ For a subaccount-scoped user, the response embeds the `subaccounts` array in pla { "results": { - "name": "Grace Hopper", "username": "grace", + "first_name": "Grace", + "last_name": "Hopper", "email": "grace@example.com", - "access": "reporting", + "access_level": "reporting", "is_sso": false, "email_verified": true, - "tfa_enabled": true + "tfa_enabled": true, + "created": "2015-01-11T08:00:00.000Z", + "updated": "2018-04-11T08:00:00.000Z", + "subaccounts": [] } } @@ -176,12 +209,15 @@ For a subaccount-scoped user, the response embeds the `subaccounts` array in pla { "results": { - "name": "Joe Mechanic", "username": "joe", + "first_name": "Joe", + "last_name": "Mechanic", "email": "joe@example.com", "is_sso": false, "email_verified": true, "tfa_enabled": false, + "created": "2015-01-11T08:00:00.000Z", + "updated": "2018-04-11T08:00:00.000Z", "subaccounts": [ { "subaccount_id": 123, From 3e1955864e34b1d207912977fe9bf1ecea8fe0dc Mon Sep 17 00:00:00 2001 From: Daniel Sousa Date: Thu, 9 Jul 2026 14:20:29 -0300 Subject: [PATCH 07/13] docs(users): Update a User accepts only access_level/access_policies PUT now changes a user's role only; any other field in the request body is rejected with 403. Claude-Session: https://claude.ai/code/session_01AE1PyieVr2SdnCwF5A4xxU --- content/api/users.apib | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/content/api/users.apib b/content/api/users.apib index 350ead7b..207de2a3 100644 --- a/content/api/users.apib +++ b/content/api/users.apib @@ -371,21 +371,18 @@ Revokes a pending invitation so its registration link can no longer be used. ### Update a User [PUT /v1/users/{username}] -Updates an existing user. Through this endpoint you can change the following fields: `first_name`, `last_name`, `access_level` (and, when `access_level` is `custom`, `access_policies`), and `options`. +Changes a user's role. The request accepts only `access_level` (and `access_policies` when `access_level` is `custom`); a request that contains any other field is rejected with `403 Forbidden`. Changing `access_level` is subject to the [role ceiling](#header-constraints), and demoting the account's last `admin` is rejected with `400`. + Data Structure - + first_name (string) - The user's first name. - + last_name (string) - The user's last name. - + access_level (enum) - The primary-account role to assign. + + access_level (enum, required) - The primary-account role to assign. + `admin` + `developer` + `reporting` + `templates` + `custom` - + access_policies (array) - The policies to grant. Only valid when `access_level` is `custom`. - + options (object) - User-level options. + + access_policies (array) - The policies to grant. Required when `access_level` is `custom`, and not allowed otherwise. + Parameters + username (required, string, `grace`) - The username of the user to update. @@ -399,8 +396,6 @@ Changing `access_level` is subject to the [role ceiling](#header-constraints), a + Body { - "first_name": "Grace", - "last_name": "Hopper", "access_level": "developer" } From fb3c20045fe961334929b3c52d4470c0730c31df Mon Sep 17 00:00:00 2001 From: Daniel Sousa Date: Fri, 31 Jul 2026 20:29:40 -0300 Subject: [PATCH 08/13] docs(users): correct page against accusers-api and tighten copy Verified every endpoint, field, and error body against the feat/users-manage-programmatic branches of accusers-api and access. Corrections: - Invite no longer returns the registration token. accusers-api 086008f1f stopped returning it from POST /users/invite entirely, so the response is just `{ id }`. Dropped the token from the example and replaced the "treat the token as a credential" banner with a note that the token only ever reaches the invitee by email. - API keys cannot assign `admin`. The programmatic cap in role-ceiling.js and edit-user-validator.js rejects admin on invite, create, and PUT, even for an admin-owned key. The page previously said an admin-owned key could assign up to `admin`. Added it as its own constraint and dropped `admin` from the invite and update request enums. - Removed the "key revocation on owner deletion" constraint. Reverted in 5b1dbdee5: keys are account-owned and get reassigned, not revoked. - Add a Subaccount Mapping requires the target to already hold at least one mapping (has-account-subaccount-access), and Remove a Subaccount Mapping refuses the last one (last-subaccount-check). Both documented with their 400 bodies. - Replaced placeholder "Forbidden" bodies with the userMessage strings the service actually sends, and fixed the Retrieve 404 to "User not found." (userNotFoundOptions, not the delete validator's "User does not exist"). - `access` is omitted, not empty, for subaccount-scoped users; a subaccounts-only invite never sets access_level on the user record. - Documented `last_login` on the List Users object. It is an ISO string or null, per auth0-post-login-action.js. - Update a User: access_policies is optional when access_level is custom, not required. Copy pass for readability: no em dashes, active voice, plainer wording. --- content/api/users.apib | 119 +++++++++++++++++++++++++---------------- 1 file changed, 72 insertions(+), 47 deletions(-) diff --git a/content/api/users.apib b/content/api/users.apib index 207de2a3..13e3407e 100644 --- a/content/api/users.apib +++ b/content/api/users.apib @@ -4,22 +4,22 @@ description: Programmatically manage the users on your SparkPost account with an # Group Users -The Users API lets you list, invite, update, and remove the users on your account, and manage each user's subaccount access, using an API key rather than the web app. +Use the Users API to manage your account's users from code instead of the web app. You can list users, invite new ones, change roles, remove users, and manage each user's subaccount access. ### Prerequisites -To call the Users API, an API key must hold one of two grants, and an **admin** must attach the grant to the key through the web app's [API Keys page](https://app.sparkpost.com/account/api-keys). A grant cannot be attached by another API key. Both are `api_key`-role grants. +Every call needs an API key holding one of two grants. Only an admin can attach either grant, and only from the web app's [API Keys page](https://app.sparkpost.com/account/api-keys). No API key can attach a grant, not even a key that already holds one. -* **`Users: View`** — read-only access, covering the `GET` endpoints only: [List Users](#header-list-users), [Retrieve a User](#header-retrieve-a-user), and [List Pending Invites](#header-list-pending-invites). -* **`Users: Manage`** — full management. Every write endpoint (invite, update, delete, and the subaccount-mapping changes) requires this grant. +* **`Users: View`** gives read-only access to the three `GET` endpoints: [List Users](#header-list-users), [Retrieve a User](#header-retrieve-a-user), and [List Pending Invites](#header-list-pending-invites). +* **`Users: Manage`** covers the same reads plus every write: invite, update, delete, and the subaccount-mapping changes. -Requests made with a key that holds neither grant, or that attempt a write with only `Users: View`, are rejected with `403 Forbidden`. +A key holding neither grant gets `403`, and so does a write attempted with only `Users: View`. -These grants are intentionally narrower than the user management available in the web app. They do not cover two-factor authentication, email verification, SCIM provisioning, or password endpoints, and they do not allow direct password-based user creation (see Invite a User). +These grants cover less than the web app's user management. They do not reach two-factor authentication, email verification, SCIM provisioning, or password endpoints, and they cannot create a user directly with a password. New users join by invitation. ### Roles -When you invite or update a user you assign a **role** through the `access_level` field. The primary-account roles are: +Inviting or updating a user assigns a role through the `access_level` field. The primary-account roles are: | Role | Description | |-------------|-------------------------------------------------------------| @@ -29,15 +29,16 @@ When you invite or update a user you assign a **role** through the `access_level | `templates` | Access limited to managing templates. | | `custom` | A role whose permissions are defined by the `access_policies` you supply. | -Subaccount access levels (used only in the `subaccounts` array and in the subaccount-mapping endpoints) are `subaccount_reporting` and `subaccount_developer`. +Subaccount-scoped users take one of two access levels instead, `subaccount_reporting` or `subaccount_developer`. These appear only in the `subaccounts` array and in the subaccount-mapping endpoints. ### Constraints -The following rules are enforced on every Users API request. They protect against privilege escalation and account lockout: +These rules apply to every Users API request. They stop a key from escalating its own privileges or locking an account out of its own administration. -* **Role ceiling.** You can never create or invite a user, or raise a user to a role, more privileged than the role of the user who owns the API key. An admin-owned key can assign up to `admin`; a key owned by a less-privileged user is bounded by that user's role. -* **Last admin protection.** The last `admin` on an account cannot be deleted or demoted through the API. Such a request is rejected with `400`. -* **Key revocation on owner deletion.** If the admin user who owns a key holding the `Users: Manage` grant is deleted, that key is revoked rather than reassigned to another user. +* **No admin through the API.** An API key never assigns the `admin` role, not even a key owned by an admin. Inviting a user as `admin` returns `403`, and so does promoting one. Create admins in the web app. +* **Role ceiling.** Below admin, a key can only grant a role at or under the role held by the user who owns it. A key owned by a `reporting` user invites `reporting` users and nothing higher. +* **Last admin protection.** The API refuses to delete or demote the last `admin` on an account, returning `400`. +* **Self-deletion.** A key cannot delete its own owner. ### User object @@ -47,7 +48,7 @@ Returned by [List Users](#header-list-users). + name (string) - The user's display name, derived from their first and last name. + username (string) - Unique username that identifies the user. + email (string) - The user's email address. - + access (enum) - The user's primary-account role. Empty for subaccount-scoped users. + + access (enum) - The user's primary-account role. Omitted for subaccount-scoped users. + `admin` + `developer` + `reporting` @@ -57,6 +58,7 @@ Returned by [List Users](#header-list-users). + is_sso (boolean) - Whether the user signs in via single sign-on. + email_verified (boolean) - Whether the user has verified their email address. + tfa_enabled (boolean) - Whether the user has two-factor authentication enabled. + + last_login (string, nullable) - ISO 8601 timestamp of the user's last sign-in, or `null` if they have never signed in. + subaccount_id (number) - The subaccount the user is scoped to, if any. + subaccounts (array) - The subaccounts a subaccount-scoped user has access to, and their access level on each. Present only for subaccount-scoped users. + (object) @@ -69,7 +71,7 @@ Returned by [List Users](#header-list-users). ### Retrieved user object -Returned by [Retrieve a User](#header-retrieve-a-user). This is a richer shape than the [User object](#header-user-object) returned by List Users: it has `first_name` and `last_name` in place of `name`, uses `access_level` for the primary-account role, and adds `created`, `updated`, and an always-present `subaccounts` array. +Returned by [Retrieve a User](#header-retrieve-a-user). It differs from the [User object](#header-user-object) that List Users returns. This shape splits `name` into `first_name` and `last_name`, names the primary-account role `access_level` rather than `access`, and adds `created`, `updated`, and a `subaccounts` array that is always present. + Data Structure: RetrievedUser + username (string) - Unique username that identifies the user. @@ -90,7 +92,7 @@ Returned by [Retrieve a User](#header-retrieve-a-user). This is a richer shape t + options (object) - User-level options, present only when set. + created (string) - ISO 8601 timestamp of when the user was created. + updated (string) - ISO 8601 timestamp of when the user was last updated. - + subaccounts (array) - The subaccounts the user has access to, and their access level on each. Always present; an empty array for a user with no subaccount mappings. When it is non-empty, the top-level `access_level` and `subaccount_id` fields are omitted. + + subaccounts (array) - The subaccounts the user has access to, and their access level on each. Always present, and an empty array for a user with no subaccount mappings. When it is non-empty, the top-level `access_level` and `subaccount_id` fields are omitted. + (object) + subaccount_id (number) - The subaccount ID. + subaccount_name (string) - The subaccount's display name. @@ -110,7 +112,7 @@ Returned by [List Pending Invites](#header-list-pending-invites). + id (string) - Unique ID for the pending invite. Use it to revoke the invite. + email (string) - The email address the invite was sent to. + from (string) - The email address of the user who created the invite. - + access_level (enum) - The role the invited user will be assigned when they register. + + access_level (enum) - The role the user takes on when they register. + `admin` + `developer` + `reporting` @@ -120,10 +122,10 @@ Returned by [List Pending Invites](#header-list-pending-invites). ### Invite lifecycle -* **Invitations expire after 3 days.** Once an invitation expires, its registration link no longer works. -* **Expired invitations are removed automatically.** They stop appearing in [List Pending Invites](#header-list-pending-invites) once they expire; you do not need to clean them up. -* **There is no resend.** To give someone another chance to register, [invite the same email address again](#header-invite-a-user). This creates a new, independent invitation with its own token and expiry; the earlier invitation keeps working until it expires or you [revoke it](#header-revoke-a-pending-invite). -* **Invite creation is rate limited.** If you create invitations too quickly, requests are rejected with `429 Too Many Requests`. Wait before retrying. +* **Invitations expire after 3 days.** After that the registration link stops working. +* **Expired invitations clean themselves up.** They drop off [List Pending Invites](#header-list-pending-invites) on expiry, so you never have to revoke them. +* **There is no resend.** To give someone another chance to register, [invite the same email address again](#header-invite-a-user). That creates a second, independent invitation with its own expiry. The first one keeps working until it expires or you [revoke it](#header-revoke-a-pending-invite). +* **Invites are rate limited.** Creating them too fast returns `429 Too Many Requests`. Back off and retry. ### List Users [GET /v1/users] @@ -147,7 +149,8 @@ Returns the users on your account. "access": "admin", "is_sso": false, "email_verified": true, - "tfa_enabled": false + "tfa_enabled": false, + "last_login": "2026-07-28T14:22:31.000Z" }, { "name": "Grace Hopper", @@ -156,7 +159,8 @@ Returns the users on your account. "access": "reporting", "is_sso": false, "email_verified": true, - "tfa_enabled": true + "tfa_enabled": true, + "last_login": null } ] } @@ -173,7 +177,7 @@ Returns the users on your account. ### Retrieve a User [GET /v1/users/{username}] -Returns a single user by username. The response uses the [Retrieved user object](#header-retrieved-user-object) shape, which differs from the [User object](#header-user-object) returned by List Users. +Returns one user by username, in the [Retrieved user object](#header-retrieved-user-object) shape rather than the [User object](#header-user-object) shape List Users returns. + Parameters + username (required, string, `grace`) - The username of the user to retrieve. @@ -234,25 +238,22 @@ Returns a single user by username. The response uses the [Retrieved user object] { "errors": [ { - "message": "User does not exist" + "message": "User not found." } ] } ### Invite a User [POST /v1/users/invite] -There is no direct, password-based user creation through the API — users are created by **invitation** only. This endpoint creates an invitation and emails the invitee a registration link; the invitee completes registration through that link and sets their own password. The invited user's role is set by `access_level` and is bounded by the [role ceiling](#header-constraints). - -Provide `access_level` for a primary-account user, or `subaccounts` to invite a subaccount-scoped user. When `subaccounts` is supplied, a top-level `access_level` is not required. +The API has no direct, password-based user creation. New users join by invitation. This endpoint creates the invitation and emails a registration link. The invitee follows the link, sets their own password, and gets the role you named in `access_level`, subject to the [constraints](#header-constraints). -The response contains the invite `token`. +Send `access_level` to invite a primary-account user, or `subaccounts` to invite a subaccount-scoped one. A `subaccounts` invite does not need a top-level `access_level`. -Treat the invite token as a credential. Anyone who holds the token can complete registration as the invited user. Invited users normally complete registration through the emailed link, so you do not need to handle the token yourself; if you do capture it, store and transmit it as securely as you would a password, and never log or share it. +The response carries only the invite `id`, which you use to [list](#header-list-pending-invites) and [revoke](#header-revoke-a-pending-invite) the invitation. The API never returns the registration token. It reaches the invitee only in the invitation email, so completing registration requires access to that mailbox. + Data Structure - + email (string, required) - Email address of the person to invite. Max length: 512 characters. - + access_level (enum) - The primary-account role to assign. Required unless `subaccounts` is supplied. - + `admin` + + email (string, required) - Email address of the person to invite. Maximum 512 characters. + + access_level (enum) - The primary-account role to assign. Required unless `subaccounts` is supplied. `admin` is not available to API keys, see [Constraints](#header-constraints). + `developer` + `reporting` + `templates` @@ -282,8 +283,7 @@ The response contains the invite `token`. { "results": { - "id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301", - "token": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6" + "id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301" } } @@ -304,7 +304,7 @@ The response contains the invite `token`. { "errors": [ { - "message": "Forbidden" + "message": "API keys cannot invite an admin-level user." } ] } @@ -321,7 +321,7 @@ The response contains the invite `token`. ### List Pending Invites [GET /v1/users/pending-invites] -Returns the invitations on your account that have not yet been accepted. Expired invitations are not included. +Returns the invitations on your account that nobody has accepted yet. Expired invitations do not appear. + Request @@ -346,7 +346,7 @@ Returns the invitations on your account that have not yet been accepted. Expired ### Revoke a Pending Invite [DELETE /v1/users/pending-invites/{id}] -Revokes a pending invitation so its registration link can no longer be used. +Revokes a pending invitation. Its registration link stops working immediately. + Parameters + id (required, string, `3f2504e0-4f89-41d3-9a0c-0305e82c3301`) - The invite ID from [List Pending Invites](#header-list-pending-invites). @@ -371,18 +371,17 @@ Revokes a pending invitation so its registration link can no longer be used. ### Update a User [PUT /v1/users/{username}] -Changes a user's role. The request accepts only `access_level` (and `access_policies` when `access_level` is `custom`); a request that contains any other field is rejected with `403 Forbidden`. +Changes a user's role. The body accepts `access_level` and `access_policies` and nothing else. Any other field returns `403`. Names, passwords, email addresses, and user options stay web-app only. -Changing `access_level` is subject to the [role ceiling](#header-constraints), and demoting the account's last `admin` is rejected with `400`. +The [constraints](#header-constraints) apply here too. You cannot set a user to `admin`, and demoting the last `admin` returns `400`. + Data Structure - + access_level (enum, required) - The primary-account role to assign. - + `admin` + + access_level (enum) - The primary-account role to assign. + `developer` + `reporting` + `templates` + `custom` - + access_policies (array) - The policies to grant. Required when `access_level` is `custom`, and not allowed otherwise. + + access_policies (array) - The policies to grant. Only valid when `access_level` is `custom`. + Parameters + username (required, string, `grace`) - The username of the user to update. @@ -422,7 +421,7 @@ Changing `access_level` is subject to the [role ceiling](#header-constraints), a { "errors": [ { - "message": "Forbidden" + "message": "API keys may only modify access_level and access_policies." } ] } @@ -431,7 +430,7 @@ Changing `access_level` is subject to the [role ceiling](#header-constraints), a Deletes a user from your account. -You cannot delete the user who owns the API key you are calling with, a user that belongs to a different account, or the account's last `admin`. +You cannot delete the owner of the key you are calling with, a user on a different account, or the account's last `admin`. + Parameters + username (required, string, `grace`) - The username of the user to delete. @@ -459,7 +458,7 @@ You cannot delete the user who owns the API key you are calling with, a user tha { "errors": [ { - "message": "Forbidden" + "message": "Cannot delete current user." } ] } @@ -476,7 +475,9 @@ You cannot delete the user who owns the API key you are calling with, a user tha ### Add a Subaccount Mapping [POST /v1/users/{username}/subaccounts] -Grants a user access to a subaccount at the given access level. If the user already has access to the subaccount, the access level is updated. +Grants a user access to a subaccount at the given access level. If the user already has access to that subaccount, the call updates their access level instead. + +This endpoint adds subaccounts to a user who is already subaccount-scoped, so the target needs at least one mapping to begin with. Calling it for a primary-account user returns `400`. To give a new person subaccount access, [invite them](#header-invite-a-user) with a `subaccounts` array. + Data Structure + subaccount_id (number, required) - The subaccount to grant access to. @@ -518,13 +519,27 @@ Grants a user access to a subaccount at the given access level. If the user alre ] } ++ Response 400 (application/json) + + The user has no subaccount mappings. + + { + "errors": [ + { + "message": "Invalid user" + } + ] + } + ### Remove a Subaccount Mapping [DELETE /v1/users/{username}/subaccounts/{subaccountId}] Removes a user's access to a subaccount. +A subaccount-scoped user must keep at least one subaccount, so removing the last mapping returns `400`. [Delete the user](#header-delete-a-user) instead. + + Parameters + username (required, string, `grace`) - The username of the user. - + subaccountId (required, number, `123`) - The subaccount to remove access to. + + subaccountId (required, number, `123`) - The subaccount to revoke access to. + Request @@ -533,3 +548,13 @@ Removes a user's access to a subaccount. Authorization: 14ac5499cfdd2bb2859e4476d2e5b1d2bad079bf + Response 204 + ++ Response 400 (application/json) + + { + "errors": [ + { + "message": "Cannot delete last subaccount for user" + } + ] + } From f00535ff99d74195030dfa648191c442e9510c3e Mon Sep 17 00:00:00 2001 From: Daniel Sousa Date: Tue, 25 Aug 2026 16:36:00 -0300 Subject: [PATCH 09/13] docs(users): document access policies, fix Data Structure rendering Request-body Data Structure blocks were separated from the following Parameters/Request/Response items by a single blank line, which remark parses as one continuous list. isDataStructure rejects lists over two items, so Invite, Update and the subaccount endpoints never rendered a Request Body table at all. Two blank lines terminate the list, matching subaccounts.apib. access_policies failed to parse as MSON: the underscore in the member name paired with the one in `access_level` in its description and was consumed as markdown emphasis, so the whole line rendered as the field name with type string. Backticking the name is the same workaround used for verification_mailbox in sending-domains.apib. Document the 15 policies from POLICIES in access/lib/user-access.js, and link the access_policies fields to the new section. An array cannot carry enumerations through the parser, so the values live in the section rather than in a Possible Values row. Split the embedded subaccounts arrays into their own object sections, following the Attachment Object pattern in transmissions.apib. The inline `+ (object)` nesting produced an object with valueless keys, which the renderer printed as a stray ", value is [{}]". Rename Update a User to Update a User's Role, since the body accepts only access_level and access_policies, and rename the subaccount endpoints to Grant/Revoke Subaccount Access, dropping "mapping" from the prose. No links referenced the old anchors. Also drop two links to the removed Constraints section, add a custom-role example to the List Users response, and cut AI-flavoured phrasing in favour of the plainer voice used in subaccounts.apib. --- content/api/users.apib | 403 ++++++++++++++++++++++------------------- 1 file changed, 219 insertions(+), 184 deletions(-) diff --git a/content/api/users.apib b/content/api/users.apib index 13e3407e..c9d4ec79 100644 --- a/content/api/users.apib +++ b/content/api/users.apib @@ -1,25 +1,26 @@ FORMAT: 1A title: Users API -description: Programmatically manage the users on your SparkPost account with an API key. +description: Manage the users on your SparkPost account. +label: New # Group Users -Use the Users API to manage your account's users from code instead of the web app. You can list users, invite new ones, change roles, remove users, and manage each user's subaccount access. +Use the Users API to manage the users on your account: list them, invite new ones, change their roles, and remove them. -### Prerequisites +### Grants -Every call needs an API key holding one of two grants. Only an admin can attach either grant, and only from the web app's [API Keys page](https://app.sparkpost.com/account/api-keys). No API key can attach a grant, not even a key that already holds one. +Every request needs an API key with one of two grants: -* **`Users: View`** gives read-only access to the three `GET` endpoints: [List Users](#header-list-users), [Retrieve a User](#header-retrieve-a-user), and [List Pending Invites](#header-list-pending-invites). -* **`Users: Manage`** covers the same reads plus every write: invite, update, delete, and the subaccount-mapping changes. +* `Users: View` allows the three `GET` endpoints: [List Users](#header-list-users), [Retrieve a User](#header-retrieve-a-user), and [List Pending Invites](#header-list-pending-invites). +* `Users: Manage` allows those reads plus every write: invite, update, and delete. -A key holding neither grant gets `403`, and so does a write attempted with only `Users: View`. +Only an admin can add these grants, and only from the web app's [API Keys page](https://app.sparkpost.com/account/api-keys). -These grants cover less than the web app's user management. They do not reach two-factor authentication, email verification, SCIM provisioning, or password endpoints, and they cannot create a user directly with a password. New users join by invitation. +A key with neither grant returns `403`, as does a write attempted with only `Users: View`. ### Roles -Inviting or updating a user assigns a role through the `access_level` field. The primary-account roles are: +The `access_level` field sets a user's role when you invite or update them. The primary-account roles are: | Role | Description | |-------------|-------------------------------------------------------------| @@ -29,22 +30,35 @@ Inviting or updating a user assigns a role through the `access_level` field. The | `templates` | Access limited to managing templates. | | `custom` | A role whose permissions are defined by the `access_policies` you supply. | -Subaccount-scoped users take one of two access levels instead, `subaccount_reporting` or `subaccount_developer`. These appear only in the `subaccounts` array and in the subaccount-mapping endpoints. - -### Constraints - -These rules apply to every Users API request. They stop a key from escalating its own privileges or locking an account out of its own administration. - -* **No admin through the API.** An API key never assigns the `admin` role, not even a key owned by an admin. Inviting a user as `admin` returns `403`, and so does promoting one. Create admins in the web app. -* **Role ceiling.** Below admin, a key can only grant a role at or under the role held by the user who owns it. A key owned by a `reporting` user invites `reporting` users and nothing higher. -* **Last admin protection.** The API refuses to delete or demote the last `admin` on an account, returning `400`. -* **Self-deletion.** A key cannot delete its own owner. +Subaccount-scoped users take `subaccount_reporting` or `subaccount_developer` instead. + +### Access policies + +A user with the `custom` role gets exactly the policies you list in `access_policies`, and nothing else. Supply them as an array of policy names. The field is only valid when `access_level` is `custom`. + +| Policy | Grants | +|--------|--------| +| `alerts/full` | View and manage alerts. | +| `api_keys/full` | Manage API keys. | +| `ab_testing/full` | View and manage A/B tests of email templates. | +| `domains/full` | View and manage sending, bounce, and tracking domains. | +| `events/read` | View and search message events. | +| `ip_pools/full` | View and manage IP pools. | +| `recipient_lists/full` | View and manage recipient lists. | +| `recipient_validation/full` | Validate email addresses and view recent validations. | +| `seeds/full` | View seeding activity and manage seed list settings. | +| `signals_analytics/full` | View and manage analytics metrics, dashboards, and reports, including health score, spam traps, engagement recency, and blocklist incidents. | +| `subaccounts/full` | Manage subaccounts. | +| `suppressions/full` | View and manage suppressions. | +| `templates/full` | View, manage, and preview email templates and snippets. | +| `users/full` | View and manage users. | +| `webhooks/full` | View, manage, and test webhooks. | ### User object Returned by [List Users](#header-list-users). -+ Data Structure: User ++ Data Structure: Attributes + name (string) - The user's display name, derived from their first and last name. + username (string) - Unique username that identifies the user. + email (string) - The user's email address. @@ -54,26 +68,20 @@ Returned by [List Users](#header-list-users). + `reporting` + `templates` + `custom` - + access_policies (array) - The policies granted to the user when `access` is `custom`. + + `access_policies` (array[string]) - The [access policies](#header-access-policies) granted to the user when `access` is `custom`. + is_sso (boolean) - Whether the user signs in via single sign-on. + email_verified (boolean) - Whether the user has verified their email address. + tfa_enabled (boolean) - Whether the user has two-factor authentication enabled. + last_login (string, nullable) - ISO 8601 timestamp of the user's last sign-in, or `null` if they have never signed in. + subaccount_id (number) - The subaccount the user is scoped to, if any. - + subaccounts (array) - The subaccounts a subaccount-scoped user has access to, and their access level on each. Present only for subaccount-scoped users. - + (object) - + subaccount_id (number) - The subaccount ID. - + access_level (enum) - The user's access level on the subaccount. - + `subaccount_reporting` - + `subaccount_developer` - + subaccount_name (string) - The subaccount's display name. + + subaccounts (array[object]) - The subaccounts a subaccount-scoped user has access to, and their access level on each. Present only for subaccount-scoped users. For a full description, see [Subaccount access object](#header-subaccount-access-object). + options (object) - User-level options, present only when set. ### Retrieved user object -Returned by [Retrieve a User](#header-retrieve-a-user). It differs from the [User object](#header-user-object) that List Users returns. This shape splits `name` into `first_name` and `last_name`, names the primary-account role `access_level` rather than `access`, and adds `created`, `updated`, and a `subaccounts` array that is always present. +Returned by [Retrieve a User](#header-retrieve-a-user). This shape differs from the [User object](#header-user-object): `name` is split into `first_name` and `last_name`, the primary-account role is called `access_level` rather than `access`, and `created`, `updated`, and `subaccounts` are included. -+ Data Structure: RetrievedUser ++ Data Structure: Attributes + username (string) - Unique username that identifies the user. + first_name (string) - The user's first name. + last_name (string) - The user's last name. @@ -84,7 +92,7 @@ Returned by [Retrieve a User](#header-retrieve-a-user). It differs from the [Use + `reporting` + `templates` + `custom` - + access_policies (array) - The policies granted to the user when `access_level` is `custom`. + + `access_policies` (array[string]) - The [access policies](#header-access-policies) granted to the user when `access_level` is `custom`. + subaccount_id (number) - The subaccount the user is scoped to, if any. Omitted when `subaccounts` is non-empty. + is_sso (boolean) - Whether the user signs in via single sign-on. + email_verified (boolean) - Whether the user has verified their email address. @@ -92,23 +100,37 @@ Returned by [Retrieve a User](#header-retrieve-a-user). It differs from the [Use + options (object) - User-level options, present only when set. + created (string) - ISO 8601 timestamp of when the user was created. + updated (string) - ISO 8601 timestamp of when the user was last updated. - + subaccounts (array) - The subaccounts the user has access to, and their access level on each. Always present, and an empty array for a user with no subaccount mappings. When it is non-empty, the top-level `access_level` and `subaccount_id` fields are omitted. - + (object) - + subaccount_id (number) - The subaccount ID. - + subaccount_name (string) - The subaccount's display name. - + access_level (enum) - The user's access level on the subaccount. - + `subaccount_reporting` - + `subaccount_developer` - + status (enum) - The subaccount's status. - + active - + suspended - + terminated + + subaccounts (array[object]) - The subaccounts the user has access to, and their access level on each. Always present, and an empty array for a user with no subaccount access. When it is non-empty, the top-level `access_level` and `subaccount_id` fields are omitted. For a full description, see [Subaccount access object](#header-subaccount-access-object). + +### Subaccount access object + +Each object in a user's `subaccounts` array describes one subaccount the user can reach, and the access level they hold on it. + ++ Data Structure: Attributes + + subaccount_id (number) - The subaccount ID. + + subaccount_name (string) - The subaccount's display name. + + access_level (enum) - The user's access level on the subaccount. + + `subaccount_reporting` + + `subaccount_developer` + + status (enum) - The subaccount's status. Returned by [Retrieve a User](#header-retrieve-a-user) only. + + active + + suspended + + terminated ++ Sample + ``` + { + "subaccount_id": 123, + "subaccount_name": "Joe's Garage", + "access_level": "subaccount_reporting", + "status": "active" + } + ``` ### Invite object Returned by [List Pending Invites](#header-list-pending-invites). -+ Data Structure: Invite ++ Data Structure: Attributes + id (string) - Unique ID for the pending invite. Use it to revoke the invite. + email (string) - The email address the invite was sent to. + from (string) - The email address of the user who created the invite. @@ -120,151 +142,47 @@ Returned by [List Pending Invites](#header-list-pending-invites). + `custom` + expires (number) - Unix timestamp, in epoch seconds, at which the invitation expires. -### Invite lifecycle - -* **Invitations expire after 3 days.** After that the registration link stops working. -* **Expired invitations clean themselves up.** They drop off [List Pending Invites](#header-list-pending-invites) on expiry, so you never have to revoke them. -* **There is no resend.** To give someone another chance to register, [invite the same email address again](#header-invite-a-user). That creates a second, independent invitation with its own expiry. The first one keeps working until it expires or you [revoke it](#header-revoke-a-pending-invite). -* **Invites are rate limited.** Creating them too fast returns `429 Too Many Requests`. Back off and retry. - -### List Users [GET /v1/users] - -Returns the users on your account. - -+ Request - - + Headers - - Authorization: 14ac5499cfdd2bb2859e4476d2e5b1d2bad079bf - Accept: application/json - -+ Response 200 (application/json) - - { - "results": [ - { - "name": "Ada Lovelace", - "username": "ada", - "email": "ada@example.com", - "access": "admin", - "is_sso": false, - "email_verified": true, - "tfa_enabled": false, - "last_login": "2026-07-28T14:22:31.000Z" - }, - { - "name": "Grace Hopper", - "username": "grace", - "email": "grace@example.com", - "access": "reporting", - "is_sso": false, - "email_verified": true, - "tfa_enabled": true, - "last_login": null - } - ] - } - -+ Response 403 (application/json) - - { - "errors": [ - { - "message": "Forbidden" - } - ] - } - -### Retrieve a User [GET /v1/users/{username}] - -Returns one user by username, in the [Retrieved user object](#header-retrieved-user-object) shape rather than the [User object](#header-user-object) shape List Users returns. - -+ Parameters - + username (required, string, `grace`) - The username of the user to retrieve. - -+ Request - - + Headers - - Authorization: 14ac5499cfdd2bb2859e4476d2e5b1d2bad079bf - Accept: application/json +### Subaccount invite object -+ Response 200 (application/json) - - { - "results": { - "username": "grace", - "first_name": "Grace", - "last_name": "Hopper", - "email": "grace@example.com", - "access_level": "reporting", - "is_sso": false, - "email_verified": true, - "tfa_enabled": true, - "created": "2015-01-11T08:00:00.000Z", - "updated": "2018-04-11T08:00:00.000Z", - "subaccounts": [] - } - } +Each object in the `subaccounts` array of an [Invite a User](#header-invite-a-user) request grants the invitee access to one subaccount. -+ Response 200 (application/json) ++ Data Structure: Attributes + + subaccount_id (number, required) - The subaccount ID. + + access_level (enum, required) - The access level to grant on the subaccount. + + `subaccount_reporting` + + `subaccount_developer` ++ Sample + ``` + { + "subaccount_id": 123, + "access_level": "subaccount_reporting" + } + ``` - A subaccount-scoped user. +### Invite lifecycle - { - "results": { - "username": "joe", - "first_name": "Joe", - "last_name": "Mechanic", - "email": "joe@example.com", - "is_sso": false, - "email_verified": true, - "tfa_enabled": false, - "created": "2015-01-11T08:00:00.000Z", - "updated": "2018-04-11T08:00:00.000Z", - "subaccounts": [ - { - "subaccount_id": 123, - "subaccount_name": "Joe's Garage", - "access_level": "subaccount_reporting", - "status": "active" - } - ] - } - } +Invitations expire after three days. The registration link then stops working and the invitation drops off [List Pending Invites](#header-list-pending-invites). -+ Response 404 (application/json) - - { - "errors": [ - { - "message": "User not found." - } - ] - } +To give someone another chance to register, [invite the same email address again](#header-invite-a-user). That creates a second, independent invitation with its own expiry, and the first one keeps working until it expires or you [revoke it](#header-revoke-a-pending-invite). ### Invite a User [POST /v1/users/invite] -The API has no direct, password-based user creation. New users join by invitation. This endpoint creates the invitation and emails a registration link. The invitee follows the link, sets their own password, and gets the role you named in `access_level`, subject to the [constraints](#header-constraints). +Creates an invitation and emails a registration link to the address you supply. The invitee follows the link, sets their own password, and takes the role you named in `access_level`. Send `access_level` to invite a primary-account user, or `subaccounts` to invite a subaccount-scoped one. A `subaccounts` invite does not need a top-level `access_level`. -The response carries only the invite `id`, which you use to [list](#header-list-pending-invites) and [revoke](#header-revoke-a-pending-invite) the invitation. The API never returns the registration token. It reaches the invitee only in the invitation email, so completing registration requires access to that mailbox. +The response contains only the invite `id`, which you use to [list](#header-list-pending-invites) and [revoke](#header-revoke-a-pending-invite) the invitation. The registration token is sent in the invitation email and is never returned by the API. + Data Structure + email (string, required) - Email address of the person to invite. Maximum 512 characters. - + access_level (enum) - The primary-account role to assign. Required unless `subaccounts` is supplied. `admin` is not available to API keys, see [Constraints](#header-constraints). + + access_level (enum) - The primary-account role to assign. Required unless `subaccounts` is supplied. `admin` is not available to API keys. + `developer` + `reporting` + `templates` + `custom` - + access_policies (array) - The policies to grant. Only valid when `access_level` is `custom`. - + subaccounts (array) - Invite the user with access to one or more subaccounts instead of the primary account. Between 1 and 25 entries. - + (object) - + subaccount_id (number, required) - The subaccount ID. - + access_level (enum, required) - The subaccount access level. - + `subaccount_reporting` - + `subaccount_developer` + + `access_policies` (array[string]) - The [access policies](#header-access-policies) to grant. Only valid when `access_level` is `custom`. + + subaccounts (array[object]) - Invite the user with access to one or more subaccounts instead of the primary account. Between 1 and 25 entries. For a full description, see [Subaccount invite object](#header-subaccount-invite-object). + + Request (application/json) @@ -369,11 +287,130 @@ Revokes a pending invitation. Its registration link stops working immediately. ] } -### Update a User [PUT /v1/users/{username}] +### List Users [GET /v1/users] + +Returns the users on your account. -Changes a user's role. The body accepts `access_level` and `access_policies` and nothing else. Any other field returns `403`. Names, passwords, email addresses, and user options stay web-app only. ++ Request -The [constraints](#header-constraints) apply here too. You cannot set a user to `admin`, and demoting the last `admin` returns `400`. + + Headers + + Authorization: 14ac5499cfdd2bb2859e4476d2e5b1d2bad079bf + Accept: application/json + ++ Response 200 (application/json) + + { + "results": [ + { + "name": "Ada Lovelace", + "username": "ada", + "email": "ada@example.com", + "access": "admin", + "is_sso": false, + "email_verified": true, + "tfa_enabled": false, + "last_login": "2026-07-28T14:22:31.000Z" + }, + { + "name": "Katherine Johnson", + "username": "katherine", + "email": "katherine@example.com", + "access": "custom", + "access_policies": [ + "templates/full", + "events/read", + "signals_analytics/full" + ], + "is_sso": false, + "email_verified": true, + "tfa_enabled": true, + "last_login": null + } + ] + } + ++ Response 403 (application/json) + + { + "errors": [ + { + "message": "Forbidden" + } + ] + } + +### Retrieve a User [GET /v1/users/{username}] + +Returns one user by username, in the [Retrieved user object](#header-retrieved-user-object) shape. + ++ Parameters + + username (required, string, `grace`) - The username of the user to retrieve. + ++ Request + + + Headers + + Authorization: 14ac5499cfdd2bb2859e4476d2e5b1d2bad079bf + Accept: application/json + ++ Response 200 (application/json) + + { + "results": { + "username": "grace", + "first_name": "Grace", + "last_name": "Hopper", + "email": "grace@example.com", + "access_level": "reporting", + "is_sso": false, + "email_verified": true, + "tfa_enabled": true, + "created": "2015-01-11T08:00:00.000Z", + "updated": "2018-04-11T08:00:00.000Z", + "subaccounts": [] + } + } + ++ Response 200 (application/json) + + A subaccount-scoped user. + + { + "results": { + "username": "joe", + "first_name": "Joe", + "last_name": "Mechanic", + "email": "joe@example.com", + "is_sso": false, + "email_verified": true, + "tfa_enabled": false, + "created": "2015-01-11T08:00:00.000Z", + "updated": "2018-04-11T08:00:00.000Z", + "subaccounts": [ + { + "subaccount_id": 123, + "subaccount_name": "Joe's Garage", + "access_level": "subaccount_reporting", + "status": "active" + } + ] + } + } + ++ Response 404 (application/json) + + { + "errors": [ + { + "message": "User not found." + } + ] + } + +### Update a User's Role [PUT /v1/users/{username}] + +The body accepts `access_level` and `access_policies` only. + Data Structure + access_level (enum) - The primary-account role to assign. @@ -381,7 +418,8 @@ The [constraints](#header-constraints) apply here too. You cannot set a user to + `reporting` + `templates` + `custom` - + access_policies (array) - The policies to grant. Only valid when `access_level` is `custom`. + + `access_policies` (array[string]) - The [access policies](#header-access-policies) to grant. Only valid when `access_level` is `custom`. + + Parameters + username (required, string, `grace`) - The username of the user to update. @@ -428,9 +466,7 @@ The [constraints](#header-constraints) apply here too. You cannot set a user to ### Delete a User [DELETE /v1/users/{username}] -Deletes a user from your account. - -You cannot delete the owner of the key you are calling with, a user on a different account, or the account's last `admin`. +Deletes a user from your account. You cannot delete the owner of the key you are calling with, a user on a different account, or the account's last `admin`. + Parameters + username (required, string, `grace`) - The username of the user to delete. @@ -473,11 +509,11 @@ You cannot delete the owner of the key you are calling with, a user on a differe ] } -### Add a Subaccount Mapping [POST /v1/users/{username}/subaccounts] +### Grant Subaccount Access [POST /v1/users/{username}/subaccounts] Grants a user access to a subaccount at the given access level. If the user already has access to that subaccount, the call updates their access level instead. -This endpoint adds subaccounts to a user who is already subaccount-scoped, so the target needs at least one mapping to begin with. Calling it for a primary-account user returns `400`. To give a new person subaccount access, [invite them](#header-invite-a-user) with a `subaccounts` array. +The user must already have access to at least one subaccount. Calling this endpoint for a primary-account user returns `400`. To give a new person subaccount access, [invite them](#header-invite-a-user) with a `subaccounts` array. + Data Structure + subaccount_id (number, required) - The subaccount to grant access to. @@ -485,6 +521,7 @@ This endpoint adds subaccounts to a user who is already subaccount-scoped, so th + `subaccount_reporting` + `subaccount_developer` + + Parameters + username (required, string, `grace`) - The username of the user. @@ -521,7 +558,7 @@ This endpoint adds subaccounts to a user who is already subaccount-scoped, so th + Response 400 (application/json) - The user has no subaccount mappings. + The user is not subaccount-scoped. { "errors": [ @@ -531,11 +568,9 @@ This endpoint adds subaccounts to a user who is already subaccount-scoped, so th ] } -### Remove a Subaccount Mapping [DELETE /v1/users/{username}/subaccounts/{subaccountId}] - -Removes a user's access to a subaccount. +### Revoke Subaccount Access [DELETE /v1/users/{username}/subaccounts/{subaccountId}] -A subaccount-scoped user must keep at least one subaccount, so removing the last mapping returns `400`. [Delete the user](#header-delete-a-user) instead. +Removes a user's access to a subaccount. A subaccount-scoped user must keep at least one subaccount, so revoking the last one returns `400`. [Delete the user](#header-delete-a-user) instead. + Parameters + username (required, string, `grace`) - The username of the user. From 2cdba9d04dcfbf47e8ebe3cf6b6cfd04ab1c74c8 Mon Sep 17 00:00:00 2001 From: Daniel Sousa Date: Wed, 26 Aug 2026 17:30:33 -0300 Subject: [PATCH 10/13] docs(users): one user object for List and Retrieve Per the decision recorded on SparkPost/access#121, List and Retrieve stop using two names for the same data. The rule is superset, not synonyms: a field means the same thing and carries the same name on both endpoints, and Retrieve may return more fields than List but never the same field under a different name. Neither endpoint has ever been reachable by an API key, so there is no programmatic contract to preserve and this is the last release in which the divergence is free to fix. - `name` becomes `first_name` and `last_name`. `name` does not exist in storage; formatName derives it at read time and falls back to `username` when both are null, so it cannot be reversed. - `access` becomes `access_level`, matching the request field name on Invite and Update. Writing one name and reading back another was the worst of the inconsistencies. - `first_name` and `last_name` are nullable. Both default to null in DynamoDB and pass through unchanged. - `last_login` is documented on both endpoints. - `created` and `updated` are marked Retrieve-only, as `status` on subaccount entries already was. The two object sections merge into one. They now share every field name, so keeping two near-identical tables would only invite drift. Presence rules that genuinely differ moved onto the fields they describe. Also drops the Grants section from the overview. The service does not return this shape yet. List still emits `name` and `access` until the projection lands in SparkPost/accusers-api#1294. --- content/api/users.apib | 65 ++++++++++++------------------------------ 1 file changed, 18 insertions(+), 47 deletions(-) diff --git a/content/api/users.apib b/content/api/users.apib index c9d4ec79..a7d88888 100644 --- a/content/api/users.apib +++ b/content/api/users.apib @@ -5,18 +5,7 @@ label: New # Group Users -Use the Users API to manage the users on your account: list them, invite new ones, change their roles, and remove them. - -### Grants - -Every request needs an API key with one of two grants: - -* `Users: View` allows the three `GET` endpoints: [List Users](#header-list-users), [Retrieve a User](#header-retrieve-a-user), and [List Pending Invites](#header-list-pending-invites). -* `Users: Manage` allows those reads plus every write: invite, update, and delete. - -Only an admin can add these grants, and only from the web app's [API Keys page](https://app.sparkpost.com/account/api-keys). - -A key with neither grant returns `403`, as does a write attempted with only `Users: View`. +Manage the users on your account: list, invite new ones, change their roles, and remove them. ### Roles @@ -56,35 +45,12 @@ A user with the `custom` role gets exactly the policies you list in `access_poli ### User object -Returned by [List Users](#header-list-users). - -+ Data Structure: Attributes - + name (string) - The user's display name, derived from their first and last name. - + username (string) - Unique username that identifies the user. - + email (string) - The user's email address. - + access (enum) - The user's primary-account role. Omitted for subaccount-scoped users. - + `admin` - + `developer` - + `reporting` - + `templates` - + `custom` - + `access_policies` (array[string]) - The [access policies](#header-access-policies) granted to the user when `access` is `custom`. - + is_sso (boolean) - Whether the user signs in via single sign-on. - + email_verified (boolean) - Whether the user has verified their email address. - + tfa_enabled (boolean) - Whether the user has two-factor authentication enabled. - + last_login (string, nullable) - ISO 8601 timestamp of the user's last sign-in, or `null` if they have never signed in. - + subaccount_id (number) - The subaccount the user is scoped to, if any. - + subaccounts (array[object]) - The subaccounts a subaccount-scoped user has access to, and their access level on each. Present only for subaccount-scoped users. For a full description, see [Subaccount access object](#header-subaccount-access-object). - + options (object) - User-level options, present only when set. - -### Retrieved user object - -Returned by [Retrieve a User](#header-retrieve-a-user). This shape differs from the [User object](#header-user-object): `name` is split into `first_name` and `last_name`, the primary-account role is called `access_level` rather than `access`, and `created`, `updated`, and `subaccounts` are included. +Returned by [List Users](#header-list-users) and [Retrieve a User](#header-retrieve-a-user). Both use the same field names. Retrieve returns three things List does not: `created`, `updated`, and a `status` on each entry of `subaccounts`. + Data Structure: Attributes + username (string) - Unique username that identifies the user. - + first_name (string) - The user's first name. - + last_name (string) - The user's last name. + + first_name (string, nullable) - The user's first name. `null` if they have not set one. + + last_name (string, nullable) - The user's last name. `null` if they have not set one. + email (string) - The user's email address. + access_level (enum) - The user's primary-account role. Omitted for subaccount-scoped users. + `admin` @@ -93,14 +59,15 @@ Returned by [Retrieve a User](#header-retrieve-a-user). This shape differs from + `templates` + `custom` + `access_policies` (array[string]) - The [access policies](#header-access-policies) granted to the user when `access_level` is `custom`. - + subaccount_id (number) - The subaccount the user is scoped to, if any. Omitted when `subaccounts` is non-empty. + is_sso (boolean) - Whether the user signs in via single sign-on. + email_verified (boolean) - Whether the user has verified their email address. + tfa_enabled (boolean) - Whether the user has two-factor authentication enabled. + + last_login (string, nullable) - ISO 8601 timestamp of the user's last sign-in, or `null` if they have never signed in. + + subaccount_id (number) - The subaccount the user is scoped to, if any. [Retrieve a User](#header-retrieve-a-user) omits it when `subaccounts` is non-empty. + + subaccounts (array[object]) - The subaccounts the user has access to, and their access level on each. For a full description, see [Subaccount access object](#header-subaccount-access-object). [Retrieve a User](#header-retrieve-a-user) always returns this field, as an empty array for a user with no subaccount access, and omits the top-level `access_level` when it is non-empty. [List Users](#header-list-users) returns it only for subaccount-scoped users. + options (object) - User-level options, present only when set. - + created (string) - ISO 8601 timestamp of when the user was created. - + updated (string) - ISO 8601 timestamp of when the user was last updated. - + subaccounts (array[object]) - The subaccounts the user has access to, and their access level on each. Always present, and an empty array for a user with no subaccount access. When it is non-empty, the top-level `access_level` and `subaccount_id` fields are omitted. For a full description, see [Subaccount access object](#header-subaccount-access-object). + + created (string) - ISO 8601 timestamp of when the user was created. Returned by [Retrieve a User](#header-retrieve-a-user) only. + + updated (string) - ISO 8601 timestamp of when the user was last updated. Returned by [Retrieve a User](#header-retrieve-a-user) only. ### Subaccount access object @@ -303,20 +270,22 @@ Returns the users on your account. { "results": [ { - "name": "Ada Lovelace", "username": "ada", + "first_name": "Ada", + "last_name": "Lovelace", "email": "ada@example.com", - "access": "admin", + "access_level": "admin", "is_sso": false, "email_verified": true, "tfa_enabled": false, "last_login": "2026-07-28T14:22:31.000Z" }, { - "name": "Katherine Johnson", "username": "katherine", + "first_name": "Katherine", + "last_name": "Johnson", "email": "katherine@example.com", - "access": "custom", + "access_level": "custom", "access_policies": [ "templates/full", "events/read", @@ -342,7 +311,7 @@ Returns the users on your account. ### Retrieve a User [GET /v1/users/{username}] -Returns one user by username, in the [Retrieved user object](#header-retrieved-user-object) shape. +Returns one user by username, in the [User object](#header-user-object) shape. + Parameters + username (required, string, `grace`) - The username of the user to retrieve. @@ -366,6 +335,7 @@ Returns one user by username, in the [Retrieved user object](#header-retrieved-u "is_sso": false, "email_verified": true, "tfa_enabled": true, + "last_login": "2026-08-02T09:14:05.000Z", "created": "2015-01-11T08:00:00.000Z", "updated": "2018-04-11T08:00:00.000Z", "subaccounts": [] @@ -385,6 +355,7 @@ Returns one user by username, in the [Retrieved user object](#header-retrieved-u "is_sso": false, "email_verified": true, "tfa_enabled": false, + "last_login": null, "created": "2015-01-11T08:00:00.000Z", "updated": "2018-04-11T08:00:00.000Z", "subaccounts": [ From 7f29ff51da8e298a6c27c07314848c3101854ea6 Mon Sep 17 00:00:00 2001 From: Daniel Sousa Date: Thu, 27 Aug 2026 16:35:57 -0300 Subject: [PATCH 11/13] docs(users): add object samples and simplify subaccount scoping Add a `+ Sample` to `User object` and `Invite object`. Every object section on the page now has one, matching the bare-object form the two subaccount samples already use rather than the `results`-wrapped form on the subaccounts page, which does not belong in an object definition. Drop `options`. It is not a public property of these endpoints. Drop the top-level `subaccount_id` and state the scoping rule once as prose. A user is scoped either to the account or to subaccounts, so `access_level` and `subaccounts` were each carrying a conditional describing the other, plus a third field encoding the same fact a second way. `subaccounts` is now the only public answer to what a user can reach, and Retrieve alone returns it, which keeps List off the user-subaccount mapping table. The service does not return this shape yet. See SparkPost/accusers-api#1294. --- content/api/users.apib | 49 +++++++++++++++++++++++++++++++----------- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/content/api/users.apib b/content/api/users.apib index a7d88888..f9f401f8 100644 --- a/content/api/users.apib +++ b/content/api/users.apib @@ -45,14 +45,16 @@ A user with the `custom` role gets exactly the policies you list in `access_poli ### User object -Returned by [List Users](#header-list-users) and [Retrieve a User](#header-retrieve-a-user). Both use the same field names. Retrieve returns three things List does not: `created`, `updated`, and a `status` on each entry of `subaccounts`. +Returned by [List Users](#header-list-users) and [Retrieve a User](#header-retrieve-a-user). + +Every user is scoped either to the account or to one or more subaccounts. An account-scoped user has an `access_level`. A subaccount-scoped user has none, and holds a role on each subaccount instead. + Data Structure: Attributes + username (string) - Unique username that identifies the user. - + first_name (string, nullable) - The user's first name. `null` if they have not set one. - + last_name (string, nullable) - The user's last name. `null` if they have not set one. + + first_name (string, nullable) - The user's first name. + + last_name (string, nullable) - The user's last name. + email (string) - The user's email address. - + access_level (enum) - The user's primary-account role. Omitted for subaccount-scoped users. + + access_level (enum) - The user's role on the account. Absent for subaccount-scoped users. + `admin` + `developer` + `reporting` @@ -63,11 +65,26 @@ Returned by [List Users](#header-list-users) and [Retrieve a User](#header-retri + email_verified (boolean) - Whether the user has verified their email address. + tfa_enabled (boolean) - Whether the user has two-factor authentication enabled. + last_login (string, nullable) - ISO 8601 timestamp of the user's last sign-in, or `null` if they have never signed in. - + subaccount_id (number) - The subaccount the user is scoped to, if any. [Retrieve a User](#header-retrieve-a-user) omits it when `subaccounts` is non-empty. - + subaccounts (array[object]) - The subaccounts the user has access to, and their access level on each. For a full description, see [Subaccount access object](#header-subaccount-access-object). [Retrieve a User](#header-retrieve-a-user) always returns this field, as an empty array for a user with no subaccount access, and omits the top-level `access_level` when it is non-empty. [List Users](#header-list-users) returns it only for subaccount-scoped users. - + options (object) - User-level options, present only when set. + + subaccounts (array[object]) - The subaccounts the user can reach, and their role on each. See [Subaccount access object](#header-subaccount-access-object). Returned by [Retrieve a User](#header-retrieve-a-user) only, and empty for an account-scoped user. + created (string) - ISO 8601 timestamp of when the user was created. Returned by [Retrieve a User](#header-retrieve-a-user) only. + updated (string) - ISO 8601 timestamp of when the user was last updated. Returned by [Retrieve a User](#header-retrieve-a-user) only. ++ Sample + ``` + { + "username": "grace", + "first_name": "Grace", + "last_name": "Hopper", + "email": "grace@example.com", + "access_level": "reporting", + "is_sso": false, + "email_verified": true, + "tfa_enabled": true, + "last_login": "2026-08-02T09:14:05.000Z", + "created": "2015-01-11T08:00:00.000Z", + "updated": "2018-04-11T08:00:00.000Z", + "subaccounts": [] + } + ``` ### Subaccount access object @@ -108,6 +125,16 @@ Returned by [List Pending Invites](#header-list-pending-invites). + `templates` + `custom` + expires (number) - Unix timestamp, in epoch seconds, at which the invitation expires. ++ Sample + ``` + { + "id": "3f2504e0-4f89-41d3-9a0c-0305e82c3301", + "email": "newuser@example.com", + "from": "ada@example.com", + "access_level": "reporting", + "expires": 1720656000 + } + ``` ### Subaccount invite object @@ -138,7 +165,7 @@ Creates an invitation and emails a registration link to the address you supply. Send `access_level` to invite a primary-account user, or `subaccounts` to invite a subaccount-scoped one. A `subaccounts` invite does not need a top-level `access_level`. -The response contains only the invite `id`, which you use to [list](#header-list-pending-invites) and [revoke](#header-revoke-a-pending-invite) the invitation. The registration token is sent in the invitation email and is never returned by the API. +The response contains the invite `id`, which you use to [list](#header-list-pending-invites) and [revoke](#header-revoke-a-pending-invite) the invitation. + Data Structure + email (string, required) - Email address of the person to invite. Maximum 512 characters. @@ -381,10 +408,8 @@ Returns one user by username, in the [User object](#header-user-object) shape. ### Update a User's Role [PUT /v1/users/{username}] -The body accepts `access_level` and `access_policies` only. - + Data Structure - + access_level (enum) - The primary-account role to assign. + + access_level (enum) - The primary-account [role](#header-roles) to assign. + `developer` + `reporting` + `templates` @@ -437,7 +462,7 @@ The body accepts `access_level` and `access_policies` only. ### Delete a User [DELETE /v1/users/{username}] -Deletes a user from your account. You cannot delete the owner of the key you are calling with, a user on a different account, or the account's last `admin`. +Deletes a user from your account. + Parameters + username (required, string, `grace`) - The username of the user to delete. From 35d0e47740c6273f2364a764b84183bd54f00c99 Mon Sep 17 00:00:00 2001 From: Daniel Sousa Date: Thu, 27 Aug 2026 18:32:29 -0300 Subject: [PATCH 12/13] docs(users): link roles, and document the invite's policies and subaccounts Every primary-account `access_level` description now links to the Roles section. Update a User's Role already did; the other three named a role without saying where the list lives. Add `access_policies` and `subaccounts` to the Invite object. Both are stored on the invite and returned as of SparkPost/accusers-api#1294. Without them a pending `custom` invite shows a role and not the policies behind it, and a subaccount-scoped invite shows neither. --- content/api/users.apib | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/content/api/users.apib b/content/api/users.apib index f9f401f8..3dd370c1 100644 --- a/content/api/users.apib +++ b/content/api/users.apib @@ -54,7 +54,7 @@ Every user is scoped either to the account or to one or more subaccounts. An acc + first_name (string, nullable) - The user's first name. + last_name (string, nullable) - The user's last name. + email (string) - The user's email address. - + access_level (enum) - The user's role on the account. Absent for subaccount-scoped users. + + access_level (enum) - The user's [role](#header-roles) on the account. Absent for subaccount-scoped users. + `admin` + `developer` + `reporting` @@ -118,12 +118,14 @@ Returned by [List Pending Invites](#header-list-pending-invites). + id (string) - Unique ID for the pending invite. Use it to revoke the invite. + email (string) - The email address the invite was sent to. + from (string) - The email address of the user who created the invite. - + access_level (enum) - The role the user takes on when they register. + + access_level (enum) - The [role](#header-roles) the user takes on when they register. + `admin` + `developer` + `reporting` + `templates` + `custom` + + `access_policies` (array[string]) - The [access policies](#header-access-policies) the user takes on when they register, if `access_level` is `custom`. + + subaccounts (array[object]) - The subaccount access the user takes on when they register. See [Subaccount invite object](#header-subaccount-invite-object). Present instead of `access_level` for a subaccount-scoped invite. + expires (number) - Unix timestamp, in epoch seconds, at which the invitation expires. + Sample ``` @@ -169,7 +171,7 @@ The response contains the invite `id`, which you use to [list](#header-list-pend + Data Structure + email (string, required) - Email address of the person to invite. Maximum 512 characters. - + access_level (enum) - The primary-account role to assign. Required unless `subaccounts` is supplied. `admin` is not available to API keys. + + access_level (enum) - The primary-account [role](#header-roles) to assign. Required unless `subaccounts` is supplied. `admin` is not available to API keys. + `developer` + `reporting` + `templates` From d3483f333f67a5dfe04348d545630259c7eb53ca Mon Sep 17 00:00:00 2001 From: Daniel Sousa Date: Thu, 27 Aug 2026 20:37:09 -0300 Subject: [PATCH 13/13] Update users.apib --- content/api/users.apib | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/api/users.apib b/content/api/users.apib index 3dd370c1..4391a8df 100644 --- a/content/api/users.apib +++ b/content/api/users.apib @@ -568,7 +568,7 @@ The user must already have access to at least one subaccount. Calling this endpo ### Revoke Subaccount Access [DELETE /v1/users/{username}/subaccounts/{subaccountId}] -Removes a user's access to a subaccount. A subaccount-scoped user must keep at least one subaccount, so revoking the last one returns `400`. [Delete the user](#header-delete-a-user) instead. +Removes a user's access to a subaccount. + Parameters + username (required, string, `grace`) - The username of the user.