docs: add Users API reference page - #586
Open
danielfsousa wants to merge 22 commits into
Open
Conversation
rafaelmatsumotomb
approved these changes
Sep 2, 2026
| FORMAT: 1A | ||
| title: Users API | ||
| description: Manage the users on your SparkPost account. | ||
| label: New |
Contributor
There was a problem hiding this comment.
Just curious: how to control this? I've seen pages that are labeled as new for years
Contributor
Author
There was a problem hiding this comment.
just by adding or removing the label: New. I opened a PR to remove them from the other pages since they are not new anymore: #593
viniciusgiles
approved these changes
Sep 2, 2026
danielfsousa
force-pushed
the
feat/users-api-docs
branch
from
September 4, 2026 14:04
e1d1f52 to
64d8554
Compare
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.
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.
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.
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.
…counts 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.
Every caller this page describes is an API key, so "not available to API keys" named the only audience there is. The enum right beneath the sentence already omits `admin`; the sentence is there to explain the omission. Update a User's Role omits `admin` from the same enum and said nothing at all, so it gets the sentence too. The error-message examples keep saying "API keys", since they are verbatim strings the service returns.
The public gateway replaces every upstream 404 body with "Resource could not be found", so the service's own messages never reach an API key.
danielfsousa
force-pushed
the
feat/users-api-docs
branch
from
September 4, 2026 16:06
9cef821 to
b1e90db
Compare
Every other date in the public reference is an ISO 8601 string; the invite object was the one field in epoch seconds.
The reference marks optional data as present only when it applies; the name fields were the one place it promised a null.
With SAML on, the invite call creates the user outright and returns a username, so there is no pending invite to list or revoke. bypass_sso keeps a password login available for subaccount-scoped users.
danielfsousa
force-pushed
the
feat/users-api-docs
branch
from
September 4, 2026 16:07
b1e90db to
eb5c2cb
Compare
An SSO user never receives a verification email, so a reader of the field description would expect false. The API reports true because the identity provider asserts the address at every sign-in.
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.Preview
https://deploy-preview-586--developers-sparkpost.netlify.app/api/users/
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 changes with no application or API implementation in this repo.
Overview
Adds a new Users API reference (
users.apib) and registers it under the Accounts section in the API table of contents (after subaccounts).The page documents programmatic account user management: listing and retrieving users, invite-based creation with pending-invite list/revoke, role updates via
access_level/access_policies, deletion, and subaccount access grant/revoke. It also defines roles, custom access policies, invite lifecycle (3-day expiry), and SSO-specific invite behavior (bypass_sso, immediate user creation vs inviteid).Reviewed by Cursor Bugbot for commit 56462ee. Bugbot is set up for automated code reviews on this repo. Configure here.