docs: add Users API reference page - #586
Open
danielfsousa wants to merge 8 commits into
Open
Conversation
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 #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
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
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
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
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
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.
danielfsousa
marked this pull request as ready for review
August 1, 2026 00:00
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a public API reference page for the Users API: the endpoints reachable by the
api_key-role user-management grants that let an API key manage the users on an account.New page
content/api/users.apib, registered under the Accounts category incontent/api/table-of-contents.json(aftersubaccounts.apib).Everything in the
.apibis public. Implementation-internal details are kept out of the page and captured in Reviewer notes below instead.Endpoints documented
GET /api/v1/users— list usersGET /api/v1/users/:username— retrieve a userPOST /api/v1/users/invite— invite a user (creation is invite-based only)GET /api/v1/users/pending-invites— list pending invites (canonical path)DELETE /api/v1/users/pending-invites/:id— revoke a pending invitePUT /api/v1/users/:username— update a userDELETE /api/v1/users/:username— delete a userPOST /api/v1/users/:username/subaccounts/DELETE /api/v1/users/:username/subaccounts/:subaccountId— manage subaccount mappingsThe read-side
GET /api/v1/users/:username/subaccountsendpoint was dropped per design review (accusers-api9a1ca17ce); a user's subaccount access is documented instead as thesubaccountsarray embedded in user objects.Also covered
api_key-role grants, attached by an admin from the web app and never by another API key:Users: Viewfor the threeGETendpoints, orUsers: Managefor the same reads plus every write. No account-option or support-enablement step is documented; see the GA rollout note under Reviewer notes.admin, even when an admin owns it. Below admin, the role ceiling still applies. The last admin cannot be deleted or demoted. A key cannot delete its own owner.idand nothing else: the registration token is never returned to any caller. Invitations expire after 3 days, expired invites drop off the pending list automatically, there is no resend, and invite creation is rate limited (429).PUTacceptsaccess_levelandaccess_policiesand rejects any other field with403.POST .../subaccountsrequires the target to already hold at least one mapping;DELETE .../subaccounts/:idrefuses to remove the last one. Both400s are documented.Verified against the code
Every endpoint, field, response shape, and error body on the page was checked against the
feat/users-manage-programmaticbranches of accusers-api and access, including the uncommittedlib/errors.jsrefactor that sets the currentuserMessagestrings.Checked: route registrations (
resources/user-endpoints.js), the grant definitions and labels (@sparkpost/accesslib/token-access.js), the response shapes (lib/models/users.jsformatUserandgetUserByUsername,resources/user-controller.jslookUpUser), the invite listing fields (lib/models/helpers/invite-dynamo.js), and every validator behind the documented status codes.Corrections made in this PR after re-verification
086008f1fstopped returning it fromPOST /users/invitefor all callers. The earlier revision of this page documentedtokenin the response and carried a "treat the token as a credential" banner. Both are gone.admin. The programmatic cap inlib/access/role-ceiling.jsandlib/validator/edit-user-validator.jsrejects admin on invite, create, andPUT, even for an admin-owned key. The earlier revision said an admin-owned key could assign up toadmin. Now its own constraint, andadminis dropped from the invite and update request enums.5b1dbdee5: API keys are account-owned and get reassigned to another admin on owner deletion, not revoked.400s (has-account-subaccount-access,last-subaccount-check), neither of which was on the page."Forbidden"bodies replaced with theuserMessagestrings the service sends. The Retrieve 404 is"User not found."(userNotFoundOptions), not the delete validator's"User does not exist".accessis omitted, not empty, for subaccount-scoped users: a subaccounts-only invite never setsaccess_levelon the user record.last_logindocumented on the List Users object (ISO string ornull, perlib/validator/auth0-post-login-action.js). This closes the open question from the earlier revision.access_policiesonPUTis optional whenaccess_leveliscustom, not required (areValidPoliciesonly forbids it for non-custom roles).Reviewer notes (not in public docs)
option: 'allow_user_management_via_api'in the access branch; that gate is expected to be gone by publish time.429comes frominviteThrottle(config.emailThrottling.invite):maxPerRecipient3,maxPerRequester20, over a 3600s window. The public page omits the numbers.config.emailInvite.ttl= 259200s. Expired invites are filtered out ininvite-dynamo.getInvitesand reaped by DynamoDB TTL.formatUser):name,username,access,access_policies,email,is_sso,email_verified,tfa_enabled,last_login,subaccount_id,options(only when set). Mapped users also getsubaccounts: [{subaccount_id, access_level, subaccount_name}], with nostatus.getUserByUsername+lookUpUser):first_name/last_nameinstead ofname,access_levelinstead ofaccess(omitted for subaccount-scoped users), pluscreated,updated, and an always-presentsubaccountsarray ({subaccount_id, subaccount_name, access_level, status}). Top-levelaccess_levelandsubaccount_iddrop out whensubaccountsis non-empty.linkobject per user, andauth_migrated. Neither is documented.lookUpUserapplies no grant-scoped field filtering. A programmaticGET /users/:usernamecurrently returnscustomer,cookie_consent,tou,tou_auto_accept,creation_params,auth_migrated,auth_connection,tokens(API-key IDs, not secrets), andcustomer_idinside eachsubaccountsentry. The page deliberately documents only the supported subset, so publishing as-is is fine, but someone should decide whether the programmatic response ought to be trimmed before GA.This documents an unreleased feature. Hold until the implementation ships:
@sparkpost/access4.13.0)Closes #584
Part of SparkPost/access#121
Note
Low Risk
Documentation-only change with no runtime or security logic; risk is limited to accuracy relative to the unreleased Users API implementation.
Overview
Adds a new Users API reference (
users.apib) under the Accounts section intable-of-contents.json(aftersubaccounts.apib).The page documents programmatic user management via
Users: ViewandUsers: ManageAPI key grants: list/retrieve users, invite-only onboarding (no password create), pending invites, role-onlyPUT, delete, and subaccount mapping add/remove. It spells out constraints (noadminvia API, role ceiling, last-admin protection, no self-delete), invite lifecycle (3-day expiry, no token in responses, rate limits), and the intentional List vs Retrieve response shape differences. SCIM, 2FA, and password flows are explicitly out of scope.Reviewed by Cursor Bugbot for commit fb3c200. Bugbot is set up for automated code reviews on this repo. Configure here.