Users

See Domain Model → Users & Organizations for the full field reference and lifecycle diagram. Signup, login, password, and MFA live on Auth.

  1. Endpoints
  2. The User object
  3. GET /v1/users
  4. POST /v1/users
  5. GET /v1/users/{id}
  6. PATCH /v1/users/{id}
  7. DELETE /v1/users/{id}
  8. POST /v1/users/{id}/suspend
  9. POST /v1/users/{id}/invitation
  10. GET /v1/users/{id}/export
  11. POST /v1/users/{id}/erasure-requests
  12. Errors specific to this resource

Endpoints

Method Path Requires Purpose
GET /v1/users users.list (or an app-confined users.list) List/search users.
POST /v1/users users.manage Create a user (invite, or direct creation).
GET /v1/users/{id} self, users.list, or app-confined users.list Fetch one user. {id} may be me. Soft-deleted users are visible to users.manage only.
PATCH /v1/users/{id} self (email only) or users.manage Update email; users.manage may also set status, including restoring a soft-deleted User.
DELETE /v1/users/{id} self or users.manage Soft-delete.
POST /v1/users/{id}/suspend users.manage Shortcut for PATCH {status: "suspended"}.
POST /v1/users/{id}/invitation users.manage Re-send the invitation email to an invited user.
GET /v1/users/{id}/export self or users.manage Everything this API holds about this User, as one bundle (GDPR Article 20 / CCPA right-to-know).
POST /v1/users/{id}/erasure-requests self or users.manage (including for an already soft-deleted User) Request right-to-erasure: soft-delete now, hard-delete cascade after 7 days.
DELETE /v1/users/{id}/erasure-requests/current users.manage Cancel a scheduled erasure inside its 7-day window.

Password, MFA, and session endpoints under /v1/users/{id}/… are specified on Auth.

The User object

{
  "id": "usr_01JAG3Z9X8QS3F6K2M4N5P6R7S",
  "email": "jordan@example.com",
  "email_verified": true,
  "pending_email": null,
  "status": "active",
  "mfa_enabled": false,
  "signup_application_id": "app_timetrack",
  "test_mode": false,
  "created_at": "2026-01-14T18:02:11Z",
  "updated_at": "2026-09-20T14:12:00Z",
  "last_login_at": "2026-10-02T09:41:03Z"
}
Field Notes
pending_email Set while a self-service email change awaits verification of the new address; email still holds the old one until then.
mfa_enabled true if any password identity has confirmed TOTP. Read-only here — see Auth → MFA.
signup_application_id Which Application the User signed up through, if any. Read-only, informational; grants nothing.
test_mode true if created by a satk_test_ key — see Conventions → Authentication.

GET /v1/users

GET /v1/users?status=active&email=jordan@example.com
// Response — 200
{
  "data": [ { "id": "usr_01JAG3Z9X8QS3F6K2M4N5P6R7S", "email": "jordan@example.com", "status": "active", "...": "..." } ],
  "page": { "next_cursor": null, "has_more": false }
}
Filter Matches
status active, invited, suspended, and, for users.manage callers only, deleted. Without an explicit status=deleted, deleted users are never listed; to everyone else they don’t exist (404).
email Exact, case-insensitive.
q Case-insensitive prefix match on email or Profile display_name, minimum 3 characters.
application_id Users holding any access path — personal Entitlement in any status, or membership in an Organization holding a grant — to that Application.
organization_id Members of that Organization.
created_after, created_before ISO 8601 bounds on created_at.

App-confined callers: an app-scoped API Key holding users.list gets exactly the application_id=<its own app> result, whatever it passes — the filter is forced, not optional.

POST /v1/users

// Request — invite (the default)
{ "email": "jordan@example.com", "display_name": "Jordan Alvarez" }
// Request — create active, no email sent
{ "email": "migrated.user@example.com", "status": "active", "send_invitation": false }
// Response — 201
{
  "id": "usr_01JAG3Z9X8QS3F6K2M4N5P6R7S",
  "email": "jordan@example.com",
  "email_verified": false,
  "pending_email": null,
  "status": "invited",
  "mfa_enabled": false,
  "signup_application_id": null,
  "test_mode": false,
  "created_at": "2026-10-03T12:00:00Z",
  "updated_at": "2026-10-03T12:00:00Z",
  "last_login_at": null
}

GET /v1/users/{id}

Self, users.list, or an app-confined key (only for users with an access path to its Application — anyone else 404s, so existence isn’t leaked). Returns the User object above.

A User’s Organization memberships aren’t a field here — see GET /v1/organizations (self-scoped) in Organizations.

PATCH /v1/users/{id}

// Request — self-service email change
{ "email": "jordan.alvarez@example.com" }
// Response — 200: email unchanged, pending_email set, verification email sent to the new address
{ "id": "usr_01JAG3Z9X8QS3F6K2M4N5P6R7S", "email": "jordan@example.com", "pending_email": "jordan.alvarez@example.com", "...": "..." }
  • Self may change email only. The change is pending until the new address is verified via POST /v1/auth/email/verify. A notice also goes to the old address. Writes user.email_changed when it completes.
  • users.manage may change email immediately (email_verified resets to false, a verification email is sent), and may change status:
Transition Effect Audit action
active → suspended Same as POST /suspend, below. user.suspended
suspended → active Login works again; access.granted fires for every Application the user still has active access to. user.reactivated
invited → active Activates without a password (see POST above). user.updated
deleted → active Restore. Undoes a soft-delete: login works again, and access.granted fires for every Application the user still has active access to. Rejected with 409 erasure_scheduled while an erasure request is scheduled (cancel it first), with 409 email_taken if a new account has taken the email meanwhile (change the email in the same request to proceed), and with 404 user_not_found once the erasure cascade has completed. user.reactivated
anything → deleted Rejected — use DELETE. —
→ invited Rejected. —

Rejected transitions return 409 invalid_status_transition. A self-service caller sending status gets 403 status_change_forbidden.

DELETE /v1/users/{id}

// Response — 204

Soft-delete: sets status: deleted and deleted_at, revokes every session, fires access.revoked (reason user_deleted) for every Application the user had active access to, and from then on GET /v1/users/{id} 404s for everyone but users.manage, who can still fetch, restore, or request erasure for the account. Self, or users.manage. Entitlements, Roles, Profile, and Settings are retained, not removed — hard deletion is the separate erasure process below. The email address becomes reusable by a new signup immediately. Writes user.deleted.

POST /v1/users/{id}/suspend

// Request
{ "reason": "Chargeback fraud investigation, ticket SUP-2231" }
// Response — 200, the User
{
  "id": "usr_01JAG3Z9X8QS3F6K2M4N5P6R7S",
  "email": "jordan@example.com",
  "email_verified": true,
  "pending_email": null,
  "status": "suspended",
  "mfa_enabled": false,
  "signup_application_id": "app_timetrack",
  "test_mode": false,
  "created_at": "2026-01-14T18:02:11Z",
  "updated_at": "2026-10-05T12:40:00Z",
  "last_login_at": "2026-10-02T09:41:03Z"
}

Requires users.manage. Suspending an already-suspended User returns 200 with no change and no new events. reason is optional but recorded on the Audit Event (user.suspended). Revokes every session and fires access.revoked (reason user_suspended) for every Application the user had active access to — the user’s Entitlements themselves are not changed, so reactivation restores exactly what was there. To cut off one specific app instead of the whole account, use Entitlements. Destructive — see MCP Server → Tool annotations.

POST /v1/users/{id}/invitation

// Response — 202

Requires users.manage. Issues a fresh invitation token (invalidating the previous one) and re-sends the email. 409 invalid_status_transition if the user isn’t invited.

GET /v1/users/{id}/export

Self, or users.manage. Limited to 5 calls per user per day.

// Response — 200
{
  "exported_at": "2026-10-05T12:00:00Z",
  "user": { "id": "usr_01JAG3Z9X8QS3F6K2M4N5P6R7S", "email": "jordan@example.com", "status": "active", "created_at": "2026-01-14T18:02:11Z" },
  "profile": { "display_name": "Jordan Alvarez", "contact_email": "jordan@example.com", "locale": "en-US" },
  "settings": { "theme": "dark", "timezone": "America/Denver", "notifications": { "email": true, "sms": false } },
  "identities": [
    { "method": "password", "mfa_enabled": true, "last_used_at": "2026-10-02T09:41:03Z" }
  ],
  "sessions": [
    { "id": "ses_01JAG8K4Q9R0S1T2V3V4W5X6Y8", "created_at": "2026-10-02T09:41:03Z", "ip_address": "203.0.113.24", "user_agent": "TimeTrack/2.4 (iOS 19.0)" }
  ],
  "organizations": [
    { "organization_id": "org_01JAFZ8Y7X6W5V4V3T2S1R0Q9P", "role": "member", "joined_at": "2026-09-20T14:00:00Z" }
  ],
  "roles": [
    { "role_id": "role_timetrack_admin", "application_id": "app_timetrack", "assigned_at": "2026-10-03T12:05:00Z" }
  ],
  "applications": [
    {
      "application_id": "app_timetrack",
      "entitlement": { "status": "active", "source": "purchase", "starts_at": "2026-01-14T18:05:00Z" },
      "app_profile": { "display_handle": "j.alvarez", "custom": { "department": "Engineering" } },
      "app_settings": { "overrides": { "week_start": "monday" } }
    }
  ],
  "audit_events": [
    { "action": "entitlement.granted", "application_id": "app_timetrack", "timestamp": "2026-01-14T18:05:00Z" }
  ]
}

Every section reads from a table the hard-delete cascade clears, with one deliberate exception: settings. Global Settings is exported because it’s keyed to the User, but erasure leaves it in place because it identifies no one once the rest is gone (see the cascade’s closing note). identities never includes password_hash or mfa_secret; audit_events is this User’s own trail as target_user_id (hot storage only — events already archived per Audit log lifecycle are available on request through support). The response is always complete and synchronous; there is no pagination. If a real account ever makes this too large to return in one response (practically, over 10 MB), the endpoint gains an asynchronous 202 + download-link mode as an additive change.

POST /v1/users/{id}/erasure-requests

The 7-day delay before the hard-delete cascade sits well inside GDPR’s 30-day ceiling, and gives support a window to cancel a request made in error or under account takeover.

// Request
{ "reason": "User request via support ticket SUP-4410" }
// Response — 202
{
  "user_id": "usr_01JAG3Z9X8QS3F6K2M4N5P6R7S",
  "status": "scheduled",
  "requested_at": "2026-10-05T12:00:00Z",
  "scheduled_for": "2026-10-12T12:00:00Z"
}

Self, or users.manage. Performs the soft-delete immediately (same effects as DELETE above; skipped if the User is already soft-deleted, which users.manage can still request erasure for), then runs the hard-delete cascade 7 days later — well inside GDPR’s 30-day ceiling, with a short window to catch a request made in error or by someone who took over the account. users.manage can cancel inside that window with DELETE /v1/users/{id}/erasure-requests/current (204; the user stays soft-deleted, and an admin can then restore them with PATCH status: active, per the transition table above). Writes user.erasure_requested; the cascade writes user.erased when it completes. Destructive.

Calling this for an already-scheduled user returns the existing request (202, same body) — it’s idempotent.

Errors specific to this resource

Code Status When
email_taken 409 email collides with an existing user on create or update.
user_not_found 404 {id} doesn’t resolve — including a soft-deleted user for any caller without users.manage, which 404s rather than returning a deleted status, to avoid leaking existence past deletion.
status_change_forbidden 403 A self-service caller’s PATCH attempts to change status.
invalid_status_transition 409 A status change not in the table above, or an invitation re-send for a non-invited user.
erasure_not_scheduled 404 Cancelling an erasure that isn’t pending.
erasure_scheduled 409 Restoring a soft-deleted User while their erasure request is still scheduled.
user_token_required 400 me used with an API Key.
validation_failed 422 Malformed email, or an unknown status value.

Back to top

Substratal Apps Platform API — living specification. This site is the system of record; see git history for how it has changed over time.

This site uses Just the Docs, a documentation theme for Jekyll.