Skip to content

docs: add Users API reference page - #586

Open
danielfsousa wants to merge 22 commits into
mainfrom
feat/users-api-docs
Open

docs: add Users API reference page#586
danielfsousa wants to merge 22 commits into
mainfrom
feat/users-api-docs

Conversation

@danielfsousa

@danielfsousa danielfsousa commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

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 in content/api/table-of-contents.json (after subaccounts.apib).

Everything in the .apib is 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 users
  • GET /api/v1/users/:username — retrieve a user
  • POST /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 invite
  • PUT /api/v1/users/:username — update a user
  • DELETE /api/v1/users/:username — delete a user
  • POST /api/v1/users/:username/subaccounts / DELETE /api/v1/users/:username/subaccounts/:subaccountId — manage subaccount mappings

The read-side GET /api/v1/users/:username/subaccounts endpoint was dropped per design review (accusers-api 9a1ca17ce); a user's subaccount access is documented instead as the subaccounts array embedded in user objects.

Also covered

  • Prerequisites / grants. A key needs one of two api_key-role grants, attached by an admin from the web app and never by another API key: Users: View for the three GET endpoints, or Users: Manage for the same reads plus every write. No account-option or support-enablement step is documented; see the GA rollout note under Reviewer notes.
  • Constraints. An API key can never assign 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.
  • Invite semantics and lifecycle. Creation is invite-based only. The response returns the invite id and 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).
  • Update is role-only. PUT accepts access_level and access_policies and rejects any other field with 403.
  • Subaccount mapping rules. POST .../subaccounts requires the target to already hold at least one mapping; DELETE .../subaccounts/:id refuses to remove the last one. Both 400s are documented.
  • Two-factor, email-verification, SCIM, and password endpoints are out of scope.

Verified against the code

Every endpoint, field, response shape, and error body on the page was checked against the feat/users-manage-programmatic branches of accusers-api and access, including the uncommitted lib/errors.js refactor that sets the current userMessage strings.

Checked: route registrations (resources/user-endpoints.js), the grant definitions and labels (@sparkpost/access lib/token-access.js), the response shapes (lib/models/users.js formatUser and getUserByUsername, resources/user-controller.js lookUpUser), 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

  • Invite no longer returns the token. accusers-api 086008f1f stopped returning it from POST /users/invite for all callers. The earlier revision of this page documented token in the response and carried a "treat the token as a credential" banner. Both are gone.
  • API keys cannot assign admin. The programmatic cap in lib/access/role-ceiling.js and lib/validator/edit-user-validator.js rejects admin on invite, create, and PUT, even for an admin-owned key. The earlier revision said an admin-owned key could assign up to admin. Now its own constraint, and admin is dropped from the invite and update request enums.
  • Dropped the key-revocation constraint. Reverted in 5b1dbdee5: API keys are account-owned and get reassigned to another admin on owner deletion, not revoked.
  • Documented the two subaccount-mapping 400s (has-account-subaccount-access, last-subaccount-check), neither of which was on the page.
  • Real error bodies. Placeholder "Forbidden" bodies replaced with the userMessage strings the service sends. The Retrieve 404 is "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.
  • last_login documented on the List Users object (ISO string or null, per lib/validator/auth0-post-login-action.js). This closes the open question from the earlier revision.
  • access_policies on PUT is optional when access_level is custom, not required (areValidPolicies only forbids it for non-custom roles).

Reviewer notes (not in public docs)

  • GA rollout / no option-gate wording. The page is slated to publish at GA, after the account-option gate is removed, so it omits any "contact support to enable" step. Both grants still carry option: 'allow_user_management_via_api' in the access branch; that gate is expected to be gone by publish time.
  • Invite rate-limit internals. The 429 comes from inviteThrottle (config.emailThrottling.invite): maxPerRecipient 3, maxPerRequester 20, over a 3600s window. The public page omits the numbers.
  • Invite expiry source. The documented 3-day expiry is config.emailInvite.ttl = 259200s. Expired invites are filtered out in invite-dynamo.getInvites and reaped by DynamoDB TTL.
  • List vs Retrieve response shapes. These genuinely differ, and the page documents both as separate objects rather than papering over it.
    • List item (formatUser): name, username, access, access_policies, email, is_sso, email_verified, tfa_enabled, last_login, subaccount_id, options (only when set). Mapped users also get subaccounts: [{subaccount_id, access_level, subaccount_name}], with no status.
    • Retrieve (getUserByUsername + lookUpUser): first_name/last_name instead of name, access_level instead of access (omitted for subaccount-scoped users), plus created, updated, and an always-present subaccounts array ({subaccount_id, subaccount_name, access_level, status}). Top-level access_level and subaccount_id drop out when subaccounts is non-empty.
    • The list response also attaches a link object per user, and auth_migrated. Neither is documented.

⚠️ One open item for the implementation

lookUpUser applies no grant-scoped field filtering. A programmatic GET /users/:username currently returns customer, cookie_consent, tou, tou_auto_accept, creation_params, auth_migrated, auth_connection, tokens (API-key IDs, not secrets), and customer_id inside each subaccounts entry. 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.

⚠️ Do not merge/publish until the feature ships

This documents an unreleased feature. Hold until the implementation ships:

  • SparkPost/access#124 (grants, @sparkpost/access 4.13.0)
  • SparkPost/accusers-api#1294 (endpoint hardening)
  • SparkPost/auth-api#319 (grant enforcement)

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 invite id).

Reviewed by Cursor Bugbot for commit 56462ee. Bugbot is set up for automated code reviews on this repo. Configure here.

@danielfsousa danielfsousa self-assigned this Jul 31, 2026
@danielfsousa
danielfsousa marked this pull request as ready for review August 1, 2026 00:00
Comment thread content/api/users.apib
FORMAT: 1A
title: Users API
description: Manage the users on your SparkPost account.
label: New

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curious: how to control this? I've seen pages that are labeled as new for years

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

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.
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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Document the Users API (User Management via API Keys)

3 participants