Authentication & User Management Authentication methods, user CRUD, getting started patterns
Authentication Methods
All authenticated endpoints require: Authorization: Bearer {token}
The token can be a JWT (from Basic Auth or OAuth), an API key, or a 2FA-upgraded JWT.
Method 1: Basic Auth to JWT
Send HTTP Basic Auth (email:password) to get a JWT.
/current/user/auth/
Authorization: Basic {base64(email:password)}
Returns auth_token (JWT). If the account has 2FA enabled, the returned token has limited scope until 2FA verification is completed.
Optional revocable=true — pass to mint a session-bound JWT that can be invalidated server-side via POST /current/user/auth/sign-out/. Recommended for browser sessions where users expect a logout button to terminate access. JWTs minted without revocable (the default) are stateless and sign-out has no effect on them — but ALL login tokens (with or without revocable) can still be killed via POST /current/user/auth/invalidate-all/, which terminates every login session for the user. See the session-termination endpoints (sign-out / invalidate-all) below.
Optional x-ve-session-cookie request header — browser clients only. Send x-ve-session-cookie: 1 (or true/yes — the same vocabulary as revocable) to ask the server to ALSO deliver the issued token in an HttpOnly, Secure, SameSite=Lax cookie scoped to the site's registrable domain (e.g. fast.io), so it is available across subdomains on that domain and the session never has to be stored anywhere JavaScript can read it between page loads. The token is still returned in the response body as usual. The browser then calls POST /current/user/auth/bootstrap/ on each page load — the only endpoint that reads the cookie — which returns that same token so the app can bring it into memory. The opt-in is only honoured as a request header; it is deliberately not accepted as a URL or body parameter. Omit the header (the default) and no cookie is set. On a 2FA-enabled account, sign-in returns a pre-2FA token and NO cookie; the cookie is issued by POST /current/user/auth/2factor/auth/{token}/ instead.
Method 2: API Keys
Long-lived tokens for service-to-service communication. Created via the API or the web UI. Used with the same Authorization: Bearer {api_key} header format as JWTs. Keys optionally support scoped permissions (scopes), agent names (agent_name), and expiration (expires). Scoped keys are enforced using the same scope system as v2.0 JWT tokens. A key created or updated without scopes now stores the explicit ["user:*:rw"] — whole-account read and write, no administration and no account settings.
Scope formats: OAuth authorization requests accept named scope strings (user, org, workspace, all_orgs, all_workspaces, all_shares, all_sign_envelopes). API responses return scopes as arrays of entity_type:entity_id:access_mode strings (e.g., ["org:12345:rw"]) — exactly three colon-separated parts. The entity types are user, org, workspace, share, sign_envelope, fileshare, memory and userdetails; the entity id is * (wildcard) or a numeric id. See the OAuth 2.0 reference for full scope format details.
Access modes. The third part of a scope string is one of three modes:
| Mode | Grants |
|---|---|
r | Read |
rw | Read and write |
rwa | Read, write and administer |
rwa implies rw, which implies r. There is no ra — administration always includes write. rwa is accepted on the ordinary entity types (user, org, workspace, share, sign_envelope) but not on fileshare, which has no administrative verb: fileshare:{id}:rwa and any fileshare:* scope are refused when a credential is issued. userdetails is narrower still — the only issuable form is the exact userdetails:*:rw, and userdetails:*:r, userdetails:*:rwa and any numeric id are refused at grant time.
Administration is always capped by the human. rwa never grants more than the person who owns the credential currently holds — it is re-checked on every request against their live role on the entity. If they lose administrative rights on an org or workspace, a credential carrying rwa for it stops administering it.
The user:* grants:
| Scope | Meaning |
|---|---|
user:*:r | Whole account, read-only — reads every entity the human can read, and writes nothing. Entity-anchored writes are refused by the entity's own scope check; account-anchored ones (creating an org, updating your account, revoking an OAuth session or all of them, minting or editing an API key) are refused with 403 and 10770, error.params.reason scope_write_required. There is no exempt route — sign-out included, so a read-only client ends its session by discarding the credential locally, or by calling POST /current/oauth/revoke/. Login, the 2FA login challenge and the OAuth token exchange are unaffected, but only because none of them runs on a scoped bearer in the first place |
user:*:rw | Whole account, read and write — not administration, not account settings |
user:*:rwa | Whole account, read, write and administer (still capped by the human's live role) |
A user:* grant matches every ordinary entity type. It does not match userdetails, which is explicit-only.
The userdetails entity type. userdetails:*:rw is the only valid form — userdetails:*:r, userdetails:*:rwa and any numeric id are refused when a credential is issued. It gates exactly four operations:
POST /current/user/update/— changingpasswordoremail_addressonlyPOST /current/user/auth/2factor/{channel}/— enrolling in 2FAPOST /current/user/auth/2factor/verify/{token}/— verifying 2FA enrolmentPOST /current/user/auth/invalidate-all/— invalidating every session on the account
No user:* grant, at any access mode, satisfies it: it must be held explicitly, or the caller must be an interactive browser login session. It is an entity type inside a credential's scopes list, not a scope type in the OAuth authorization flow, and it is never advertised in scopes_supported.
Credentials with no scopes (“legacy”). A credential is legacy when it declares no scopes claim at all: an API key created before scoped keys existed, an OAuth session minted before scoped tokens existed, and every browser login session. Legacy is therefore not a synonym for “old API key”.
- A legacy API key or legacy OAuth grant behaves as
user:*:rw— whole-account read and write, no administration and no account settings. It does not retain unrestricted access. - A browser login session is different: it is unbounded — admin-capable, and it passes the account-settings gate, because it is the human acting interactively.
Newly issued credentials are never legacy: a key created or updated without scopes stores the explicit ["user:*:rw"], and an OAuth grant for scope=user is stored the same way.
Containment — no credential may mint something broader than itself. A credential may only create or edit another credential whose scopes it already covers. Covering compares access-mode rank (rwa ≥ rw ≥ r), and a wildcard id covers any id of that type while a numeric id covers only itself: org:*:rw covers org:123:r, but org:123:rw does not cover org:*:r. There is no hierarchy walk — org:1:rwa does not cover workspace:5:rw. userdetails:*:rw must be held exactly to be propagated, and an empty scope set is refused everywhere it can be submitted. A browser login session is unbounded and may mint anything the human may grant.
Method 3: OAuth 2.0 PKCE
For desktop/mobile apps and MCP-connected agents. No password passes through the agent. Access tokens last 1 hour; refresh tokens are long-lived. S256 challenge method only. See the OAuth 2.0 reference for the full flow.
Method 4: 2FA
When 2FA is enabled on an account, Basic Auth returns a limited-scope JWT. Complete authentication via POST /current/user/auth/2factor/auth/{token}/ with the 2FA code. The response contains a full-scope JWT.
What the access modes mean for existing credentials
- An existing read-write API key or OAuth grant keeps reading and writing, but no longer performs administrative operations — org, workspace and share administration, including administrative reads such as org billing details, invoices, usage, credits, plan preview and payment method, and the events audit log — and no longer changes account settings (password, email address, 2FA enrolment, invalidate-all).
- To restore administration, update the key with
rwascopes, or reconnect the application asking foraccess_mode=rwa. - To restore account settings, add
userdetails:*:rwto the key, or reconnect the application withaccount_settings=1. Widening a credential can only be done from a signed-in web session — a credential cannot widen itself. user:*:ris now a real whole-account read-only grant: it returns full read results where it previously returned empty lists on some endpoints.- Clients validating
access_modes_supportedagainst{r, rw}must acceptrwa.
Getting Started
Option 1: Use a Human's Existing Account (API Key)
A human creates an API key and gives it to you. You operate as that user with their permissions, org, and billing.
Human instructions: "Go to Settings > Devices & Agents > API Keys and click Create API Key. Optionally enter a memo to label the key (e.g., 'Agent access'), then click Create. Copy the key immediately -- it is only displayed once. Direct link: https://go.fast.io/settings/api-keys"
Once you have the key: Authorization: Bearer {api_key}. No further steps needed.
Option 2: Create Your Own Agent Account (Autonomous)
Create your own account to work independently. Agent accounts are ordinary Fastio accounts tagged account_type=agent — they require an email address and follow the same signup, organization, and paid-plan flow as everyone else.
POST /current/user/withemail_address,password,tos_agree=true,agent=trueGET /current/user/auth/with Basic Auth to get JWT- Verify email:
POST /current/user/email/validate/withemail— sends verification codePOST /current/user/email/validate/withemailandemail_token— validates the code
POST /current/org/create/withdomain(required, 2-63 chars lowercase alphanumeric + hyphens)- Select a paid plan to activate the org via
POST /current/org/{org_id}/billing/withbilling_plan(e.g.solo_monthly) — a new organization must choose a paid plan (Starter, Business, Growth, or Enterprise) before it can be used (see the Organizations reference) POST /current/org/{org_id}/create/workspace/withfolder_name,name,perm_join,perm_member_manage
New organizations choose a paid plan (Starter, Business, Growth, or Enterprise) to get started; until a paid plan is selected the org is in an upgrade-only state (the same state as an org that has exhausted its credits).
Option 3: Agent Account Invited to a Human's Org
- Create an agent account (steps 1-2 from Option 2)
- Give the human your agent's email address
- Human invites agent to their org or workspace
- Accept:
POST /current/org/{org_id}/members/join/orPOST /current/workspace/{workspace_id}/members/join/ - You now operate within their resources with granted permissions
Option 4: PKCE Browser Login (No Password Sharing)
Most secure option. Works with SSO. No credentials pass through the agent.
- Agent initiates PKCE flow via
POST /current/oauth/authorize/withcode_challenge,code_challenge_method=S256,client_id,redirect_uri,response_type=code - User opens the returned URL in browser, signs in, approves access
- Browser displays authorization code — user copies it to agent
- Agent calls
POST /current/oauth/token/withgrant_type=authorization_code,code,code_verifier - Access tokens last 1 hour; refresh via
POST /current/oauth/token/withgrant_type=refresh_token
Which option to choose
- Human wants you to manage their account → Option 1 (API key)
- You're building something independently → Option 2 (agent account + own org)
- You need to work within a human's existing org → Option 3 (agent account + invitation)
- Human wants to authorize agent without sharing credentials → Option 4 (PKCE)
Compact Responses (output=)
Every endpoint that returns user objects — including your own profile (/current/user/details/), other users' profiles, and member listings on workspaces, orgs, and shares — accepts an optional output query parameter that selects the response shape. A single detail-level token may be combined with modifier tokens; specifying two detail levels (e.g. ?output=terse,standard) returns HTTP 406. When output= is omitted, responses are full and byte-for-byte unchanged.
| Level | Fields returned on each user (cumulative) |
|---|---|
terse | id, account_type, first_name, last_name, profile_pic |
standard | terse + email_address, is_anonymous, status, permissions, created, member_added_at (membership responses only), updated, invite, expires, locked, suspended, closed (last three visible to self/managers only) |
full | standard + country_code, phone_country, phone_number, 2factor, notify, sync_profile, tos_agree, valid_email, valid_phone, apps, owner_defined, parents |
Use terse for mention pickers, avatar lists, creator cells, and message-author headers — it carries the identifier, display name, account type (human/agent), and profile picture, which is everything the avatar/name cells render. email_address is intentionally excluded from terse to keep PII out of the smallest shape. Use standard for member list views and account-settings summaries — it adds email_address, the caller-relative permissions role, active/pending status, invitation details for pending members, and account created/updated timestamps (now visible at standard for every user the caller can see, not just self/managers). Membership responses — the member-detail endpoint for an org, workspace, or share — also carry member_added_at at standard: the date the membership was created, distinct from created, which is the date the user's own account was created. Fastio returns it to the member themselves and to admins (and owners) of the containing org, workspace, or share; for any other caller the key is absent from the response rather than null, and it is omitted for every caller when the membership has no recorded date. It is formatted like every other response timestamp, e.g. 2026-08-26 14:03:11 UTC. Admin member-list UIs also receive the lock/suspend/close account-status chips at standard; these three fields are gated server-side to the self-view or manager-view of the target user, so non-privileged callers never see them at any tier. Use full (or omit the parameter) for the user profile screen, account settings, admin audits, and any workflow that reads phone, 2FA, TOS, anonymous-guest detection, or account-validity fields. Unknown tokens are silently ignored. Add the markdown modifier (e.g. ?output=standard,markdown) to receive the response as GitHub-flavored Markdown (Content-Type: text/markdown; charset=UTF-8) instead of JSON — see the cross-cutting ?output= reference for the full contract.
User Creation
POST /current/user/
Create a new user account.
Auth: None (IP-throttled)
Request Parameters
| Parameter | Type | Required | Constraints | Description |
|---|---|---|---|---|
| email_address | string | Yes | Valid email format; domain must accept email; must be unique | User's email address. Tags (e.g., +tag) are stripped for storage and uniqueness checks, but the original is preserved. |
| password | string | Yes | Must pass password validity checks | Account password. |
| tos_agree | string | Yes | Must be "true" |
Must be "true" to accept Terms of Service. |
| agent | string | No | "true" or "false" |
Set "true" for AI agent accounts. Sets account_type to "agent" permanently for identification. It does not grant a different or free plan — agent accounts follow the same signup, organization, and paid-plan flow as everyone else. |
| first_name | string | No | 2–45 characters; refused if it contains a URL or scheme: prefix, a www. or domain-like token (example.com), an IP address, or </> |
User's given/first name. |
| last_name | string | No | 2–45 characters; refused if it contains a URL or scheme: prefix, a www. or domain-like token (example.com), an IP address, or </> |
User's family/last name. |
| phone_country | string | No | Numeric country calling code | Phone country code. Required if phone_number is provided. |
| phone_number | string | No | Numeric phone number | Phone number. Required if phone_country is provided. |
Request Example
curl -X POST "https://api.fast.io/current/user/" \
-d "email_address=jane.doe@example.com" \
-d "password=$PASSWORD" \
-d "tos_agree=true" \
-d "first_name=Jane" \
-d "last_name=Doe" \
-d "agent=true"
Success Response (200 OK)
{
"result": true
}
Response Fields
| Field | Type | Description |
|---|---|---|
| result | boolean | true on success |
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
10025 | 406 | "An invalid email was supplied." | Email format invalid |
10025 | 406 | "The email domain is invalid or cannot receive email." | Email domain validation failed |
162057 | 406 | "Accounts cannot be registered with this email domain." | The email domain, or a parent of it, is reserved and cannot be used to register an account |
10026 | 406 | "An invalid password was supplied." | Password does not meet requirements |
10394 | 406 | "An invalid tos_agree value was create." | TOS value not a valid boolean string |
10395 | 406 | "You declined to accept the terms of service." | TOS set to "false" |
10027 | 406 | "An invalid first name was supplied to create." | First name fails validation |
10027 | 406 | "An invalid last name was supplied to create." | Last name fails validation |
10163 | 406 | "An invalid phone country code was supplied." | Invalid phone country code |
10029 | 406 | "An invalid phone number was supplied." | Invalid phone number |
10165 | 406 | "An invalid phone number or country code was supplied." | Full phone number validation failed |
10354 | 401 | "Your attempt to create an account was not accepted." | Risk/fraud check failed |
10032 | 500 | "We were unable to create your user account..." | Internal processing failure |
Notes
- Account enumeration is intentionally not possible. Signing up with an email that already has an account does NOT return an "already in use" error — it returns the SAME success response as a brand-new signup and emails the existing account so the owner can sign in / reset their password. A caller cannot use signup to tell whether an email is registered, and no duplicate account is created.
- Email addresses are normalized by stripping tag extensions (e.g.,
user+tag@example.combecomesuser@example.com) for storage and uniqueness lookup; the original email is preserved separately. - Country code is detected from the client IP and stored automatically.
agent=trueis permanent and cannot be changed after account creation.- An email address is required for every account, agent or human. The
agent=truetag is for identification only and does not grant a different or free plan.
User Management Endpoints
POST /current/user/update/
Update the current authenticated user's profile information.
Auth: Required (JWT)
Request Parameters
All fields are optional. Only provided fields are updated.
| Parameter | Type | Required | Constraints | Description |
|---|---|---|---|---|
| email_address | string | No | Valid email format; unique; domain must accept email | New email address. You MUST also send current_password. An account with no password yet (password_set: false, SSO-only) is refused with 10766 — set a password through the email reset flow first. Does not take effect immediately: a confirmation link is emailed to the new address and the change applies only after it is confirmed via /current/user/email/change/. Your current email stays active and verified until then. |
| password | string | No | Must pass validity checks; POST-only | New password for an account that already has one — you MUST also send current_password. An account with no password yet (password_set: false on GET /current/user/details/, i.e. SSO-only) cannot set its first password here: the request is refused with 10766 and the first password is set through the email reset flow (POST /current/user/email/reset/ then POST /current/user/password/{code}/). Must be sent in the POST body — a copy in the query string is rejected, not ignored. |
| current_password | string | Conditional | Must match the account's current password; POST-only | Required to change the password or email_address of an account that already has a password. POST body only, never the query string. |
| first_name | string | No | 2–45 characters; refused if it contains a URL or scheme: prefix, a www. or domain-like token (example.com), an IP address, or </> |
Updated given/first name. |
| last_name | string | No | 2–45 characters; refused if it contains a URL or scheme: prefix, a www. or domain-like token (example.com), an IP address, or </> |
Updated family/last name. |
| phone_country | string | No | Numeric country code; 2FA must be disabled first | Updated phone country code. Pass "null" or empty to clear. |
| phone_number | string | No | Numeric phone number; 2FA must be disabled first | Updated phone number. Pass "null" or empty to clear. |
| owner_defined | string (JSON) | No | Must be valid JSON if provided | Custom owner-defined properties. Pass null or empty to clear. |
Request Example
curl -X POST "https://api.fast.io/current/user/update/" \
-H "Authorization: Bearer {jwt_token}" \
-d "first_name=Jane" \
-d "last_name=Smith"
Success Response (200 OK)
{
"result": true,
"sessions_invalidated": true,
"auth_token": "{jwt_token}"
}
Both extra fields are optional and appear only when the password was changed:
| Field | When present | Meaning |
|---|---|---|
| sessions_invalidated | Always, when the password changed | Other sessions on this account have been signed out. Your own credential may be among them — see auth_token. |
| auth_token | When the password changed and you authenticated with a sign-in session token | A replacement session token. Your previous one is no longer valid; use this for subsequent requests. |
| email_send_failed | A combined password + email_address change whose confirmation email could not be sent | The password change still succeeded; the email change did not start. |
If sessions_invalidated is true but no auth_token is
returned, you authenticated with a credential that was not invalidated (an API key
or an OAuth access token), so no replacement is needed and you can keep using it. Do not treat a
missing auth_token as a failure.
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
10025 | 406 | "An invalid email was supplied to update." | Invalid email format |
10025 | 406 | "The email domain is invalid or cannot receive email." | Invalid email domain |
10025 | 409 | "The email you specified is not available." | Email already in use |
20544 | 500 | "We could not send the confirmation email; please try again." | The email-change confirmation could not be sent, so the email change was not started; any other fields in the same request were still applied |
10164 | 406 | "You must disable 2-Factor before updating your phone." | 2FA enabled when trying to change phone |
10026 | 406 | "An invalid password was supplied to update." | Invalid password |
10026 | 406 | "The password must be sent in the POST body, not the query string." | password was supplied as a query parameter |
10770 | 403 | "Your credential is read-only and is not authorized to make changes." | The calling credential holds no write-capable scope anywhere — user:*:r, or a set every entry of which is :r. error.params.reason is scope_write_required |
10769 | 403 | "This operation changes account credentials and requires the "userdetails:*:rw" scope." | password or a changed email_address sent with a credential that does not explicitly hold userdetails:*:rw (see the note below). error.params.reason is userdetails_scope_required |
10766 | 406 | "This account has no password yet. Set the first password through the email reset flow: request a code with POST /current/user/email/reset/ and complete it with POST /current/user/password/{code}/." | password sent for an account that has no password (SSO-only) — the first password is set through the email reset flow, never through a signed-in session |
10766 | 406 | "This account has no password yet. Set a password through the email reset flow first (POST /current/user/email/reset/, then POST /current/user/password/{code}/), then change the email with current_password." | A changed email_address sent for an account that has no password (SSO-only) — set a password first, then change the email |
10759 | 403 | "Your current password is required and must be correct to change your password or email." | Changing the password or email of a password-having account without a valid current_password |
10027 | 406 | "An invalid first name was supplied to update." | Invalid first name |
10027 | 406 | "An invalid last name was supplied to update." | Invalid last name |
10731 | 406 | "Owner-defined properties must be valid JSON." | Invalid JSON in owner_defined |
10354 | 406 | "Your request was not accepted." | The new email address failed a risk check. The message is deliberately non-specific and there is nothing to correct in the request itself — this is the same code as the signup-time risk rejection, in a different context |
Notes
- Order of the credential checks when
passwordor a changedemail_addressis sent: the credential-breadth gate runs first (10769), then a passwordless account is refused (10766), then thecurrent_passwordproof (10759). Forpassword, validation of the value (10026) runs before the gates; foremail_address, an unchanged address is a no-op that runs none of them, and a changed address passes the gates before it is validated (10025). - Changing the password or the email requires
userdetails:*:rw. Both are account settings, so a request carryingpasswordor a changedemail_addressis refused with403and10769unless the credential explicitly holdsuserdetails:*:rw, or the caller is a browser login session (which passes).user:*:rw,user:*:rwa, every entity-scoped key and every legacy (unscoped) key are all refused — a whole-account grant does not confer account settings. The same bar applies toPOST /current/user/auth/2factor/{channel}/,POST /current/user/auth/2factor/verify/{token}/andPOST /current/user/auth/invalidate-all/. This covers setting the initial password on an SSO-only account too. The other fields on this endpoint (names, phone,owner_defined) are not gated. - A third kind of credential also passes the 2FA enrolment gates above: an enrolment token. When an org's Require-2FA policy forces enrolment at login (
enrol_required: true— see Interactive Login & Enrolment), the resulting token is narrower thanuserdetails:*:rwin every other way but is admitted specifically onPOST /current/user/auth/2factor/{channel}/andPOST /current/user/auth/2factor/verify/{token}/, plus the three2factor/send/*code-delivery endpoints. It is refused everywhere else, includingGET/DELETE /current/user/auth/2factor/,POST /current/user/auth/2factor/auth/{token}/,POST /current/user/update/, andGET /current/user/auth/check/. - Changing an existing password OR email requires the current password. If the account already has a password, a
passwordchange or anemail_addresschange must include a validcurrent_password(POST-only) or it is rejected with1700/403/10759— this prevents a session-only attacker (e.g. a stolen JWT) from overwriting the password or hijacking the email. Use thepassword_setfield onGET /current/user/details/to tell which case applies. - An account with no password (SSO-only) sets its FIRST password through the email reset flow, not here. A signed-in session alone must not be able to mint a durable credential for the account, so
passwordon a passwordless account is refused with10766. CallPOST /current/user/email/reset/with the account's email, open the emailed link, and completePOST /current/user/password/{code}/— that proves ownership of the email and signs every session out (including the current one), after which the user signs in with email and password. The same applies toemail_address: a passwordless account cannot change its email on a bare session (the new address would only have to be confirmed by whoever supplied it) — set a password first, then change the email withcurrent_password. - Changing the email address starts a confirmation flow rather than changing it immediately: a confirmation link is sent to the new address (and a notification to the current address), and the change applies only after the link is confirmed via
/current/user/email/change/. The current email remains active and verified until then. When a change is pending, the response includes"email_change_pending": true. - Changing the password signs out the account's other browsers and devices.
Existing sign-in sessions stop working on their next request and must sign in again with the new
password. The session that made this request is kept alive by the
auth_tokenreturned above — store it and use it in place of the token you sent, or your next call will be rejected too. - AI assistant sessions are NOT signed out. They are short-lived and delegated, so a routine password change leaves an in-progress assistant session running rather than interrupting it mid-task.
- Not affected: OAuth-connected applications, API keys, e-signature links, guest share access, file-share links and open realtime connections. These are revoked separately — remove the connected application or delete the API key.
- Sessions created before account-wide revocation was introduced do not carry the markers this check relies on, and are therefore refused outright: any such sign-in session is already signed out and must sign in again. Current sign-in sessions, OAuth tokens, API keys and the other credentials listed above are not affected.
- Phone number changes require 2FA to be disabled first.
- If no fields have changed, the endpoint returns success and no update is performed.
POST /current/user/close/
Close (soft-delete) the current user's account.
Auth: Required (JWT)
Request Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| email_address | string | Yes | Must match the user's current email address (confirmation). |
| dryrun | string | No | If truthy, checks eligibility without closing the account. |
Request Example
curl -X POST "https://api.fast.io/current/user/close/" \
-H "Authorization: Bearer {jwt_token}" \
-d "email_address=jane.doe@example.com"
Success Response (202 Accepted)
{
"result": true
}
Dry Run Response (Cannot Close, 202 Accepted)
{
"result": false
}
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
10024 | 404 | "User not found to close." | User object invalid |
10025 | 406 | "An invalid email was supplied to close account." | Invalid email format |
10025 | 406 | "An incorrect email was supplied to close account." | Email does not match user's email |
159788 | 406 | "Cannot close user account that owns active organizations..." | User owns active organizations |
Notes
- 2FA verification is required if 2FA is enabled on the account.
- Users who own active organizations must close or transfer ownership first.
- The
dryrunparameter checks closure eligibility without actually closing the account. - On closure: subscriptions are cancelled, SSO connections are removed, the account is flagged as closed.
POST /current/user/email/
Deprecated. This endpoint previously reported whether an email was already
registered, which let anyone enumerate Fastio accounts. It no longer performs any account lookup:
for any well-formed email it returns a uniform 202 Accepted / result: true.
It is retained only so existing callers keep receiving a success response. To handle an
already-registered email, just call signup (POST /user/) — it notifies the existing
account and returns the same success as a new signup.
Auth: None (IP-throttled)
Request Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| string | Yes | Email address (format-validated only; not looked up). |
Request Example
curl -X POST "https://api.fast.io/current/user/email/" \
-d "email=jane.doe@example.com"
Response (202 Accepted) — always, regardless of whether the email is registered
{
"result": true
}
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
10022 | 406 | "You provided an invalid email to check." | Invalid email format or missing |
Notes
- The response does not reveal whether the email is registered (account enumeration is intentionally not possible).
POST /current/user/email/reset/
Request a password reset email.
Auth: None (IP-throttled)
Request Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| string | Yes | Email address of the account. |
Request Example
curl -X POST "https://api.fast.io/current/user/email/reset/" \
-d "email=jane.doe@example.com"
Success Response (202 Accepted)
{
"result": true
}
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
10022 | 406 | "You provided an invalid email to check." | Invalid email format |
20544 | 500 | "We were unable to send a verification email." | Email send failure |
Notes
- For security, this endpoint always returns success regardless of whether the email exists in the system.
POST /current/user/email/validate/
Send or validate an email verification code. Two-step flow.
Auth: Required (JWT)
Mode 1: Send Verification Code
When email_token is NOT provided, sends a new validation code to the user's email.
| Parameter | Type | Required | Description |
|---|---|---|---|
| string | Yes | Must match the authenticated user's email address. |
Mode 2: Validate Code
When email_token IS provided, validates the code and marks the email as verified.
| Parameter | Type | Required | Description |
|---|---|---|---|
| string | Yes | Must match the authenticated user's email address. | |
| email_token | string | Yes | Verification code received via email. |
Request Example (Send Code)
curl -X POST "https://api.fast.io/current/user/email/validate/" \
-H "Authorization: Bearer {jwt_token}" \
-d "email=jane.doe@example.com"
Request Example (Validate Code)
curl -X POST "https://api.fast.io/current/user/email/validate/" \
-H "Authorization: Bearer {jwt_token}" \
-d "email=jane.doe@example.com" \
-d "email_token=123456"
Success Response (202 Accepted)
{
"result": true
}
Side Effects
- On successful validation, any pending invitations addressed to this email remain pending. The user accepts them explicitly via the invitation accept endpoints (list pending invitations with
GET /current/user/invitations/list/, then accept withPOST /current/user/invitation/{invitation_id}/accept/).
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
10011 | 401 | "Your credentials were not supplied or invalid." | User not authenticated |
10037 | 406 | "Your email address is already verified." | Email already verified |
10023 | 409 | "Your credentials do not match the email you provided." | Email mismatch with authenticated user |
10033 | 406 | "You provided an invalid or expired token to validate email." | Invalid or expired code |
10199 | 401 | "Provided code has expired, get a new code and try again." | Code expired |
POST /current/user/email/change/
Confirm a pending email change. Consumes the one-time confirmation token from the link that was emailed to the new address when the change was requested via /current/user/update/, and applies the new email.
Auth: Required (JWT). The signed-in user must be the account the change was requested for.
Request Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| token | string | Yes | The one-time confirmation token from the confirmation link. |
Request Example
curl -X POST "https://api.fast.io/current/user/email/change/" \
-H "Authorization: Bearer {jwt_token}" \
-d "token={confirmation_token}"
Success Response (202 Accepted)
{
"result": true
}
Side Effects
- On success the new email becomes the account email and is marked verified. Any pending invitations addressed to the new email remain pending; the user accepts them explicitly via the invitation accept endpoints (list with
GET /current/user/invitations/list/, then accept withPOST /current/user/invitation/{invitation_id}/accept/).
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
10755 | 406 | "There is no pending email change to confirm." | No pending change exists for this account |
10033 | 401 | "The confirmation link is invalid or has expired." | Token invalid, expired, or already used |
10023 | 401 | "This confirmation link does not belong to the signed-in account." | Token belongs to a different account than the one signed in |
10025 | 409 | "That email address is no longer available." | The pending email was claimed by another account before confirmation |
10032 | 500 | "There was an internal error applying your email change." | The change could not be applied |
Notes
- The confirmation token is single-use and time-limited; once used or expired, request the change again via
/current/user/update/.
POST /current/user/password/{code}/
Set a new password using a password reset code.
Auth: None (code-based authentication)
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {code} | string | Yes | Password reset code from the reset email. |
Request Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| password1 | string | Yes | New password. |
| password2 | string | Yes | New password confirmation. Must match password1. |
Request Example
curl -X POST "https://api.fast.io/current/user/password/abc123def456/" \
-d "password1=NewSecureP@ss" \
-d "password2=NewSecureP@ss"
Success Response (202 Accepted)
{
"result": true
}
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
10197 | 401 | "An invalid code was provided, cannot reset password." | Invalid code format |
10198 | 401 | "Provided code was not found or expired, cannot reset password." | Code not found or wrong type |
10199 | 401 | "Provided code has expired, get a new code and try again." | Code expired |
10200 | 409 | "Provided code belongs to another user account and cannot be used." | Code/user mismatch |
10201 | 404 | "The provided code belongs to an invalid user." | User not found for code |
10202 | 409 | "The provided passwords don't match." | password1 and password2 differ |
10204 | 406 | "Both password fields must be provided and match." | Missing password fields |
10203 | 500 | "The provided password could not be processed..." | Encryption failure |
10204 | 500 | "The provided password could not be processed..." | The new password could not be written |
10204 | 500 | "The provided password could not be processed..." | The account could not be reloaded before its sessions were invalidated |
10204 | 500 | "The provided password could not be processed..." | The reset code could not be claimed because the datastore did not answer; nothing was consumed, retry |
Notes
10204is overloaded and the HTTP status is what separates the two meanings. At406it is a caller mistake (the two password fields are missing or do not match) and is worth reporting to the user; at500it is a server-side failure writing the password, carries a completely different message, and the user did nothing wrong. Branch on the status, not on the code alone.10203is only ever the500.- The reset code is consumed by an atomic claim immediately before the new password is written, so a replayed or concurrent request carrying the same code is refused with the same error as an unknown code. If the password write itself fails after the claim, the code is already spent and a new reset must be requested.
- Completing a reset signs out the account's other browsers and devices. Any existing sign-in session stops working on its next request. This endpoint does not return a token — sign in normally with the new password afterwards to obtain one. OAuth-connected applications, e-signature links, guest share access and open realtime connections are not affected; revoke those separately.
GET /current/user/password/{code}/details/
Get details of a password reset code (check if valid/expired).
Auth: None (code-based)
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {code} | string | Yes | Password reset code to check. |
Request Example
curl -X GET "https://api.fast.io/current/user/password/abc123def456/details/"
Success Response (200 OK)
{
"result": true,
"email": "jane.doe@example.com"
}
Response Fields
| Field | Type | Description |
|---|---|---|
| response.email | string | The email address associated with the reset code. |
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
10197 | 401 | "An invalid code was provided, cannot reset password." | Invalid code format |
10198 | 401 | "Provided code was not found or expired, cannot reset password." | Code not found |
10199 | 401 | "Provided code has expired, get a new code and try again." | Code expired |
10200 | 409 | "Provided code belongs to another user account..." | Code/user mismatch |
10207 | 423 | "The account has been restricted and cannot be updated." | Account locked/suspended/closed |
GET /current/user/phone/{country_code}-{phone_number}/
Validate a phone number and country code combination.
Auth: Required (JWT)
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {country_code}-{phone_number} | string | Yes | Country code and phone number separated by a hyphen (e.g., 1-5551234567). |
Request Example
curl -X GET "https://api.fast.io/current/user/phone/1-5551234567/" \
-H "Authorization: Bearer {jwt_token}"
Success Response (202 Accepted)
{
"result": true
}
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
10022 | 406 | "You provided an invalid phone number to check." | Invalid format |
10163 | 406 | "An invalid phone country code was supplied." | Invalid country code |
10029 | 406 | "An invalid phone number was supplied." | Invalid phone number |
10165 | 406 | "An invalid phone number or country code was supplied." | Full number validation failed |
GET /current/user/pin/
Get the user's support PIN and identity verification hash.
Auth: Required (JWT)
Request Example
curl -X GET "https://api.fast.io/current/user/pin/" \
-H "Authorization: Bearer {jwt_token}"
Success Response (200 OK)
{
"result": true,
"supportcode": "1234",
"intercom": "a1b2c3d4e5f6..."
}
Response Fields
| Field | Type | Description |
|---|---|---|
| response.supportcode | string | 4-digit support PIN. Defaults to "0000" if not set. |
| response.intercom | string | HMAC-SHA256 identity-verification hash of your user ID, for authenticating you to the support widget. |
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
10023 | 404 | "Unable to fetch the user details." | User not found |
10541 | 500 | "Internal temporary error, please try again later." | Internal error |
GET|POST /current/user/sso/signin/{provider}/
SSO (Single Sign-On) authentication flow.
Auth: None (IP-throttled)
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {provider} | string | Yes | SSO provider name: google or microsoft. |
GET: Get SSO Redirect URL
Returns the OAuth2 authorization URL for the specified provider.
curl -X GET "https://api.fast.io/current/user/sso/signin/google/"
To request a session-bound JWT after the SSO flow completes:
curl -X GET "https://api.fast.io/current/user/sso/signin/google/?revocable=true"
Optional Query Parameters (GET only)
| Parameter | Type | Default | Description |
|---|---|---|---|
| revocable | boolean | false |
When true, the JWT issued at the end of the SSO flow is session-bound and can be invalidated server-side via POST /current/user/auth/sign-out/. The flag rides through the OAuth round-trip inside the HMAC-signed state token. Recommended for browser SSO logins. |
| return_url | string | (unset) | Optional https:// URL to redirect to after the SSO callback completes. Must be a trusted Fastio domain. |
GET Response (200 OK)
{
"result": true,
"provider": "google",
"redirect_url": "https://accounts.google.com/o/oauth2/v2/auth?response_type=code&client_id=...",
"return_url": "https://fast.io/sso/callback/google"
}
Response Fields
| Field | Type | Description |
|---|---|---|
| response.provider | string | The provider name. |
| response.redirect_url | string | URL to redirect the user to for SSO authentication. |
| response.return_url | string | Callback URL the provider will redirect back to. |
POST: Process SSO Callback
Processes the OAuth2 callback with the authorization code from the provider.
| Parameter | Type | Required | Description |
|---|---|---|---|
| code | string | Yes | Authorization code from the SSO provider. |
| state | string | Yes | State token for CSRF protection. |
POST Response (200 OK)
{
"result": true,
"provider": "google",
"email": "user@example.com",
"token": "{jwt_token}",
"2factor": false,
"enrol_required": false,
"account_created": true
}
Response Fields
| Field | Type | Description |
|---|---|---|
| response.provider | string | The provider that authenticated the user. |
| response.email | string | The email address on the authenticated account. |
| response.token | string | The issued JWT. Send it as Authorization: Bearer {token}. |
| response.2factor | boolean | Whether the account has 2FA enabled, OR enrol_required is true. When true, complete the 2FA step before the token has full access. |
| response.enrol_required | boolean | Always present, exactly like the password-login field of the same name — see Interactive Login & Enrolment. true only when an org this account belongs to requires 2FA and the account holds none; token is then an enrolment token, not a session. |
| response.account_created | boolean | Present and true only when this exchange created the account — i.e. a first-time SSO signup. Omitted entirely for a returning sign-in, so treat a missing field as false. |
| response.redirect_after_login | string | Only present when a return_url was supplied on the GET step; the URL to send the user to after login completes. |
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
10041 | 406 | "An invalid provider name was supplied." | Invalid provider name format |
10226 | 406 | "An unknown provider name was supplied." | Provider not in allowed list |
10530 | 406 | "Cookies must be enabled and passed to this API." | Missing state cookie |
10260 | 401 | "Permission was not granted by the provider." | OAuth error returned from provider |
10230 | 401 | "Invalid or missing input in a required field was received." | Missing code or state |
145237 | 401 | "Accounts cannot be registered with this email domain." | First-time SSO signup only: the provider-asserted email is at a reserved domain, or a subdomain of one, so no account is created. An account that already exists on such a domain can still sign in |
| — | 503 | "Your organization's sign-in policy could not be read. Please try again." | An org's Require-2FA policy governing this account could not be resolved, so the sign-in was neither completed nor refused. Retryable, and NOT a credential failure — identical to the password-login case; see the note below |
Notes
- This is the personal social sign-in, not enterprise SSO. It authenticates an individual against Google or Microsoft and needs no organization configuration. To sign a user in against their organization's own identity provider, see Enterprise SSO Sign-In below.
- Supported providers:
google,microsoft. Requesting any other provider — includingapple— is rejected with the10226"An unknown provider name was supplied." error. - GET generates a state token (requires cookies) and returns the redirect URL.
- POST exchanges the authorization code for tokens and creates/links the user account.
- Telling a first-time signup from a returning sign-in: the POST response carries
account_created: trueonly when that exchange created the account. It is omitted for every returning sign-in, so a missing field means "existing account" — use it to send a brand-new user into org creation rather than the normal post-login landing. - Browser session cookie: send the
x-ve-session-cookieheader on the POST callback request to also receive the issued token in an HttpOnly cookie, exactly as onGET /current/user/auth/. Because only code the page itself runs can set a header, the opt-in takes effect when the app makes the callback request; where the identity provider posts the browser to the callback directly, no cookie is issued and the app uses the token in the response body as it does today. - This is subject to an org's Require-2FA policy exactly like password login.
enrol_required: truebehaves identically to the password-login case — see Interactive Login & Enrolment below. - So is the
503. When that policy cannot be read, this callback answers503("Your organization's sign-in policy could not be read. Please try again.") rather than issuing a token. It is retryable and is not an authentication failure — the provider already authenticated the user. Do not report it as a rejected sign-in, and do not let it count toward any attempt or lockout counter. Only an account holding no second factor can see it. To retry, start the provider sign-in again from the beginning — callGET /current/user/sso/signin/{provider}/for a fresh redirect and send the user back through the provider. Do NOT re-send the same callback: the authorization code in it is single-use and was already exchanged, so replaying it fails on the code rather than retrying the policy read.
GET /current/user/assets/
List available user asset metadata types (e.g., profile photo specifications).
Auth: None
Request Example
curl -X GET "https://api.fast.io/current/user/assets/"
Notes
- Returns the schema/specifications for available asset types, not actual assets.
GET /current/user/available_profiles/
Check what profile types (orgs, workspaces, shares) the current user has access to.
Auth: Required (JWT)
Request Example
curl -X GET "https://api.fast.io/current/user/available_profiles/" \
-H "Authorization: Bearer {jwt_token}"
Success Response (200 OK)
{
"result": true,
"has_orgs": true,
"has_workspaces": true,
"has_shares": false,
"has_pending_invitations": false
}
Response Fields
| Field | Type | Description |
|---|---|---|
| response.has_orgs | boolean | Whether the user has access to any organizations. |
| response.has_workspaces | boolean | Whether the user has access to any workspaces. |
| response.has_shares | boolean | Whether the user has access to any shares. |
| response.has_pending_invitations | boolean | Whether the user has any pending invitations awaiting their explicit acceptance. Computed for verified-email accounts only (false otherwise). |
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
10023 | 404 | "Unable to fetch the user details." | User not found |
GET /current/user/{user_id}/details/
Get user profile details.
Auth: Required (JWT)
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {user_id} | string | No | 19-digit user ID. If omitted, returns the current user's details. |
Request Example
curl -X GET "https://api.fast.io/current/user/1234567890123456789/details/" \
-H "Authorization: Bearer {jwt_token}"
Success Response (200 OK)
{
"result": true,
"user": {
"id": "1234567890123456789",
"account_type": "human",
"email_address": "jane.doe@example.com",
"first_name": "Jane",
"last_name": "Doe",
"locked": false,
"profile_pic": "https://assets.fast.io/..."
}
}
Response Fields
| Field | Type | Description |
|---|---|---|
| response.user.id | string | 19-digit user ID. |
| response.user.account_type | string | "human" or "agent". |
| response.user.email_address | string | User's email address. |
| response.user.first_name | string | Given name. |
| response.user.last_name | string | Family name. |
| response.user.locked | boolean | Whether the account is locked. |
| response.user.profile_pic | string | Profile photo URL. |
Self-Only Fields (included only when viewing your own profile)
| Field | Type | Description |
|---|---|---|
| 2factor | boolean | Whether 2FA is enabled. |
| closed | boolean | Whether the account is closed. |
| country_code | string | Country of residence. |
| created | string | Registration date. |
| password_set | boolean | Whether the account has a password set (false = SSO-only). Self/manager view only. |
| phone_country | string | Phone country code. |
| phone_number | string | Phone number. |
| sso | object | Enterprise SSO enforcement state for the caller: enforced, exempt, org_domain. Absent when it could not be determined — treat an absent block as not enforced. See Enterprise SSO Enforcement. |
| suspended | boolean | Suspension status. |
| tos_agree | string | ToS agreement date. |
| updated | string | Last profile update time. |
| valid_email | boolean | Email verified status. |
| valid_phone | boolean | Phone verified status. |
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
10023 | 404 | "Unable to fetch the user details." | User not found |
GET /current/user/me/autosync/{state}/
Enable or disable profile photo auto-synchronization from SSO providers.
Auth: Required (JWT)
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {state} | string | Yes | "enable" or "disable". |
Request Example
curl -X GET "https://api.fast.io/current/user/me/autosync/enable/" \
-H "Authorization: Bearer {jwt_token}"
Success Response (200 OK)
{
"result": true
}
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
135405 | 500 | "There was an internal error processing your request..." | Commit failure |
GET /current/user/me/allowed/
Check if the user's country (based on IP geolocation) allows creating shares or organizations.
Auth: None (IP-throttled)
Request Example
curl -X GET "https://api.fast.io/current/user/me/allowed/"
Success Response (200 OK)
{
"result": true,
"allowed": true
}
Response Fields
| Field | Type | Description |
|---|---|---|
| response.allowed | boolean | Whether the user's location allows resource creation. |
| response.reasons | array | Array of blocked reason strings. Only present when allowed is false. |
GET /current/user/me/limits/orgs/
Check free organization creation eligibility.
Auth: Required (JWT)
Request Example
curl -X GET "https://api.fast.io/current/user/me/limits/orgs/" \
-H "Authorization: Bearer {jwt_token}"
Success Response (200 OK)
{
"result": true,
"can_create_free_org": false,
"existing_free_orgs": 0,
"cooldown_remaining": 0,
"max_free_orgs": 1,
"reason": "The free plan is no longer available. Please choose a paid plan.",
"free_trial_eligible": true,
"trial_days": 14
}
The free plan is retired for new organizations, so can_create_free_org is false and a reason is returned. (These field NAMES are unchanged. The plan identifier itself is now reported as unpaid rather than free — an organization with no active subscription is on the unpaid tier.) New organizations select a paid plan (Starter, Business, Growth, or Enterprise) instead. While the free plan is closed, existing_free_orgs and cooldown_remaining are not meaningful (reported as 0/null).
The free_trial_eligible and trial_days fields describe whether a NEW org this user creates can start a free trial of a paid plan (vs. buying immediately). A free trial is only available on a user's first organization, so at this pre-org stage free_trial_eligible is true only when the user owns no organization at all — once the user owns (or has ever owned) any organization, every later org is permanently ineligible regardless of elapsed time. A secondary per-user cooldown (60 days on modern plans, anchored at trial start) also applies on top of this. Use these fields to render the plan-selection cards ("Start N-day free trial" vs. "Buy now") before any org exists. When free_trial_eligible is false, a no_trial_reason string is also returned explaining why; a trial_available_at timestamp is included only when the block is the cooldown (the first-org block is permanent, so it has no future date).
Response Fields
| Field | Type | Description |
|---|---|---|
| response.can_create_free_org | boolean | Whether the user can create a free organization. The free plan is retired for new orgs, so this is false. |
| response.existing_free_orgs | integer | Number of existing free organizations owned by the user. |
| response.cooldown_remaining | integer | Seconds remaining before next creation is allowed. |
| response.max_free_orgs | integer | Maximum number of free organizations allowed. |
| response.reason | string | Reason creation is not allowed. Only present when can_create_free_org is false. |
| response.free_trial_eligible | boolean | Whether a new org this user creates can start a free trial of a paid plan. true only when the user owns no organization at all (a trial is only ever available on a user's first org); false and permanent thereafter, subject also to the per-user cooldown. |
| response.trial_days | integer | Length of the free trial in days for the default paid plan. |
| response.no_trial_reason | string | Why the user is not trial-eligible. Present only when free_trial_eligible is false and a reason exists. |
| response.trial_available_at | string | Canonical Y-m-d H:i:s UTC timestamp of when the user next becomes trial-eligible. Present only when blocked by the per-user cooldown — absent when the user already owns an organization, since that block is permanent. |
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
141088 | 404 | "Unable to fetch the user." | User not found |
GET /current/user/me/list/shares/
List all shares accessible to the current user.
Auth: Required (JWT)
Query Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
| archived | string | No | "false" |
"true" to show archived shares, "false" to show non-archived. |
| limit | integer | No | 100 |
Page size (1–500). An out-of-range value is rejected as invalid input. |
| offset | integer | No | 0 |
Number of items to skip before the returned page. |
Request Example
curl -X GET "https://api.fast.io/current/user/me/list/shares/?limit=50&offset=0" \
-H "Authorization: Bearer {jwt_token}"
Success Response (200 OK)
{
"result": true,
"shares": [
{
"id": "1234567890123456789",
"name": "Project Files",
"type": "send",
"archived": false
}
],
"pagination": {
"total": 1,
"limit": 50,
"offset": 0,
"has_more": false
}
}
Response Fields
| Field | Type | Description |
|---|---|---|
| response.shares | array | Array of share resource objects for the current page. Each includes parent workspace and org info. |
| response.pagination | object | Pagination metadata: total (count of all matching shares before paging), limit, offset, and has_more (boolean — true when more items remain beyond this page). |
Notes
- This endpoint is paginated. The
sharesarray is a single page (default 100 items). Keep advancingoffsetbylimitwhilepagination.has_moreistrueto retrieve every share — reading only the first page will silently miss shares beyond the page size. - Shares are gathered from three sources: owned by the user, invited to, and joined.
- Duplicates are removed by share ID.
- Does NOT include shares from workspaces the user has access to — only shares with direct user relationships.
GET /current/user/{user_id}/assets/
List set assets (e.g., profile photo) for a user.
Auth: Required (JWT)
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {user_id} | string | Yes | 19-digit numeric user ID. |
Request Example
curl -X GET "https://api.fast.io/current/user/1234567890123456789/assets/" \
-H "Authorization: Bearer {jwt_token}"
POST|DELETE /current/user/{user_id}/assets/{asset_name}/
Upload or delete a user asset.
Auth: Required (JWT)
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {user_id} | string | Yes | 19-digit numeric user ID. |
| {asset_name} | string | Yes | Asset type name (e.g., profile_pic). |
POST: Upload Asset
Multipart form data with exactly one file upload.
| Parameter | Type | Required | Description |
|---|---|---|---|
| (file) | file | Yes | The asset file. Exactly one file must be included. |
| metadata | array | No | Optional metadata. Must be a valid array if provided. |
DELETE: Delete Asset
No request body required.
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
10586 | 400 | "Only user may modify." | Non-owner attempting to modify |
10418 | 400 | "Asset upload missing" | No file in POST request |
156780 | 406 | "metadata invalid" | Invalid metadata parameter |
Notes
- Only the user themselves can modify their own assets.
- Uploading or deleting disables profile photo auto-sync.
GET|HEAD /current/user/{user_id}/assets/{asset_name}/read/
Read the binary content of a user asset.
Auth: Required (JWT)
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {user_id} | string | Yes | 19-digit numeric user ID. |
| {asset_name} | string | Yes | Asset type name (e.g., profile_pic). |
Notes
- Returns raw binary bytes with appropriate content-type headers, not JSON.
- HEAD returns headers only.
Installed Apps
Track the desktop/mobile apps and agents a user has installed on their account. All four endpoints are user-authenticated and scoped to the calling user; installations are keyed by a caller-defined app_id.
An installation object has this shape everywhere it is returned:
| Field | Type | Description |
|---|---|---|
| id | string | 19-digit installation ID. |
| app_id | string | Caller-defined app identifier this installation belongs to. |
| app_version | string or null | App version last reported, or null if never provided. |
| platform | string or null | Platform string last reported (e.g. macos, windows, ios), or null. |
| status | string | "installed" or "uninstalled". |
| installed_at | string | Canonical Y-m-d H:i:s UTC timestamp of first install. |
| uninstalled_at | string or null | Canonical Y-m-d H:i:s UTC timestamp of last uninstall, or null if currently installed. |
| last_heartbeat | string or null | Canonical Y-m-d H:i:s UTC timestamp of the last heartbeat/install check-in, or null. |
| metadata | object or null | Arbitrary caller-defined JSON metadata, or null. |
GET /current/user/apps/
List all app installations for the authenticated user (both installed and previously uninstalled).
Auth: Required (JWT)
Request Example
curl -X GET "https://api.fast.io/current/user/apps/" \
-H "Authorization: Bearer {jwt_token}"
Success Response (200 OK)
{
"result": true,
"apps": [
{
"id": "1234567890123456789",
"app_id": "com.example.desktop",
"app_version": "1.4.2",
"platform": "macos",
"status": "installed",
"installed_at": "2026-07-07 16:37:29 UTC",
"uninstalled_at": null,
"last_heartbeat": "2026-07-07 18:02:10 UTC",
"metadata": null
}
]
}
Response Fields
| Field | Type | Description |
|---|---|---|
| response.apps | array | Array of installation objects (see shape above). |
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
227759 | 500 | "Internal error initializing app installations." | Backend unavailable |
218645 | 404 | "No app installations found." | Installation lookup failed |
Notes
- Also responds to
HEAD(headers only).
POST /current/user/apps/install/
Register an app installation for the authenticated user. Creates a new record, reinstalls a previously uninstalled app, or updates the version/platform (and refreshes the heartbeat) on an existing installation — all keyed by app_id.
Auth: Required (JWT)
Request Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| app_id | string | Yes | Caller-defined app identifier. Must not be blank. |
| app_version | string | No | App version string. |
| platform | string | No | Platform string (e.g. macos, windows, ios, android). |
| metadata | string (JSON) | No | Arbitrary JSON object of caller-defined metadata. |
Request Example
curl -X POST "https://api.fast.io/current/user/apps/install/" \
-H "Authorization: Bearer {jwt_token}" \
-d "app_id=com.example.desktop" \
-d "app_version=1.4.2" \
-d "platform=macos"
Success Response (200 OK)
{
"result": true,
"installation": {
"id": "1234567890123456789",
"app_id": "com.example.desktop",
"app_version": "1.4.2",
"platform": "macos",
"status": "installed",
"installed_at": "2026-07-07 16:37:29 UTC",
"uninstalled_at": null,
"last_heartbeat": "2026-07-07 16:37:29 UTC",
"metadata": null
}
}
Response Fields
| Field | Type | Description |
|---|---|---|
| response.installation | object | The created or updated installation object (see shape above). |
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
296856 / 210782 / 224953 / 296358 | 406 | Various | app_id blank/missing/too long → 296856; app_version too long → 210782; platform too long → 224953; metadata not valid JSON → 296358 |
246127 | 500 | "Internal error initializing app installations." | Backend unavailable |
217449 | 404 | "Failed to save app installation." | Persisting the installation failed |
Notes
- Idempotent per
app_id: calling install again for an already-installed app updates version/platform (when provided) and refresheslast_heartbeatrather than creating a duplicate. - Calling install for a previously uninstalled
app_idreinstalls it (status returns toinstalled). - Throttled per user.
POST /current/user/apps/uninstall/
Mark an app installation as uninstalled.
Auth: Required (JWT)
Request Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| app_id | string | Yes | Caller-defined app identifier of the installation to uninstall. Must not be blank. |
Request Example
curl -X POST "https://api.fast.io/current/user/apps/uninstall/" \
-H "Authorization: Bearer {jwt_token}" \
-d "app_id=com.example.desktop"
Success Response (200 OK)
{
"result": true,
"installation": {
"id": "1234567890123456789",
"app_id": "com.example.desktop",
"app_version": "1.4.2",
"platform": "macos",
"status": "uninstalled",
"installed_at": "2026-07-07 16:37:29 UTC",
"uninstalled_at": "2026-07-07 19:15:44 UTC",
"last_heartbeat": "2026-07-07 18:02:10 UTC",
"metadata": null
}
}
Response Fields
| Field | Type | Description |
|---|---|---|
| response.installation | object | The updated installation object; status is now uninstalled. |
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
234003 | 406 | Various | app_id blank/missing or too long |
226304 | 404 | "No installation record found for this app." | No installation exists for this app_id |
233650 | 409 | "This app is already uninstalled." | Installation already in the uninstalled state |
237113 | 500 | "Failed to save uninstall status." | Persisting the change failed |
POST /current/user/apps/heartbeat/
Record a periodic liveness check-in from an installed app, updating last_heartbeat and optionally the app version.
Auth: Required (JWT)
Request Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| app_id | string | Yes | Caller-defined app identifier of the installed app. Must not be blank. |
| app_version | string | No | Updated app version string. When provided, it replaces the stored version. |
Request Example
curl -X POST "https://api.fast.io/current/user/apps/heartbeat/" \
-H "Authorization: Bearer {jwt_token}" \
-d "app_id=com.example.desktop" \
-d "app_version=1.4.3"
Success Response (200 OK)
{
"result": true,
"installation": {
"id": "1234567890123456789",
"app_id": "com.example.desktop",
"app_version": "1.4.3",
"platform": "macos",
"status": "installed",
"installed_at": "2026-07-07 16:37:29 UTC",
"uninstalled_at": null,
"last_heartbeat": "2026-07-07 20:41:03 UTC",
"metadata": null
}
}
Response Fields
| Field | Type | Description |
|---|---|---|
| response.installation | object | The updated installation object with a refreshed last_heartbeat. |
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
276731 / 272675 | 406 | Various | app_id blank/missing/too long → 276731; app_version too long → 272675 |
245439 | 404 | "No installation record found for this app." | No installation exists for this app_id |
231483 | 409 | "Cannot heartbeat an uninstalled app." | Installation is in the uninstalled state |
237563 | 500 | "Failed to save heartbeat." | Persisting the change failed |
Notes
- Only currently-installed apps can heartbeat; reinstall via
POST /current/user/apps/install/first if the app was uninstalled. - Throttled per user.
Invitations
GET /current/user/invitation/{invitation_id}/details/
Get details for a specific invitation.
Auth: Required (JWT)
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {invitation_id} | string | Yes | Invitation ID (numeric) or invitation key (alphanumeric). |
Request Example
curl -X GET "https://api.fast.io/current/user/invitation/1234567890123456789/details/" \
-H "Authorization: Bearer {jwt_token}"
Success Response (200 OK)
{
"result": true,
"invitation": {
"id": "1234567890123456789",
"invitation_key": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6abcd",
"inviter": "Jane Doe",
"invitee_email": "newuser@example.com",
"entity_type": "workspace",
"workspace": {
"id": "9876543210987654321",
"name": "Marketing Team"
},
"state": "pending",
"created": "2025-01-15 10:30:00 UTC",
"expires": "2025-01-22 10:30:00 UTC"
},
"owner": {
"id": "1111111111111111111",
"account_type": "human",
"email_address": "admin@example.com",
"first_name": "Admin",
"last_name": "User",
"profile_pic": "https://assets.fast.io/..."
},
"org": {
"id": "2222222222222222222",
"name": "Example Org"
}
}
Response Fields
| Field | Type | Description |
|---|---|---|
| response.invitation | object | Invitation resource. Embeds entity_type and the entity object (org/workspace/share). Includes invitation_key because this is the invitee's own authenticated view. |
| response.owner | object | User resource of the profile owner. |
| response.org | object or null | Org resource if the invitation is for an org-owned entity. |
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
10618 | 406 | "An invalid invitation ID was supplied." | Invalid ID format |
10630 | 406 | "Invitation not found." | Invitation does not exist |
159135 | 500 | "Failed to load the invitation profile or its owner." | Profile or owner load failure |
Notes
invitation_keyis included because this is the invitee's own authenticated view. It can be used with the per-entity join endpoints (POST /current/{org|workspace|share}/{entity_id}/members/join/{invitation_key}/{accept|decline}/), but prefer the by-id accept/decline endpoints below, which do not require the key.
GET /current/user/invitation/{invitation_id}/public/details/
Get public details for an invitation without authentication.
Auth: None (IP-throttled)
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {invitation_id} | string | Yes | Invitation ID (numeric) or invitation key (alphanumeric). |
Request Example
curl -X GET "https://api.fast.io/current/user/invitation/1234567890123456789/public/details/"
Success Response (200 OK)
{
"result": true,
"invitation": {
"id": "1234567890123456789",
"state": "pending"
},
"owner": {
"id": "1111111111111111111",
"account_type": "human",
"first_name": "Admin",
"last_name": "User"
},
"org": {
"id": "2222222222222222222",
"name": "Example Org"
}
}
Notes
- Returns a more limited view than the authenticated version.
- If the profile or owner cannot be loaded,
ownerwill benull. - The secret
invitation_keyis never included in this unauthenticated view.
POST /current/user/invitation/{invitation_id}/accept/
Accept a single pending invitation by its ID. Self-service: authorized by the authenticated
invitee's verified email (or user ID), so the secret invitation key is not required. Use this to act
on the invitations returned by GET /current/user/invitations/list/ — for example
from a no-org landing page where the user has no email-link token.
Auth: Required (JWT, validated email)
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {invitation_id} | string | Yes | The invitation id from the invitations list/details response. |
Request Example
curl -X POST "https://api.fast.io/current/user/invitation/1234567890123456789/accept/" \
-H "Authorization: Bearer {jwt_token}"
Success Response (200 OK)
{
"result": true,
"invitation": {
"id": "1234567890123456789",
"inviter": "Jane Doe",
"invitee_email": "newuser@example.com",
"entity_type": "workspace",
"workspace": {
"id": "9876543210987654321",
"name": "Marketing Team"
},
"state": "accepted",
"created": "2025-01-15 10:30:00 UTC",
"expires": "2025-01-22 10:30:00 UTC"
}
}
Response Fields
| Field | Type | Description |
|---|---|---|
| response.invitation | object | The updated invitation resource; state is now accepted. |
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
10618 | 406 | "An invalid invitation ID was supplied." | Malformed ID |
10631 | 406 | "This invitation can no longer be accepted." | Already accepted/declined, or expired |
10630 | 404 | "Invitation not found." | No such invitation |
127827 | 401 | "You are not authorized to act on this invitation." | The invitation is not addressed to the authenticated user |
Notes
- Requires a validated email; ownership is verified by matching the authenticated user's verified email (or user ID) to the invitation's invitee.
- On success the user is added as a member of the invitation's entity (org, workspace, or share).
- Idempotent: re-accepting an invitation you have already accepted returns success.
POST /current/user/invitation/{invitation_id}/decline/
Decline a single pending invitation by its ID. Self-service counterpart to the accept endpoint; authorized by the authenticated invitee's verified email (or user ID), no invitation key required.
Auth: Required (JWT, validated email)
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {invitation_id} | string | Yes | The invitation id from the invitations list/details response. |
Request Example
curl -X POST "https://api.fast.io/current/user/invitation/1234567890123456789/decline/" \
-H "Authorization: Bearer {jwt_token}"
Success Response (200 OK)
{
"result": true,
"invitation": {
"id": "1234567890123456789",
"inviter": "Jane Doe",
"invitee_email": "newuser@example.com",
"entity_type": "workspace",
"workspace": {
"id": "9876543210987654321",
"name": "Marketing Team"
},
"state": "declined",
"created": "2025-01-15 10:30:00 UTC",
"expires": "2025-01-22 10:30:00 UTC"
}
}
Response Fields
| Field | Type | Description |
|---|---|---|
| response.invitation | object | The updated invitation resource; state is now declined. |
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
10618 | 406 | "An invalid invitation ID was supplied." | Malformed ID |
10631 | 406 | "This invitation can no longer be declined." | Already accepted/declined |
10630 | 404 | "Invitation not found." | No such invitation |
127827 | 401 | "You are not authorized to act on this invitation." | The invitation is not addressed to the authenticated user |
Notes
- A declined invitation is marked declined and no longer appears in
GET /current/user/invitations/list/; it will not reappear. - No membership is created. Idempotent: re-declining an already-declined (or expired) invitation returns success.
POST /current/user/invitations/acceptall/
Accept all pending invitations.
Auth: Required (JWT)
Request Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| invitation_key | string | No | Optional invitation key. If the user's email is not validated, this key can identify invitations. |
Request Example
curl -X POST "https://api.fast.io/current/user/invitations/acceptall/" \
-H "Authorization: Bearer {jwt_token}"
Success Response (200 OK)
{
"result": true,
"accepted_invitations": ["1234567890123456789"],
"refused": []
}
Response Fields
| Field | Type | Description |
|---|---|---|
accepted_invitations | array of string | IDs of invitations that were accepted. |
refused | array of object | Invitations skipped by the collaboration policy (see the Organizations reference — Collaboration Policies): {invitation_id, target_type, target_id, reason}, reason is external_invites_denied or external_invites_object_denied. Always present; empty when nothing was refused. A refused invitation stays pending, not failed — no membership is created for it, and it can be retried later if the policy changes. |
failed_invitations | array of object | Present only when something went wrong outside the policy (e.g. the policy could not be evaluated, or an unsupported invitation type): {invitation_key, error}. |
Notes
- If email is validated, all pending invitations matching that email are accepted (subject to the collaboration policy above).
- If email is not validated, use
invitation_keyto identify invitations. - This is a partial-success endpoint: a policy refusal on one invitation does not stop the rest of the batch from being processed.
GET /current/user/invitations/list/
List all pending invitations for the current user.
Auth: Required (JWT)
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| invitation_key | string | No | Optional invitation key for users without validated email. |
Request Example
curl -X GET "https://api.fast.io/current/user/invitations/list/" \
-H "Authorization: Bearer {jwt_token}"
Success Response (200 OK)
{
"result": true,
"invitations": [
{
"id": "1234567890123456789",
"invitation_key": "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6abcd",
"inviter": "Jane Doe",
"invitee_email": "newuser@example.com",
"entity_type": "workspace",
"workspace": {
"id": "9876543210987654321",
"name": "Marketing Team"
},
"state": "pending",
"created": "2025-01-15 10:30:00 UTC",
"expires": "2025-01-22 10:30:00 UTC"
}
]
}
Response Fields
| Field | Type | Description |
|---|---|---|
| response.invitations | array | Array of invitation resource objects. |
Notes
- Returns only pending invitations for the current user.
- Each invitation embeds its
entity_typeand the entity object (org/workspace/share) so cards can render without an extra details fetch. invitation_keyis included for the invitee's own list. Prefer acting on invitations with the by-idPOST /current/user/invitation/{invitation_id}/{accept|decline}/endpoints, which do not require the key.
User Authentication Endpoints
GET /current/user/auth/
Authenticate via HTTP Basic Auth. Returns JWT token.
Auth: HTTP Basic Auth (email:password)
Query Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
| expires | integer | No | Server default | Custom JWT expiration time in seconds from now. Capped at one year; a larger value is rejected with 10454. |
| revocable | boolean | No | false |
When true, the issued JWT is session-bound and can be invalidated server-side via POST /current/user/auth/sign-out/. Recommended for browser sessions. Tokens minted without this flag are stateless and live their full TTL — sign-out has no effect on them. |
Request Headers
| Header | Type | Required | Default | Description |
|---|---|---|---|---|
| x-ve-session-cookie | boolean | No | (absent) | Browser clients only. When truthy (1, true or yes — the same vocabulary as revocable), the issued token is ALSO returned in an HttpOnly, Secure, SameSite=Lax cookie scoped to the site's registrable domain (e.g. fast.io), with the same lifetime as the token. auth_token is still returned in the body. On later page loads, bring the cookie's token back into memory with POST /current/user/auth/bootstrap/. The opt-in is only honoured as a request header — there is no URL or body parameter equivalent. On a 2FA-enabled account no cookie is set here — see the notes below. |
Request Example
curl -X GET "https://api.fast.io/current/user/auth/" \
-u "jane.doe@example.com:$PASSWORD"
To request a revocable session-bound JWT (recommended for browser logins):
curl -X GET "https://api.fast.io/current/user/auth/?revocable=true" \
-u "jane.doe@example.com:$PASSWORD"
To additionally receive the token in an HttpOnly session cookie (browser logins that keep the token out of client storage):
curl -X GET "https://api.fast.io/current/user/auth/" \
-u "jane.doe@example.com:$PASSWORD" \
-H "x-ve-session-cookie: 1" \
--cookie-jar cookies.txt
Success Response (200 OK)
{
"result": true,
"expires_in": 86400,
"auth_token": "{jwt_token}",
"2factor": false,
"enrol_required": false
}
Response Fields
| Field | Type | Description |
|---|---|---|
| response.expires_in | integer | JWT token expiration time in seconds. |
| response.auth_token | string | JWT access token. If 2FA is enabled, has twofactor scope (restricted). If an org requires 2FA and the account holds none, has enrol scope (restricted to the enrolment surfaces — see Interactive Login & Enrolment). Otherwise has user scope (full access). |
| response.2factor | boolean | true if 2FA is enabled, OR if enrol_required is true — so a client that does not yet read enrol_required still shows its existing code-entry screen rather than treating the login as complete. |
| response.enrol_required | boolean | Always present. true only when an org this account belongs to requires 2FA and the account holds none. When true, auth_token is an enrolment token, not a session — see Interactive Login & Enrolment. |
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
10454 | 405 | "The expires time specified is invalid." | Invalid expires parameter |
10001 | 401 | "Your credentials were not supplied or invalid." | Missing Basic Auth header |
10004 | 401 | "Username is not valid." | Invalid email format |
10005 | 401 | "Password is not valid." | Invalid password format |
10008 | 401 | "Your credentials supplied are invalid." | Wrong password, unrecognized email, or SSO-only account (no password set) — identical response and matched timing prevent account enumeration. Carries error.params.attempts_remaining + attempts_max when known |
10105 | 401 | "Your account is suspended..." | Account suspended (only after a correct password) |
10104 | 401 | "Your account is locked..." | Account locked |
10106 | 401 | "Your account is suspended due to abuse." | Account flagged for abuse |
10103 | 401 | "Your account is closed by you." | Account closed |
10103 | 401 | "This account has not been claimed yet." | The address belongs to a placeholder account created by an invitation that has never been claimed. Same code as "account closed" — the message is the only thing that distinguishes them, so branch on the message, not the code. The remedy is different too: this one clears by accepting the invitation, not by contacting support |
10760 | 429 | "Too many failed sign-in attempts. Try again in N minutes." | Too many consecutive failed sign-in attempts for this account — a temporary, self-clearing lockout |
| — | 503 | "Your organization's sign-in policy could not be read. Please try again." | An org's Require-2FA policy governing this account could not be resolved, so the sign-in was neither completed nor refused. Retryable, and NOT a credential failure — see the note below |
Notes
- Email tags (e.g.,
user+tag@example.com) are stripped before lookup. - If 2FA is enabled, complete the 2FA verification flow to upgrade the token.
- A failed sign-in reports how many attempts remain. The
401response carrieserror.params.attempts_remaining— the number of further failures this account tolerates before it is temporarily locked — alongsideerror.params.attempts_max, the threshold in force, so a client can render "2 of 5" without hard-coding a limit that can change. Both fields are absent when the count is unknown (it could not be recorded); treat absence as "unknown" and show a plain invalid-credentials message rather than assuming zero. The value carries no account-existence signal: the counter is keyed on the submitted address, so an unregistered email reports the same countdown as a registered one. - Repeated failed sign-in attempts temporarily lock the account. After too many consecutive failures this endpoint returns
429witherror.code10760, and the response body carrieserror.params.retry_after_seconds— the number of seconds until sign-in is accepted again. Wait that long before retrying; further attempts during the lockout do not extend it, but they do not succeed either. A successful sign-in clears the failure count.- Do not confuse this with "Your account is locked" (
401). That one is an administrative lock a client cannot wait out and requires contacting support;10760clears by itself. - Do not confuse it with the per-IP rate limit, which also returns
429but witherror.code10368and reflects request volume from your address rather than failed credentials for one account. - Clients should not retry sign-in automatically on a
429; an automatic retry consumes the account's remaining attempts without user intervention.
- Do not confuse this with "Your account is locked" (
- Account enumeration is intentionally not possible: an unknown email, an SSO-only account (no password), and a wrong password all return the identical
401/error.code10008/"Your credentials supplied are invalid." response with matched timing. SSO-only accounts must sign in via their provider instead. - An address on an enforced domain cannot sign in here. When the address belongs to a domain verified by an organization whose SSO mode is
required, this endpoint answers403witherror.params.reason=sso_requiredand astart_pathto send the browser to instead — before the password is examined, and identically for every address on that domain. The organization's owner, its admins, and any address on its enforcement exception list keep a password path atGET /current/user/auth/?break_glass=true, where the password is examined first and a failure counts toward the lockout above. See Enterprise SSO Enforcement. - Pass
revocable=trueto mint a session-bound JWT that respects sign-out. The default (omitted) yields a stateless JWT that cannot be invalidated server-side and is appropriate for service-to-service tokens, AI tokens, and any non-browser caller. - The
x-ve-session-cookieheader additionally delivers the token as an HttpOnly cookie. It is opt-in per request: omit it — which is what every non-browser client does — and the response and behaviour are unchanged in every respect. The cookie carries the SAME token asauth_token, not a second credential, and expires when that token does. The opt-in is only honoured as a request header; sending it in the query string or the body does nothing. - A 2FA-enabled account gets no cookie from this call. Sign-in returns a pre-2FA token (
"2factor": true), which is not a completed session and is never put in a cookie. Sendx-ve-session-cookieagain onPOST /current/user/auth/2factor/auth/{token}/; that call issues the cookie. - A
503from this endpoint is NOT a failed credential — never count it as one. When an org's Require-2FA policy cannot be read, sign-in answers503("Your organization's sign-in policy could not be read. Please try again.") instead of guessing: issuing a session could hand out one the policy forbids, and forcing enrolment would march the user through one they were never subject to. This is retryable. Back off briefly and re-send the same request unchanged. The submitted password was never the problem, so this outcome must not be rendered as "wrong password", must not decrementattempts_remaining, and must not feed a client-side attempt counter — a client that treats every non-200as a bad credential will lock a user out of their own account over a transient condition. Only an account that holds no second factor can ever see it; an already-enrolled account never consults the policy. See Interactive Login & Enrolment below. - An
enrol_required: truelogin is a different case from an already-2FA-enabled account, even though both set"2factor": trueand neither gets a cookie here. The 2FA-enabled case has a factor to challenge; the enrolment case does not — the account has never enrolled one, andauth_tokenis anenrol-scoped token that can only enrol a first factor, not challenge an existing one. See Interactive Login & Enrolment below for the flow and what the token can and cannot do.
POST /current/user/auth/bootstrap/
Return the session token held in the browser's HttpOnly cookie. A browser that signed in with the x-ve-session-cookie header calls this on page load to bring that token into memory, then uses it in the Authorization header for every other request.
This is the ONLY endpoint in the API that authenticates from a cookie — every other endpoint still requires Authorization: Bearer {token}, unchanged. The point of the arrangement is that the durable session credential never has to live anywhere JavaScript can read it, except in memory once bootstrap hands it back.
Auth: The HttpOnly session cookie ONLY, sent automatically by the browser. A request that carries an Authorization header instead is rejected with 401 — a caller that already holds a token does not need to bootstrap.
Rate Limited: Yes (per user)
Request Parameters: None.
Request Example
curl -X POST "https://api.fast.io/current/user/auth/bootstrap/" \
--cookie cookies.txt
Success Response (200 OK)
{
"result": true,
"id": "1234567890123456789",
"auth_token": "{jwt_token}",
"expires_in": 2591990
}
Response Fields
| Field | Type | Description |
|---|---|---|
| response.id | string | The 19-digit numeric user ID the cookie authenticated as. |
| response.auth_token | string | The session token held in the cookie — the same credential the cookie carries, not a separately issued one. |
| response.expires_in | integer | Seconds of remaining life on the token. Always read this rather than assuming a fixed duration; the lifetime can vary. 0 (or a 401 on a later call) means the session is finished and the user must sign in again. |
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
100966 | 401 | "This endpoint requires a browser session cookie." | The request authenticated with an Authorization header, or carried no session cookie at all |
Notes
- This returns the cookie's own token — nothing is minted.
auth_tokenis the same credential the cookie is already holding, andexpires_inreports that credential's own remaining life, not a separate short-lived window. - Call it once per page load. Since bootstrap hands back the existing token rather than issuing a new one, there is no obligation to call it again before that token expires — bring it into memory once per page load, and call it again after a
401if you need to confirm whether the underlying session is still valid. - POST only. A
GETreturns the standard405 Method Not Allowed. POST is required becauseSameSite=Laxstill sends cookies on cross-site top-levelGETnavigations, and this response body contains a bearer token. - Call it same-origin from whatever page is making the request. The cookie is scoped to the site's registrable domain (e.g.
fast.io) rather than a single host, so it travels to every subdomain on that domain — including one different from wherever sign-in happened. The API still sendsAccess-Control-Allow-Origin: *and neverAccess-Control-Allow-Credentials, so a browser will not expose this response to a script running on a different origin than the one that made the request. - Nothing else about the API changes: after bootstrapping, send
Authorization: Bearer {auth_token}on every other call exactly as before. - Rate limited per user. A browser legitimately calls this once per page load and once per restored tab.
- The cookie only exists if the client opted in with the
x-ve-session-cookieheader at sign-in — or, on a 2FA-enabled account, at 2FA completion. Without it there is nothing to bootstrap. POST /current/user/auth/sign-out/clears the cookie, so a signed-out browser gets the401above on its next bootstrap.
POST /current/user/auth/sign-out/
Invalidate every revocable JWT issued to the calling user. The user's session counter is incremented; revocable tokens carrying the previous counter value will be rejected on their next request.
Auth: Required (JWT, scope: user or admin; or an unscoped API key for the user)
This is account-wide, not session-scoped. It bumps the user's SHARED session counter, so it signs out every revocable session on the account — not only the caller's. An entity-scoped API key is therefore refused with 10175, the same bar invalidate-all/ applies: a key carries no sv claim, so a sign-out never affects the key itself, which makes this purely an action on other people's sessions when called with one. An unscoped key is unaffected. To end only your own session, discard your own credential — this endpoint cannot do that selectively.
Rate Limited: Yes (per user)
Request Example
curl -X POST "https://api.fast.io/current/user/auth/sign-out/" \
-H "Authorization: Bearer {jwt_token}"
Success Response (200 OK)
{
"result": true
}
Notes
- Only affects JWTs minted with
revocable=trueonGET /current/user/auth/. API keys, OAuth/PKCE access tokens, agent tokens, and JWTs minted withoutrevocableare not session-bound and are unaffected. - The bump applies to all revocable tokens for this user across every browser and device. There is no per-device sign-out via this endpoint.
- The HttpOnly session cookie IS cleared by this call. A browser that opted into it with the
x-ve-session-cookieheader loses it here, so its nextPOST /current/user/auth/bootstrap/returns401instead of silently signing it back in. Nothing else the client stored is cleared — the client remains responsible for the rest of its local credential state after sign-out. - To terminate ALL of a user's sessions — including logins that did NOT opt into
revocable— usePOST /current/user/auth/invalidate-all/below instead.
POST /current/user/auth/invalidate-all/
Invalidate EVERY login session for the calling user — a strict superset of sign-out. Kills interactive logins (browser, 2FA, password-reset, SSO) regardless of whether they opted into revocable. Any token issued before this call is rejected on its next request.
Use this for "sign me out of all devices" or a suspected account compromise — i.e. account-security actions, as opposed to a per-browser logout button (use sign-out for that). A password change or reset already invalidates other sessions on its own, so calling this afterwards is unnecessary and will additionally invalidate the replacement auth_token that the password change just returned, signing you out of the session you are using. Call it after a password change only when you deliberately want every session gone, including your own.
Auth: Required (a browser login session, or a credential explicitly holding userdetails:*:rw — see the gate below)
This is a strict superset of sign-out/, not a different class of action — both bump the user's shared session counters and both reach every session on the account; this one additionally kills tokens carrying gsv.
This is an account-settings operation and requires userdetails:*:rw. A credential that does not explicitly hold that scope is refused with 403 and 10769 (error.params.reason: userdetails_scope_required). user:*:rw, user:*:rwa, entity-scoped keys and legacy (unscoped) keys are all refused; a browser login session passes. The limited twofactor-scope token from a half-completed 2FA sign-in is refused separately with 10175. POST /current/user/auth/sign-out/ is not gated this way.
Request Example
curl -X POST "https://api.fast.io/current/user/auth/invalidate-all/" \
-H "Authorization: Bearer {jwt_token}"
Success Response (200 OK)
{
"result": true
}
Notes
- Kills interactive logins (browser, 2FA, password-reset, SSO) whether or not they opted into
revocable. Enforcement is opt-in per token, so tokens that never carried the marker — OAuth/PKCE access tokens, e-sign signer tokens, anonymous share-guest tokens, AI assistant sessions, short-lived realtime/websocket and FileShare resource tokens, and API keys — are NOT affected and keep their own revocation paths (revoke the OAuth grant, delete the API key, etc.). - Anonymous share-guest sessions and suspended/locked/closed/abuse-flagged accounts are rejected (nothing to invalidate / already terminated).
- It does NOT clear client-side storage, and — unlike sign-out — it does not clear the HttpOnly session cookie either. The client must clear its own credential state and re-authenticate afterward.
- Rate limited per user.
- A platform-wide “log everyone out” also exists: all tokens can be invalidated platform-wide by a Fastio operator action. It has no API.
GET /current/user/auth/check/
Validate current JWT and get user ID.
Auth: Required (Bearer token)
Request Example
curl -X GET "https://api.fast.io/current/user/auth/check/" \
-H "Authorization: Bearer {jwt_token}"
Success Response (200 OK)
{
"result": true,
"id": "1234567890123456789"
}
Response Fields
| Field | Type | Description |
|---|---|---|
| response.id | string | The 19-digit numeric user ID. |
Notes
- Lightweight health-check for token validity. Only validates that the JWT is structurally valid and not expired.
GET /current/auth/scopes/
Token scope introspection. Returns information about the current token's scope, auth type, and agent status.
Auth: Required (Bearer token)
Request Example
curl -X GET "https://api.fast.io/current/auth/scopes/" \
-H "Authorization: Bearer {jwt_token}"
Success Response (200 OK)
{
"result": true,
"auth_type": "jwt_v2",
"scopes": ["org:12345:rw", "org:67890:rw"],
"scopes_detail": [],
"is_agent": true,
"agent_name": "My MCP Agent",
"full_access": false,
"admin": false,
"legacy": false
}
Response Fields
| Field | Type | Description |
|---|---|---|
| response.auth_type | string | Token type: "jwt_v2" (scoped JWT), "jwt_v1" (legacy JWT, which is what a browser login session is), "api_key" (legacy API key that declares no scopes claim), or "api_key_scoped" (API key with scopes). An OAuth grant for scope=user is now stored explicitly as ["user:*:rw"] and reports jwt_v2 — do not branch on auth_type === "jwt_v1" to detect it. |
| response.scopes | array | Array of scope strings in entity_type:entity_id:access_mode format. Empty only for a legacy credential that declares no scopes claim at all — a v1 JWT (browser login session) or a pre-scopes API key. |
| response.scopes_detail | array | Hydrated scope details with entity information. Empty when scopes are empty. Each entry carries entity_type, entity_id, access_mode, a boolean admin (true when that entry's access mode is rwa, so a client need not re-parse the string) and the label/name fields described in the OAuth 2.0 reference. |
| response.is_agent | boolean | Whether the token represents an agent. |
| response.agent_name | string or null | Agent display name. null if not set or not an agent. |
| response.full_access | boolean | Whether the credential is account-wide and may write — user:*:rw or user:*:rwa. true for legacy (unscoped) API keys and browser login sessions; false for user:*:r and for entity-scoped credentials. It is not an administration flag — read admin for that. |
| response.admin | boolean | Whether the credential can perform administrative operations. true for a browser login session and for any credential holding an rwa scope. Always present. |
| response.legacy | boolean | Whether the credential declares no scopes claim at all — a pre-scopes API key, a pre-scopes OAuth session, and every browser login session. Always present. legacy never implies admin, and admin never implies legacy. |
How the flags combine
| Credential | full_access | admin | legacy | auth_type |
|---|---|---|---|---|
user:*:rwa | true | true | false | jwt_v2 / api_key_scoped |
user:*:rw | true | false | false | jwt_v2 / api_key_scoped |
user:*:r | false | false | false | jwt_v2 / api_key_scoped |
Scoped, e.g. org:123:rwa | false | true | false | jwt_v2 / api_key_scoped |
| Legacy key with no scopes | true | false | true | api_key |
| Browser login session | true | true | true | jwt_v1 |
Error Response (401 Unauthorized) — the token could not be verified
{
"result": false,
"error": {
"code": 154843,
"text": "The supplied token could not be verified.",
"params": { "reason": "verification_failed" }
}
}
A bearer token that cannot be decoded returns 401 with error.params.reason set to verification_failed. This is never answered with a 200. Treat it as "this token was not checked", NOT as "this token has no rights" — a successful response always describes a token that was read; it never reports the absence of scopes because verification failed.
reason carries a single value here on purpose. The underlying cause — an expired credential, a forged or malformed one, or key material being temporarily unavailable — is not distinguishable at this layer, and reporting a guessed cause would be worse than reporting that it is unknown. Branch on reason, never on error.code (the numeric code identifies the call site and is not a stable contract).
API Keys
POST /current/user/auth/key/
Create a new API key.
Auth: Required (JWT, scope: user or admin)
Request Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| memo | string | No | Label/description for the key. |
| scopes | string | No | JSON array of scope strings (e.g., ["org:123:rw", "workspace:456:r"]). Omitted, empty, or the string "null" stores the explicit ["user:*:rw"] — whole-account read and write, no administration and no account settings. An explicit empty array ([]) is refused with 406 190363; it is not a way to create an unconstrained key. |
| agent_name | string | No | Agent or application name for tracking. Max 128 characters. |
| expires | string | No | Expiration datetime. Accepts any strtotime-compatible value; canonical form is Y-m-d H:i:s UTC (e.g. 2026-12-31 23:59:59 UTC). Must be in the future. Omit or null for no expiration. |
scopes handling
The same rules apply to creating a key and to updating one (POST /current/user/auth/key/{key_id}/):
| Submitted | Create | Update |
|---|---|---|
| Omitted | Stored as ["user:*:rw"] | Left as-is |
"" or "null" | Stored as ["user:*:rw"] | Clears to ["user:*:rw"] |
"[]" | Refused — 406 190363 | Refused — 406 190363 |
| Malformed JSON, a JSON object, a non-string member, or an invalid scope string | 406 197558 | 406 121158 |
| A non-empty JSON list of valid scope strings | Issuance checks below | Issuance checks below |
An unscoped key is never created any more. The explicit ["user:*:rw"] is stored instead, which is the same authority a legacy key has: whole-account read and write, no administration, no account settings.
Issuance checks (create and update)
They run in this order on the final effective scope set:
- Empty set →
406190363— "The scopes provided must contain at least one scope." - Broader than the calling credential →
40310768,error.params.reasonscope_exceeds_issuer— "The requested scopes are broader than the credential making this request." A browser login session skips this check; it is unbounded. - Not grantable to this user →
406185003— the human does not hold the entity, or the scope string is not issuable at all (for exampleuserdetails:*:rwaorfileshare:*:rw). - Exceeds the governing org's credential policy →
403,error.params.reasoncredential_policy_modeorcredential_policy_scope— "The requested scopes exceed this organization's credential policy." Each attributable scope is checked against the policy of its own owning org, and a policy that is corrupt, names a deleted organization, or could not be read answers with its own reason instead (credential_policy_unreadable,credential_policy_org,credential_policy_unavailable). See Org Credential Policy below.
Request Example
curl -X POST "https://api.fast.io/current/user/auth/key/" \
-H "Authorization: Bearer {jwt_token}" \
-d "memo=CI/CD Pipeline Key"
Request Example (scoped key with expiration)
curl -X POST "https://api.fast.io/current/user/auth/key/" \
-H "Authorization: Bearer {jwt_token}" \
-d "memo=Workspace Agent" \
-d 'scopes=["workspace:1234567890123456789:rw"]' \
-d "agent_name=my-agent" \
-d "expires=2026-12-31 23:59:59 UTC"
Success Response (200 OK)
{
"result": true,
"api_key": "{the raw key, shown only here}",
"key": {
"id": "{key_id}",
"memo": "Workspace Agent",
"scopes": "[\"workspace:1234567890123456789:rw\"]",
"agent_name": "my-agent",
"created": "2026-09-09 14:03:11 UTC",
"expires": "2026-12-31 23:59:59 UTC",
"admin": false,
"legacy": false,
"api_key": "****************************abcd"
}
}
Response Fields
api_key is the secret. key is the created row, in the same shape the read and update calls return — including its own masked api_key. The two are siblings so that reading the secret stays exactly as simple as it was.
| Field | Type | Description |
|---|---|---|
| response.api_key | string | The raw key. Only shown here, only once — it is never retrievable again, so store it before you discard the response. |
| response.key | object | The created key. Carries id, memo, scopes, agent_name, created, expires, admin, legacy and a masked api_key. |
| response.key.id | string | The key id, for the update, read and delete calls. |
| response.key.scopes | string | The stored scopes claim, as a JSON list string. |
| response.key.admin | boolean | Whether the key's scopes confer administration. A user:*:rw key is not admin. |
| response.key.legacy | boolean | Whether the key predates scoped keys. legacy never implies admin. |
key is a new field and the addition is backwards compatible: api_key is unchanged and still the raw string. Use key when you need the new key's id, created or expires without a second call.
key is best effort. It is built by reading the stored row back, and on the rare occasion that read fails the response is {"result": true, "api_key": "…"} with no key field — the key is created and the secret is still returned, because losing the one-time secret to a failed convenience lookup would be the worse outcome. Treat key as optional. If it is absent and you need the row, list your keys with GET /current/user/auth/keys/ and match on created — the degraded response carries the secret only, so there is no id in it to read back by.
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
10011 | 401 | "Your credentials were not supplied or invalid." | Missing or invalid JWT |
10175 | 403 | "The scope of your credentials are not sufficient." | JWT scope not user or admin |
10015 | 429 | "You are at the maximum number of API keys, {max}." | Maximum key limit reached |
10016 | 406 | "You provided an invalid Memo." | Invalid memo format |
190363 | 406 | "The scopes provided must contain at least one scope." | scopes was an explicit empty array ([]) |
197558 | 406 | invalid scopes value | Malformed JSON, a JSON object, a non-string member, or an invalid scope string |
10770 | 403 | "Your credential is read-only and is not authorized to make changes." | The calling credential holds no write-capable scope anywhere — user:*:r, or a set every entry of which is :r. error.params.reason is scope_write_required |
10768 | 403 | "The requested scopes are broader than the credential making this request." | The requested scopes exceed what the calling credential holds. error.params.reason is scope_exceeds_issuer |
185003 | 406 | scopes not grantable | A scope the human does not hold, or one that is not issuable at all (e.g. userdetails:*:rwa, fileshare:*:rw) |
158000 | 403 | "The requested scopes exceed this organization's credential policy." | A requested scope exceeds the governing org's credential_policy (error.params.reason credential_policy_mode or credential_policy_scope) — see Org Credential Policy below |
158000 | 403 | "This organization's credential policy could not be read, so no credential may be issued for it." | The governing org's stored credential_policy is corrupt (error.params.reason credential_policy_unreadable) — permanent |
158000 | 403 | "This grant names an organization that no longer exists." | The requested grant names an entity whose owning organization has been deleted (error.params.reason credential_policy_org) — permanent |
158000 | 503 | "The organization credential policy is temporarily unavailable. Please try again shortly." | The governing org, or its policy, could not be READ (error.params.reason credential_policy_unavailable) — the one case worth retrying |
Notes
- 2FA verification is required if 2FA is enabled.
- The full key value is only returned at creation time, as the top-level
api_keystring. Subsequent reads return masked versions. Theadminandlegacyflags appear on thekeyobject create returns as well as onGET,POST(update) and the list endpoint. - A key can never be minted broader than the credential minting it (see the issuance checks above). Widening therefore has to be done from a signed-in web session.
GET /current/user/auth/key/{key_id}/
Get details of an API key (key value is masked).
Auth: Required (JWT, scope: user or admin)
Scope visibility. A write-capable account-wide credential (user:*:rw or user:*:rwa), a legacy key, and a signed-in web session see every key on the account. Every other credential — one created with entity scopes, and also user:*:r — sees only the keys its own grant contains — the same containment rule POST /current/user/auth/key/ applies to a mint. A key outside that grant is not visible and answers 403 10175, exactly as before. This is what lets a scoped agent inspect and revoke the keys it creates; it never widens what a credential can reach.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {key_id} | string | Yes | The API key's unique identifier. |
Request Example
curl -X GET "https://api.fast.io/current/user/auth/key/key_12345/" \
-H "Authorization: Bearer {jwt_token}"
Success Response (200 OK)
{
"result": true,
"api_key": {
"id": "key_12345",
"api_key": "****************************ab12",
"memo": "CI/CD Pipeline Key",
"created": "2024-01-15 10:30:00 UTC",
"scopes": "[\"workspace:1234567890123456789:rw\"]",
"agent_name": "my-agent",
"expires": "2026-12-31 23:59:59 UTC",
"admin": false,
"legacy": false
}
}
Response Fields
| Field | Type | Description |
|---|---|---|
| response.api_key.id | string | Unique key identifier. |
| response.api_key.api_key | string | Masked API key (only last 4 characters visible). |
| response.api_key.memo | string | Key description/label. |
| response.api_key.created | string | Key creation timestamp in UTC. |
| response.api_key.scopes | string or null | JSON array of scope strings. null only on a legacy key that declares no scopes claim at all — newly created keys always store an explicit list, ["user:*:rw"] when none was supplied. |
| response.api_key.agent_name | string or null | Agent/application name, or null if not set. |
| response.api_key.expires | string or null | Expiration datetime in canonical Y-m-d H:i:s UTC format, or null for no expiration. |
| response.api_key.admin | boolean | Whether the key carries at least one rwa scope, i.e. whether it can perform administrative operations (still capped by the human's live role). |
| response.api_key.legacy | boolean | Whether the key declares no scopes claim at all. A legacy key behaves as user:*:rw: whole-account read and write, no administration, no account settings. legacy never implies admin, and admin never implies legacy. |
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
10019 | 406 | "You provided an invalid Token to get details of." | Invalid key ID format |
10175 | 403 | "The scope of your credentials are not sufficient." | The key exists and is yours, but its scopes are not contained in your credential's grant — see Scope visibility above |
| (none) | 404 | no error body — result: false only | Key does not exist at all — no error.code is returned in this case; a key that exists but belongs to a different user instead returns error.code 199646 with the same "The API Key was not found." message |
POST /current/user/auth/key/{key_id}/
Update an existing API key's memo, scopes, agent_name, and/or expires.
Auth: Required (JWT, scope: user or admin)
The update verb is POST. There is no PUT on this path.
Containment is re-checked on every update, including a metadata-only edit such as changing expires or memo: the key's stored scopes are the effective set being re-authorized, so they must still be covered by the credential making the request. A request that would leave the key broader than its issuer is refused with 403 and 10768 (error.params.reason: scope_exceeds_issuer). A browser login session is unbounded and skips that check.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {key_id} | string | Yes | The API key's unique identifier. |
Request Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| memo | string | No | Updated label/description for the key. |
| scopes | string | No | JSON array of scope strings. Omit to leave the stored scopes unchanged. Send an empty string or "null" to clear them to the explicit ["user:*:rw"] — whole-account read and write, no administration and no account settings. An explicit empty array ([]) is refused with 406 190363. See scopes handling and the issuance checks above. |
| agent_name | string | No | Agent/application name. Send empty string or "null" to clear. Max 128 characters. |
| expires | string | No | Expiration datetime. Accepts any strtotime-compatible value; canonical form is Y-m-d H:i:s UTC (e.g. 2026-12-31 23:59:59 UTC). Must be in the future. Send empty string or "null" to clear (no expiration). |
Request Example
curl -X POST "https://api.fast.io/current/user/auth/key/key_12345/" \
-H "Authorization: Bearer {jwt_token}" \
-d 'scopes=["org:1234567890123456789:r"]' \
-d "agent_name=updated-agent"
Success Response (200 OK)
{
"result": true,
"api_key": {
"id": "key_12345",
"api_key": "****************************ab12",
"memo": "CI/CD Pipeline Key",
"created": "2024-01-15 10:30:00 UTC",
"scopes": "[\"org:1234567890123456789:r\"]",
"agent_name": "updated-agent",
"expires": null,
"admin": false,
"legacy": false
}
}
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
187851 or 100527 | 404 | "The API Key was not found." | 187851 when the key does not exist at all; 100527 when it exists but belongs to another user |
121158 / 107184 / 163622 | 406 | Various | 121158 invalid scopes JSON (malformed, an object, a non-string member, or an invalid scope string); 107184 invalid agent_name; 163622 invalid/past expires |
190363 | 406 | "The scopes provided must contain at least one scope." | scopes was an explicit empty array ([]) |
10770 | 403 | "Your credential is read-only and is not authorized to make changes." | The calling credential holds no write-capable scope anywhere — user:*:r, or a set every entry of which is :r. error.params.reason is scope_write_required |
10768 | 403 | "The requested scopes are broader than the credential making this request." | The key's effective scopes exceed what the calling credential holds — re-checked on every update, including metadata-only edits. error.params.reason is scope_exceeds_issuer |
185003 | 406 | scopes not grantable | A scope the human does not hold, or one that is not issuable at all (e.g. userdetails:*:rwa, fileshare:*:rw) |
158000 | 403 | "The requested scopes exceed this organization's credential policy." | The key's effective scopes exceed the governing org's credential_policy (error.params.reason credential_policy_mode or credential_policy_scope) — see Org Credential Policy below |
158000 | 403 | "This organization's credential policy could not be read, so no credential may be issued for it." | The governing org's stored credential_policy is corrupt (error.params.reason credential_policy_unreadable) — permanent |
158000 | 403 | "This grant names an organization that no longer exists." | The effective scopes name an entity whose owning organization has been deleted (error.params.reason credential_policy_org) — permanent |
158000 | 503 | "The organization credential policy is temporarily unavailable. Please try again shortly." | The governing org, or its policy, could not be READ (error.params.reason credential_policy_unavailable) — the one case worth retrying |
Notes
- Only the fields you send are updated; omitted fields remain unchanged.
- Send empty string or
"null"to clear a nullable field. Clearingscopesrestores the explicit["user:*:rw"], not an unscoped key. - The response object carries the
adminandlegacybooleans described underGET /current/user/auth/key/{key_id}/. - 2FA verification is required if 2FA is enabled.
- The update path is taken only when
{key_id}is a well-formed key identifier. If{key_id}is not a valid key-ID, the request is NOT rejected — it falls through to the create path (seePOST /current/user/auth/key/) and mints a brand-new key. Always confirm the{key_id}you send is valid before treating a call as an update. - The org credential-policy check is not limited to this update call — it is re-evaluated on every later request the key makes, not only on a scope edit. See Org Credential Policy below.
DELETE /current/user/auth/key/{key_id}/
Delete an API key.
Auth: Required (JWT, scope: user or admin)
Scope visibility. A write-capable account-wide credential (user:*:rw or user:*:rwa), a legacy key, and a signed-in web session see every key on the account. Every other credential — one created with entity scopes, and also user:*:r — sees only the keys its own grant contains — the same containment rule POST /current/user/auth/key/ applies to a mint. A key outside that grant is not visible and answers 403 10175, exactly as before. This is what lets a scoped agent inspect and revoke the keys it creates; it never widens what a credential can reach.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {key_id} | string | Yes | The API key's unique identifier. |
Request Example
curl -X DELETE "https://api.fast.io/current/user/auth/key/key_12345/" \
-H "Authorization: Bearer {jwt_token}"
Success Response (200 OK)
{
"result": true
}
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
10019 | 406 | "You provided an invalid Token to Delete." | Invalid key ID format |
10020 | 404 | "You provided a Token that was not found." | Key not found or belongs to another user |
10021 | 500 | "There was an error deleting the API Key." | Internal deletion failure |
10175 | 403 | "The scope of your credentials are not sufficient." | The key exists and is yours, but its scopes are not contained in your credential's grant — see Scope visibility above |
Notes
- 2FA verification is required if 2FA is enabled.
- Returns "not found" if the key belongs to a different user (does not reveal ownership).
GET /current/user/auth/keys/
List all API keys for the user.
Auth: Required (JWT, scope: user or admin)
Scope visibility. A write-capable account-wide credential (user:*:rw or user:*:rwa), a legacy key and a signed-in web session list every key on the account. Every other credential — one created with entity scopes, and also user:*:r — lists only the keys its own grant contains — the same containment rule POST /current/user/auth/key/ applies to a mint. Keys outside that grant are omitted from api_keys and are not counted in results; a narrowed credential that owns no such key receives results: 0 and api_keys: null, the same answer an account with no keys receives.
This is what lets a scoped agent enumerate and revoke the keys it creates. It never widens what a credential can reach: a key it could not have minted stays invisible, and a credential whose scopes cannot be read at all is still refused outright with 403 10175.
Request Example
curl -X GET "https://api.fast.io/current/user/auth/keys/" \
-H "Authorization: Bearer {jwt_token}"
Success Response (200 OK)
{
"result": true,
"results": 2,
"api_keys": [
{
"id": "key_12345",
"api_key": "****************************ab12",
"memo": "CI/CD Pipeline Key",
"created": "2024-01-15 10:30:00 UTC",
"scopes": "[\"workspace:1234567890123456789:rw\"]",
"agent_name": "my-agent",
"expires": "2026-12-31 23:59:59 UTC",
"admin": false,
"legacy": false
},
{
"id": "key_67890",
"api_key": "****************************cd34",
"memo": "Backup Script",
"created": "2024-02-20 14:00:00 UTC",
"scopes": null,
"agent_name": null,
"expires": null,
"admin": false,
"legacy": true
}
]
}
No Keys Response (200 OK)
{
"result": true,
"results": 0,
"api_keys": null
}
Response Fields
| Field | Type | Description |
|---|---|---|
| response.results | integer | Number of API keys. |
| response.api_keys | array or null | Array of API key objects, or null if none exist. |
| response.api_keys[].id | string | Unique key identifier. |
| response.api_keys[].api_key | string | Masked API key (only last 4 characters visible). |
| response.api_keys[].memo | string | Key description/label. |
| response.api_keys[].created | string | Key creation timestamp in UTC. |
| response.api_keys[].scopes | string or null | JSON array of scope strings. null only on a legacy key that declares no scopes claim at all. |
| response.api_keys[].agent_name | string or null | Agent/application name, or null if not set. |
| response.api_keys[].expires | string or null | Expiration datetime in canonical Y-m-d H:i:s UTC format, or null for no expiration. |
| response.api_keys[].admin | boolean | Whether the key carries at least one rwa scope. |
| response.api_keys[].legacy | boolean | Whether the key declares no scopes claim at all (it then behaves as user:*:rw). |
Org Credential Policy
Organizations on the Enterprise plan can cap what API keys and OAuth grants issued inside the org may hold, and constrain them on every later request, not only when they are issued. This is the org's credential_policy setting — see Credential Policy in the Organizations reference for the envelope shape, the org update/details fields, and capabilities.credential_policy_api_keys / _oauth. This section documents what it changes for API keys.
These refusal reasons carry the ordinary scope-error params (entity_type, entity_id, required_access_mode, current_access_mode, credential_type, and reason). Most are 403; credential_policy_unavailable is always 503, and credential_policy_sso can be either, depending on which of the two things it is reporting:
Branch on params.reason, never on the numeric code. A code identifies the individual place the refusal was raised, not the reason it carries — one reason is raised from several places and therefore has several codes, and more can appear at any time without notice. The codes below are examples for correlating a single response with a support request; they are not an enumeration, and matching on them will miss refusals your client must handle.
params.reason | Example code | HTTP | When |
|---|---|---|---|
credential_policy_mode | 169015, 133221 | 403 | The access mode the credential holds for an entity exceeds the org's configured ceiling (max_mode) for that credential family. This inverts the ordinary scope error above, where the held mode is too low — here it is too high. Do not render it as "insufficient scope." |
credential_policy_scope | 169015, 133221 | 403 | The credential names an entity type the org's policy does not allow that family to hold at all (scope_types). These two reasons share their codes: the same check reports whichever of them applies. |
credential_policy_sso | 117677, 187061 | 403 | Single sign-on is required for the organization and the credential's owner is not exempt. |
credential_policy_sso | 167474, 130525 | 503 | The organization's sign-on policy could not be read — the check itself failed, not the sign-on requirement. Retry. |
credential_policy_unavailable | 142426, 114359, 139632, 171492, 145667, 120029 | 503 | The organization's credential policy could not be read. Transient — the one case where retrying is the correct client behaviour. |
credential_policy_unreadable | 138730, 177544 | 403 | The organization's stored credential policy is corrupt. Permanent — retrying will not help; an administrator must rewrite the policy. |
credential_policy_org | 155387, 127015, 131925 | 403 | The resource's owning organization no longer exists. Permanent — retrying will never come good. |
Note the same credential_policy_sso reason on two HTTP statuses: the status is what separates "sign on through your organization" (403) from "we could not tell, try again" (503). Read both together.
required_access_mode and current_access_mode can both be null on credential_policy_unavailable, credential_policy_unreadable, and credential_policy_org — when the org's policy could not be read, or the stored policy will not parse, there is no ceiling left to quote. Treat null as unknown, never as a mode, and never respond to it by re-minting the credential: on these three reasons the credential was never the problem.
A refusal of an account-wide grant is reported against the organization that refused it. When the credential holding the refused grant is user:*:*, entity_type is org and entity_id is the id of the organization whose policy refused it — not entity_type user with a null entity_id. Read it as "this organization's ceiling is lower than your account-wide grant," never as "your credential is refused everywhere."
Where these are raised.
- At issuance —
POST /current/user/auth/key/andPOST /current/user/auth/key/{key_id}/check the requested (or, on update, the effective) scopes against the calling user's governing org'sapi_keyspolicy, as a fourth issuance check, run after the three above. Refusal:403,error.params.reasoncredential_policy_modeorcredential_policy_scope, "The requested scopes exceed this organization's credential policy." Three other outcomes are possible at issuance, and each carries its OWN reason rather than a scope verdict: a corrupt stored policy refuses issuance entirely (403,credential_policy_unreadable); a grant naming an organization that no longer exists is refused permanently (403,credential_policy_org); and a policy that could not be READ answers503(credential_policy_unavailable) rather than a silent pass — the only one of the four worth retrying. - At every later request — unlike the checks above, which run once at mint, the org's
credential_policyis re-checked on every authenticated request an API key or OAuth token makes that resolves to a governing organization. A key that was valid when it was minted can start failing this check the moment an admin tightens the org's policy (credential_policy_mode/credential_policy_scope). A stored policy that is corrupt refuses every request in that family against that org (credential_policy_unreadable), and a resource whose owning organization no longer exists is refused the same way (credential_policy_org) — both permanent, and retrying will not help either one. A policy that could not be read at all is the transient case (credential_policy_unavailable) and answers503rather than a401— the one case worth retrying. See the table above for the code and HTTP status each reason carries.
This reaches credentials that already exist. A stored API key or OAuth grant that predates a policy, or predates a tightening of one, can begin returning 403 on its very next call with no change to the key itself, no revocation event, and no grace period — the policy is evaluated live, against the credential's currently-held authority. Treat credential_policy_mode / credential_policy_scope the way any other scope refusal is treated: surface it as "this credential no longer meets your organization's policy," route to key management, and never treat it as a sign-in failure. A signed-in browser session is never affected — it carries no scopes claim and is exempt from credential_policy by construction, the same way it is exempt from the ordinary scope checks above.
What is evaluated is only the authority actually used for the request — the concrete or wildcard grant that satisfied this request's check, never the credential's whole scope set and never an inherited parent grant. A key holding org:A:rwa and org:B:r is checked against org A's policy only when it acts in org A.
What is not covered by the request-time check. Three collection endpoints that list an account's own orgs and shares in bulk (orgs/list, orgs/all, shares/all) resolve no single governing org per result and are permanently outside this check. A public File Share single-file link carries no API key and no account session, so it is not an org-governed credential and is never subject to credential_policy, at issuance or at request time. A small number of late-loaded cloud write-back endpoints are checked, if at all, at their own endpoint rather than on this shared path.
Enterprise SSO enforcement. When the credential owner's organization has SSO set to mode=required and the owner is not exempt, a separate request-time check can also refuse with reason = credential_policy_sso — see Credential-request enforcement in the SSO reference. A lookup failure on that check is 503, never a silent pass and never a 401.
Two-Factor Authentication (2FA)
GET /current/user/auth/2factor/
Get current 2FA status.
Auth: Required (JWT, scope: user or admin)
Request Example
curl -X GET "https://api.fast.io/current/user/auth/2factor/" \
-H "Authorization: Bearer {jwt_token}"
Success Response (200 OK)
{
"result": true,
"state": "enabled",
"totp": false
}
Response Fields
| Field | Type | Description |
|---|---|---|
| response.state | string | 2FA status: "enabled" (fully verified), "unverified" (added but not verified), or "disabled" (not configured). |
| response.totp | boolean | Whether the 2FA method is TOTP (Time-based One-Time Password). |
POST /current/user/auth/2factor/{channel}/
Enable 2FA on the account.
Auth: Required (JWT, scope: user or admin)
Enrolling in 2FA is an account-settings operation and requires userdetails:*:rw. A credential that does not explicitly hold that scope is refused with 403 and 10769 (error.params.reason: userdetails_scope_required) — user:*:rw, user:*:rwa, entity-scoped keys and legacy (unscoped) keys alike. A browser login session passes. The 2FA login challenge endpoints are not gated this way.
Path Parameters
| Parameter | Type | Required | Default | Description |
|---|---|---|---|---|
| {channel} | string | No | sms |
2FA delivery channel: sms, call, whatsapp, or totp. |
Request Example
curl -X POST "https://api.fast.io/current/user/auth/2factor/sms/" \
-H "Authorization: Bearer {jwt_token}"
Success Response for SMS/Voice/WhatsApp (202 Accepted)
{
"result": true
}
Success Response for TOTP (202 Accepted)
{
"result": true,
"binding_uri": "otpauth://totp/fast.io:jane@example.com?secret=ABCDEF..."
}
Response Fields (TOTP only)
| Field | Type | Description |
|---|---|---|
| response.binding_uri | string | TOTP provisioning URI for QR code display. |
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
10167 | 409 | "2Factor already added, please remove first." | 2FA already enabled |
10173 | 406 | "An invalid channel was supplied." | Invalid channel name |
10168 | 406 | "2Factor cannot be added, you need a valid phone_number and phone_country..." | No phone number configured |
Notes
- User must have a valid phone number and country code on their account before enabling 2FA (for non-TOTP channels).
- After adding 2FA, it enters
unverifiedstate. Must complete verification viaPOST /current/user/auth/2factor/verify/{token}/. - For TOTP, display the
binding_urias a QR code for the user to scan.
POST /current/user/auth/2factor/verify/{token}/
Verify a 2FA setup code to confirm enrollment. Transitions 2FA from unverified to enabled state.
Auth: Required (JWT, scope: user or admin)
This is the enrollment step, performed from a fully signed-in session, so the limited twofactor-scope token issued during a 2FA sign-in is refused here with 10175 — that token belongs to POST /current/user/auth/2factor/auth/{token}/, which completes a login rather than confirming enrollment.
Verifying 2FA enrolment is an account-settings operation and requires userdetails:*:rw. A credential that does not explicitly hold that scope is refused with 403 and 10769 (error.params.reason: userdetails_scope_required) — user:*:rw, user:*:rwa, entity-scoped keys and legacy (unscoped) keys alike. A browser login session passes.
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {token} | string | Yes | 2FA verification code (e.g., 6-digit code). |
Request Example
curl -X POST "https://api.fast.io/current/user/auth/2factor/verify/123456/" \
-H "Authorization: Bearer {jwt_token}"
Success Response (202 Accepted)
{
"result": true
}
Success Response — enrolment token, factor persisted (202 Accepted)
When the inbound token was an enrol-scoped enrolment token (see Interactive Login & Enrolment) AND this call actually verified the code and persisted the enrolment, the response carries a full session instead of the plain accepted body:
{
"result": true,
"expires_in": 86400,
"auth_token": "{jwt_token}"
}
auth_token is a complete user-scope session — the enrolment interstitial is over. It is also delivered as an HttpOnly cookie when x-ve-session-cookie was sent, subject to the same opt-in as GET /current/user/auth/. This does not happen when the account was already enrolled (that branch returns the plain accepted body without checking the submitted code). Nor does it happen when the code verified but the enrolment write did not land — that arm answers 503 with a machine-readable reason instead, documented immediately below. An ordinary user-scoped self-service caller confirming their own 2FA setup is unaffected: it already holds a session and gets the plain response.
Enrolment NOT saved (503 Temporarily Unavailable)
A code the delivery side accepted, whose enrolment then failed to save, is its own outcome and is not the same as a wrong code. Either write behind the confirmation can fail, and both answer 503 carrying the same error.params.reason = two_factor_enrolment_not_persisted:
- The stored factor could not be read or saved — "Your code could not be confirmed because the enrolment could not be saved. Request a new code and confirm again." This one is reachable by every caller, including an ordinary
user-scoped self-service one adding 2FA to their own account. - The account was not marked as enrolled — "Your code was accepted but the enrolment could not be saved. Request a new code and confirm again." This one is reachable only by a caller upgrading an enrolment token, and no session is minted.
The correct client action for both is to REQUEST A NEW CODE and confirm again — not to resubmit the same one. The code was right, and by this point it may already have been consumed on the delivery side, so re-sending it can only fail again, for ever, with the account left short of enrolled. Send GET /current/user/auth/2factor/send/{channel}/ (or read a fresh code from the authenticator app for totp) and call this endpoint again with the new code.
This is deliberately distinguishable from the plain verification failure below. That 406 means what it says — the submitted code itself was not accepted — it carries no reason, and it is the one failure retried by asking the user to re-enter the code they already have.
Verification Failed (406 Not Accepted)
{
"result": false
}
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
10173 | 406 | "An invalid token was supplied to validate." | Invalid token format |
10170 | 406 | "2Factor is not enabled." | 2FA not configured |
| — | 503 | "Your code could not be confirmed because the enrolment could not be saved. Request a new code and confirm again." | Every caller. The stored factor could not be read or saved, so the confirmation did not complete (params.reason = two_factor_enrolment_not_persisted). Request a NEW code and confirm again — this is NOT a wrong code, and the submitted one may already have been consumed |
| — | 503 | "Your code was accepted but the enrolment could not be saved. Request a new code and confirm again." | Enrolment tokens only. The submitted code verified, but the account write confirming the factor did not land, so no session was minted (params.reason = two_factor_enrolment_not_persisted). Request a NEW code and confirm again — the submitted one has already been consumed and resubmitting it can only fail again |
Notes
- If 2FA is already in the
enabledstate, returns success without modification — this is also true for an inboundenroltoken, and it does NOT mint a session, since the caller proved nothing on this call. - This is the final step of the 2FA setup flow, and — for an
enroltoken — also the final step of the enrolment flow. See Interactive Login & Enrolment below. two_factor_enrolment_not_persistedis the one refusal you must not retry as-is. It covers both enrolment writes and is the whole reason a503here is not a406: the406is retried by asking the user to re-enter the code, this one only by obtaining a fresh code first. Branch onerror.params.reason, never on the numeric code.
POST /current/user/auth/2factor/auth/{token}/
Authenticate with a 2FA code. Upgrades a limited-scope JWT to a full-scope JWT.
Auth: Required (JWT, scope: user, twofactor, or admin)
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {token} | string | Yes | Valid 2FA verification code (e.g., 6-digit TOTP or SMS code). |
Request Headers
| Header | Type | Required | Default | Description |
|---|---|---|---|---|
| x-ve-session-cookie | boolean | No | (absent) | Browser clients only. Same meaning and vocabulary (1, true, yes) as on GET /current/user/auth/: the full-scope token minted here is ALSO delivered in an HttpOnly, Secure, SameSite=Lax cookie scoped to the site's registrable domain (e.g. fast.io). Only honoured as a request header. |
Request Example
curl -X POST "https://api.fast.io/current/user/auth/2factor/auth/123456/" \
-H "Authorization: Bearer {twofactor_jwt_token}"
A browser client that opted into the session cookie at sign-in sends the same header again here:
curl -X POST "https://api.fast.io/current/user/auth/2factor/auth/123456/" \
-H "Authorization: Bearer {twofactor_jwt_token}" \
-H "x-ve-session-cookie: 1" \
--cookie-jar cookies.txt
Success Response (200 OK)
{
"result": true,
"expires_in": 86400,
"auth_token": "{jwt_token}"
}
Response Fields
| Field | Type | Description |
|---|---|---|
| response.expires_in | integer | JWT expiration time in seconds. |
| response.auth_token | string | New JWT with full user scope. |
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
10173 | 406 | "An invalid token was supplied to authenticate." | Invalid token format |
10172 | 406 | "2Factor is not enabled on this account." | 2FA not enabled |
10174 | 406 | "The supplied token failed to authenticate." | Wrong 2FA code |
10009 | 401 | "Internal Error." | JWT creation failure |
Notes
- This is where a 2FA-enabled account gets its session cookie. Sign-in returns a pre-2FA token and sets no cookie, so a browser client that sent
x-ve-session-cookieat sign-in must send the same header again here; otherwise there is no cookie forPOST /current/user/auth/bootstrap/to read.
DELETE /current/user/auth/2factor/{token}/
Disable (remove) 2FA from the account.
Auth: Required (JWT, scope: user or admin)
Path Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| {token} | string | Yes | Valid 2FA verification code. Required only if 2FA is in enabled (verified) state. |
Request Example
curl -X DELETE "https://api.fast.io/current/user/auth/2factor/123456/" \
-H "Authorization: Bearer {jwt_token}"
Success Response (200 OK)
{
"result": true
}
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
10173 | 406 | "An invalid token was supplied, valid token required to remove 2Factor." | Invalid token format |
10174 | 406 | "The supplied token failed to authenticate." | Token verification failed |
10169 | 500 | "2Factor could not be removed, please contact support." | Internal removal failure |
Notes
- If 2FA is
enabled(verified), a valid 2FA code is required to remove it. - If 2FA is
unverified, it can be removed without a code. - If 2FA is already disabled, returns success.
2FA Code Delivery Endpoints
Request a 2FA code via different channels. All require auth (accepts user, twofactor, admin, or enrol JWT scope — the last one lets an enrolment token request a code for the factor it is enrolling; see Interactive Login & Enrolment below).
/current/user/auth/2factor/send/sms/
Send code via SMS
/current/user/auth/2factor/send/call/
Send code via voice call
/current/user/auth/2factor/send/whatsapp/
Send code via WhatsApp
Success Response (202 Accepted)
{
"result": true
}
Failure Response (406 Not Accepted)
{
"result": false
}
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
10011 | 401 | "Your credentials were not supplied or invalid." | Invalid JWT |
10175 | 403 | "The scope of your credentials are not sufficient." | Wrong JWT scope |
10170 | 406 | "2Factor is not enabled." | 2FA not configured on account |
Notes
- 2FA must be enabled (or in unverified state) for codes to be sent.
- Returns
result: falseif the code send fails (e.g., invalid phone number).
Complete 2FA Flows
Complete 2FA Login Flow
1. GET /current/user/auth/
- Send email:password via HTTP Basic Auth
- Response includes "2factor": true and limited-scope auth_token
2. GET /current/user/auth/2factor/send/sms/ (or /call/ or /whatsapp/)
- Request a fresh 2FA code
- Uses the limited-scope (twofactor) JWT
3. POST /current/user/auth/2factor/auth/{code}/
- Submit the 2FA code
- Receive a new JWT with full "user" scope
- Use this token for all subsequent requests
Complete 2FA Setup Flow
1. POST /current/user/auth/2factor/{channel}/
- Choose channel: sms, call, whatsapp, or totp
- Phone number must be configured on account (for non-TOTP)
- State becomes "unverified"
- For TOTP: receive binding_uri for QR code
2. Receive code via selected channel (or scan QR code for TOTP)
3. POST /current/user/auth/2factor/verify/{token}/
- Submit the verification code
- State becomes "enabled"
- 2FA is now active on the account
Complete 2FA Removal Flow
1. DELETE /current/user/auth/2factor/{code}/
- Must provide valid 2FA code if state is "enabled"
- Can remove without code if state is "unverified"
- 2FA is fully removed from the account
Interactive Login & Enrolment (org-required 2FA)
An organization can require its members to hold a second factor before they may sign in — see Require-2FA Policy in the Organizations reference for the org-level setting. This section is what a client sees when that policy applies to an account that has not yet enrolled a factor, at the two login surfaces the policy governs.
Scope — password and social login only. The policy is checked at the moment GET /current/user/auth/ (Basic Auth) or POST /current/user/sso/signin/{provider}/ (personal Google/Microsoft sign-in) mints a session, because those are the only two surfaces where a factor can be demanded at that moment. It has no effect on API keys, OAuth grants, or MCP tokens — none of them is challenged for a factor at request time, policy or no policy. Enterprise SSO (POST /current/user/sso/exchange/) is unaffected regardless of this or any other org's requirement — see the note on that endpoint below.
The signal: enrol_required. Both GET /current/user/auth/ and the social sign-in callback always carry a boolean enrol_required field — present and false on every ordinary login. It is true only when: the account holds no factor, AND at least one org the account belongs to requires one. When true:
"2factor"is alsotrueon the same response, so a client that only reads2factorstill shows its existing code-entry screen instead of treating the login as complete — it will not be ABLE to enrol from that screen, but it will not silently drop the user into a full session either.- The issued token is an enrolment token, not a session: no session cookie is set even when
x-ve-session-cookiewas sent, and the token is scope-limited to exactly the enrolment surfaces below.
What the enrolment token can do — five endpoints, nothing else:
| Endpoint | Purpose |
|---|---|
POST /current/user/auth/2factor/{channel}/ | Add a factor — every channel, including totp (the only channel available to an account with no stored phone number). |
POST /current/user/auth/2factor/verify/{token}/ | Confirm the factor. On success this call also upgrades the enrolment token into a full session — see the response documented on that endpoint above. |
GET /current/user/auth/2factor/send/sms/ | Request a code for the factor being enrolled. |
GET /current/user/auth/2factor/send/call/ | Same, via voice call. |
GET /current/user/auth/2factor/send/whatsapp/ | Same, via WhatsApp. |
It is refused everywhere else — GET/DELETE /current/user/auth/2factor/, POST /current/user/auth/2factor/auth/{token}/, POST /current/user/update/, and GET /current/user/auth/check/ all reject it with 403 and a params.reason (never a 401). The enrolment interstitial takes the user's identity from the login response, not from any of those calls.
The refusal reason is two_factor_enrolment_only, and it is always a 403 — never a 401. That distinction is the whole point: the credential is valid, correctly signed and unexpired, and is simply not admitted on the endpoint that was called. A client that reads it as 401 will discard a live enrolment token and strand the user mid-enrolment with no way back but a fresh sign-in. The refusal carries the ordinary scope-error params, with this shape:
"params": {
"reason": "two_factor_enrolment_only",
"entity_type": "userdetails",
"entity_id": null,
"required_access_mode": null,
"current_access_mode": null
}
required_access_mode and current_access_mode are both null, and that is correct rather than missing data: no access mode would have made this call succeed, because the enrolment token is restricted by which endpoint it may call, not by how much authority it holds. Treat null as not applicable — never re-mint the credential asking for a wider mode. Branch on reason, never on the numeric code. The remedy is always the same: finish enrolling the factor, then use the full session the confirmation returns.
The flow — one code, not two:
1. GET /current/user/auth/ (or the social callback)
- Response: "2factor": true, "enrol_required": true, auth_token = enrolment token
2. POST /current/user/auth/2factor/{channel}/ (with the enrolment token)
- Choose channel: sms, call, whatsapp, or totp
- State becomes "unverified"
3. GET /current/user/auth/2factor/send/{channel}/ (skip for totp -- scan the QR code instead)
- Request a fresh code
4. POST /current/user/auth/2factor/verify/{token}/ (with the enrolment token)
- Submit the code
- Response: full session -- auth_token, expires_in -- not the plain accepted body
A client that predates enrol_required is not broken by this policy, only stalled: it sees "2factor": true exactly as it does for an already-2FA-enabled account and shows its code screen, but has no code to submit — it cannot enrol from that screen. Reading enrol_required is required to route such a user into an enrolment UI instead.
What this policy does NOT do — four limits a login client must not assume away:
- It governs interactive password and social login, and nothing else. It is never evaluated for an API key, an OAuth grant, or an MCP token: none of them is challenged for a factor at request time, whatever an org requires.
- A session minted by enterprise single sign-on is compliant regardless of this policy, in this org or any other — the organization's identity provider owns that factor. The exchange response carries neither
2factornorenrol_required, so there is no field to branch on there. - Turning the requirement on does not end existing sessions immediately. The sessions of users who become required and hold no factor are revoked asynchronously, so there is a window between the administrator's write succeeding and those sessions ending. A token that was issued before the flip can keep working for a short while afterwards; that is expected, not a bug to work around.
- A session minted before its holder enrolled is never revoked. An already-enrolled user is left alone by that revocation — enrolment already satisfies the requirement — and nothing on the token distinguishes the session they held before they enrolled from one issued after. Do not rely on the flip to invalidate it.
See Require-2FA Policy in the Organizations reference for the org-side setting, its refusals, and the populations the revocation covers.
Enterprise SSO Sign-In
Enterprise SSO signs a user in against their organization's own identity provider instead of against a Fastio password or a personal social account. An org administrator connects one identity provider (OpenID Connect or SAML 2.0) to the org and proves ownership of the email domains it speaks for; see the Enterprise SSO reference for that configuration surface. This section is the sign-in flow — how a browser is handed to that provider and comes back holding a JWT.
This is not GET|POST /current/user/sso/signin/{provider}/. That route is the personal Google / Microsoft convenience sign-in available to any individual account, and it needs no org configuration. The routes below are org-wide and organization-configured, even though they sit beside it under /current/user/sso/.
The flow
1. discover POST /current/user/sso/discover/ (optional) an email address -> which org, if any
2. start GET /current/user/sso/start/ -> redirect_url at the organization's provider
3. provider the user authenticates at their own identity provider
4. callback the provider returns the browser to Fastio, which redirects it to
{your_origin}/signin/sso and sets a short-lived, single-use handoff cookie
5. exchange POST /current/user/sso/exchange/ empty body, cookies included -> JWT
Steps 1, 2 and 5 are calls your application makes. Steps 3 and 4 happen in the browser, and your application never calls the callback routes itself — they are the stable URLs the organization's administrator registers inside the identity provider.
Two browser requirements, both non-negotiable:
- The browser key.
GET /current/user/sso/start/requires the browser-key credential the platform already issues (theve_br_keycookie, or thex-ve-br-keyrequest header where a client forwards it). The sign-in is bound to it, andPOST /current/user/sso/exchange/re-checks the binding — which is what makes a stolen handoff useless in a different browser. Absent,startrefuses; mismatched, the exchange refuses withbrowser_mismatch. - Credentials on the exchange. The handoff arrives as an HttpOnly cookie, so the exchange request must be made with credentials included (
credentials: "include", or-bin curl) from an origin on the same registrable domain as the API host. NoAuthorizationheader is sent — there is no session yet.
What the handoff is. On a successful callback, Fastio redirects the browser to {your_origin}/signin/sso carrying nothing in the URL and sets a ve_sso_ex cookie: HttpOnly, Secure, SameSite=Lax, Path=/, scoped to the registrable domain, and valid for 60 seconds and exactly one use. Because it never appears in a Location header, a URL or browser history, it is not exposed in any of the places a redirect is recorded. Your landing route reads nothing from the URL — it simply posts the exchange.
Failures come back in the fragment. A callback that cannot complete redirects to {your_origin}/signin/sso#error={reason}&org={org_domain} and sets no cookie. A reason string carries nothing sensitive, which is why it is allowed in a URL where the handoff is not. Clear any stale #error= fragment when your landing route mounts. The org parameter is present only once the organization is known — a refusal that happens before that, such as a sign-in record that has expired or already been used, carries the reason alone.
A third outcome lands on the same route: an administrator's test sign-in. An organization's administrator can run a test sign-in while SSO is still switched off (see the Enterprise SSO reference, The Test Sign-In). It completes the whole protocol and then deliberately stops — no account, no membership, no identity, no session and no handoff cookie. The browser arrives at:
{your_origin}/signin/sso?sso-test=1&ok=<0|1>&message=<urlencoded>
in the query string, not the fragment. Check for sso-test=1 before your sign-in handling runs. It is neither of the two sign-in outcomes: there is nothing to exchange, and a failure here is a test result rather than a login error. Show the ok and message values as the result of the test, and let the administrator read the full outcome from GET /current/org/{org_id}/sso/. Treat an unrecognised message as a generic failure — the values are a closed vocabulary.
POST /current/user/sso/discover/
"I typed this email address — where do I sign in?" Maps an email domain to the organization that federates it, for a platform-wide sign-in page that does not yet know which organization the user belongs to.
Auth: None (IP-throttled)
Request Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| string | Yes | The address the user typed. Max 320 characters. |
Request Example
curl -X POST "https://api.fast.io/current/user/sso/discover/" \
--data-urlencode "email=user@acme.com"
Success Response — the domain federates (200 OK)
{
"result": true,
"sso": true,
"mode": "optional",
"org": {
"domain": "acme-corp",
"name": "Acme Corporation"
},
"start_path": "/user/sso/start/?org=acme-corp&login_hint=user%40acme.com"
}
Success Response — the domain does not federate (200 OK)
{
"result": true,
"sso": false
}
Response Fields
| Field | Type | Description |
|---|---|---|
| sso | boolean | Whether this email domain is federated to an organization. |
| mode | string | Present only when sso is true: optional or required. |
| org.domain | string | The organization's URL-safe domain (slug), for the org parameter on start. |
| org.name | string | The organization's display name, for the sign-in button. |
| start_path | string | The path to begin the sign-in, with the typed address already supplied as login_hint. Use it as given rather than composing your own. |
Error Responses
| HTTP Status | params.reason | Cause |
|---|---|---|
| 429 | — | Over the per-IP throttle. The standard x-ve-limit-avail, x-ve-limit-max and x-ve-limit-expires headers and error code 1671 apply, exactly as documented in the global rate-limiting section. |
| 503 | — | The lookup itself could not answer. Retry — this is never reported as sso: false. |
Notes
sso: falseis a constant answer covering three different situations — a domain nobody has claimed, a domain claimed but not yet verified, and a verified domain whose organization has SSO switched off. They are deliberately indistinguishable, so this endpoint cannot be used to map which companies are on the platform.- It never says anything about whether an account exists. The answer is a property of the domain, not of the address.
- A
503is not "no SSO". Treat it as "try again" and keep the user on the SSO path. Rendering an outage as "sign in with your password" would route a federated organization's users around the control their administrator turned on. - The call is optional. An application that already knows which organization it is signing the user into can go straight to
start.
GET /current/user/sso/start/
Begin an enterprise SSO sign-in for one organization. Returns the URL at that organization's identity provider to send the browser to.
Auth: None, but the browser key is required (ve_br_key cookie, or the x-ve-br-key request header). IP-throttled.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| org | string | Yes | The organization's domain (slug) — the same value as org.domain on the discovery and public-details responses. This route does not accept a 19-digit numeric org ID. Max 255 characters. |
| return_url | string | No | An https:// URL on a Fastio-hosted origin. Only its origin is kept — any path, query or fragment is discarded, and the sign-in always lands on {origin}/signin/sso. Must be https, on the default port, and on an allow-listed platform domain; local development origins may include a port. Not echoed back. Defaults to the organization's own origin. Max 1024 characters. |
| login_hint | string | No | The address the user typed. Passed to the identity provider so it can pre-fill, and checked up front against the organization's verified domains. Max 320 characters. |
Request Example
curl -X GET "https://api.fast.io/current/user/sso/start/?org=acme-corp&return_url=https%3A%2F%2Fround-lake.dustinice.workers.dev%3A443%2Fhttps%2Facme-corp.fast.io&login_hint=user%40acme.com" \
-b "ve_br_key={browser_key}"
Success Response (200 OK)
{
"result": true,
"provider": "sso",
"protocol": "oidc",
"redirect_url": "https://idp.example.com/authorize?response_type=code&client_id={client_id}&state={state}"
}
Response Fields
| Field | Type | Description |
|---|---|---|
| provider | string | Always sso, distinguishing an enterprise sign-in from google / microsoft. |
| protocol | string | oidc or saml — which protocol the organization is configured for. Informational; the client treats both identically. |
| redirect_url | string | Where to send the browser. Navigate the top-level window to it — this is a redirect flow, exactly like the social sign-in. Do not fetch it, and do not open it in an iframe. |
Error Responses
| HTTP Status | params.reason | Cause |
|---|---|---|
| 406 | sso_not_configured | No such organization, or it has no usable identity-provider configuration. The two are deliberately the same answer. |
| 406 | sso_disabled | The organization has a configuration but its mode is off. |
| 406 | domain_not_permitted | The login_hint address is not on a domain the organization has verified. |
| 406 | browser_mismatch | No browser key was presented. |
| 503 | — | Temporary failure opening the sign-in. Retry. |
Notes
return_urlis origin-only by design. Keep the destination you want the user to land on in your own client state; the backend has no channel to carry it, and the exchange response does not return one.- A
login_hintis checked before the round trip to the provider, so a user who types a personal address at an organization's sign-in page is told immediately rather than after authenticating. - The route works from any Fastio-hosted origin — a platform sign-in page or an organization's own subdomain — because the organization comes from the
orgparameter, not from the hostname. - Signing in is never plan-gated. An organization whose plan no longer includes Enterprise SSO keeps signing in through the identity provider it already configured; only configuration changes are refused. An administrator who wants to stop using single sign-on steps the mode down.
The identity-provider callbacks
These two routes are the URLs an organization's administrator registers inside their identity provider. Your application does not call them; the browser arrives at them from the provider. They are described here so client authors recognise what happens between start and exchange.
GET /current/user/sso/oidc/callback/ OIDC redirect URI (code + state)
POST /current/user/sso/saml/acs/ SAML assertion consumer service (HTTP-POST binding)
Auth: None. On the OIDC route the state value, and on the SAML route the RelayState value, is the credential: each names a single-use record Fastio created at start, and the organization is resolved from that record alone. Both routes are IP-throttled.
Fastio validates what came back (state, nonce and PKCE for OIDC; signature, audience, InResponseTo, Recipient and Destination for SAML), resolves, links or creates the user's identity, and mints the one-time handoff.
Neither route ever returns a JSON body — a browser is waiting on the other end of it, so every outcome is a redirect:
| Outcome | Redirect | Cookie |
|---|---|---|
| Success | 302 {your_origin}/signin/sso | ve_sso_ex, HttpOnly, Secure, SameSite=Lax, 60 seconds, single use |
| Failure | 302 {your_origin}/signin/sso#error={reason}&org={org_domain} | none |
{your_origin} is the origin of the validated return_url supplied at start, defaulting to the organization's own origin.
Notes
- SAML is SP-initiated only. Every sign-in must begin at
start; a SAML Response that names no live sign-in record is rejected rather than accepted as unsolicited. There is no IdP-initiated ("launch from the provider's app dashboard") path. - There is one shared callback URL per protocol for the whole platform. The organization is never taken from the assertion's issuer, so an administrator registers one stable URL and nothing about it is organization-specific.
- The single-use record behind
state/RelayStateis short-lived. A user who leaves the provider's page open for a long time and then signs in getsstate_expiredand simply starts again.
GET /current/user/sso/saml/metadata/
Service-provider (SP) metadata for one organization, for SAML identity providers that import a metadata document rather than taking fields one at a time.
Auth: None. IP-throttled.
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| org | string | Yes | The organization's domain (slug). Max 255 characters. |
Request Example
curl -X GET "https://api.fast.io/current/user/sso/saml/metadata/?org=acme-corp"
Success Response (200 OK)
application/samlmetadata+xml — a SAML 2.0 EntityDescriptor carrying the SP entity ID, the assertion consumer service URL and its HTTP-POST binding, and the emailAddress name-ID format.
Error Responses
| HTTP Status | Cause |
|---|---|
| 404 | No such organization, the organization has no configuration, or it is not configured for SAML. All three are the same answer. |
| 503 | Temporary failure building the document. Retry. |
Notes
- Public by design. The document contains no secret and nothing about the organization beyond the identifier already in the URL.
- The SP entity ID is keyed to the organization's stable numeric ID, not to its domain (slug). A slug can be changed and re-registered by somebody else; an entity ID must not move with it. Use the document exactly as served.
- An administrator reading the configuration surface gets the entity ID and the ACS URL as individual fields (
sp.saml_entity_id,sp.saml_acs_url), alongsidesp.saml_metadata_url— the fetchable address of this document.sp.saml_metadata_urlandsp.saml_entity_idare different strings on purpose: the URL names the organization by its domain (slug), because that is what this route resolves, while the entity ID stays on the numeric ID so a rename cannot move it. See the Enterprise SSO reference.
POST /current/user/sso/exchange/
Turn the 60-second handoff into a session. This is the call that actually signs the user in: the account, its organization membership and any role it inherits are all committed here, so an abandoned sign-in leaves nothing behind.
Auth: None. The ve_sso_ex handoff cookie and the ve_br_key browser key are the credentials, so the request must be sent with credentials included. Send no Authorization header. IP-throttled.
Request Parameters: None. The body is empty — the handoff and the browser key travel as cookies.
Request Example
curl -X POST "https://api.fast.io/current/user/sso/exchange/" \
-b "ve_sso_ex={handoff_cookie}; ve_br_key={browser_key}"
The body is empty. From a browser, the same call is a POST with credentials: "include" and no body.
Success Response (200 OK)
{
"result": true,
"provider": "sso",
"email": "user@acme.com",
"token": "{jwt_token}",
"account_created": false,
"org": {
"id": "1234567890123456789",
"domain": "acme-corp"
}
}
Response Fields
| Field | Type | Description |
|---|---|---|
| provider | string | Always sso. |
| string | The address on the signed-in account. | |
| token | string | The issued JWT. Send it as Authorization: Bearer {token}. |
| account_created | boolean | Always present, always a boolean — true only when this exchange created the account. Note the difference from the social sign-in, where the field is omitted for a returning user. |
| org.id | string | 19-digit numeric ID of the organization the user signed into. |
| org.domain | string | That organization's URL-safe domain (slug). Land the user here first; treat anything you read from the account's profile afterwards as secondary. |
Error Responses
| HTTP Status | params.reason | Cause |
|---|---|---|
| 406 | code_expired | No handoff was presented, it had already been used, or its 60 seconds elapsed. |
| 406 | browser_mismatch | The handoff was presented from a different browser than the one that called start. |
| 406 | state_expired | The sign-in record behind the handoff is gone — or the organization's configuration moved underneath the sign-in while it was in flight. Either way it means "start again". |
| 406 | domain_not_permitted | The address the provider asserted is not on a domain the organization has verified. |
| 406 | not_provisioned | The organization provisions through its directory only, and this person has not been provisioned. Nothing was created, linked or claimed. |
| 406 | email_unverified | The provider did not assert the address as verified, and the address matches an existing account or a pending invitation to this organization. Linking an unverified address to either is how an account takeover is spelled, so it is refused. A brand-new account for an address nobody has invited is still created. |
| 406 | account_conflict | The address belongs to an account this organization may not link to the federated identity. |
| 406 | deprovisioned | The identity was deprovisioned by the organization's directory; a sign-in must not step over that. |
| 406 | sso_disabled / sso_not_configured | The configuration changed underneath an in-flight sign-in. |
| 503 | — | Temporary failure, carrying no reason. Most of these have already spent the handoff, so restart the sign-in from start. The exception is a storage fault while the handoff is being claimed: nothing was consumed and the cookie is deliberately left in place, so repeating the exchange itself can succeed. Retry the exchange once; if it comes back code_expired, start again at start. |
Notes
- The handoff is cleared on every outcome but one. On success and on every refusal the cookie goes, so a refused exchange never leaves one waiting to produce a second, more confusing refusal; retrying means starting again at
start. The single exception is a 503 raised while the handoff is being claimed — nothing was consumed, the cookie stays, and the exchange may be repeated as it stands. - The session is a normal revocable account session. It carries the same claims a
revocable=truepassword login does, soPOST /current/user/auth/sign-out/andPOST /current/user/auth/invalidate-all/both reach it, and every downstream surface — OAuth grants, the browser session cookie — works unchanged. - There is no
2factorkey on this response, not evenfalse— and noenrol_requiredeither. A federated sign-in has already satisfied whatever multi-factor policy the organization's identity provider enforces, and the issued token is fully usable regardless of the org's Require-2FA setting — the identity provider owns that factor, not Fastio. Do not branch on a field that is not there. - There is no
redirect_after_logineither.return_urlis reduced to an origin atstart, so the backend has no destination to hand back: use the destination you stored client-side, and fall back toorg.domain. - Send
x-ve-session-cookiehere if you want the issued token mirrored into the browser session cookie, exactly as onGET /current/user/auth/. That header governs the resulting session cookie only; it is not needed to read the handoff. - The account, its membership and any mapped role are committed before this call responds, so the very next request observes them.
Sign-in error reasons
Every sign-in refusal — whether it arrives in a callback's #error= fragment or as error.params.reason on a 4xx from start or exchange — uses this closed vocabulary and no other value. Branch on reason. Do not branch on the numeric error.code (assigned per endpoint, so the same condition reports different numbers from different routes) and do not branch on the HTTP status alone.
reason | What happened | What to show the user |
|---|---|---|
sso_not_configured | The organization named has no usable identity-provider configuration — including the case where no such organization exists. | "Single sign-on is not set up for this organization." Offer the other sign-in methods. |
sso_disabled | The organization has a configuration but has switched SSO off. | The same message. Offer the other sign-in methods. |
domain_not_permitted | The email address is not on a domain the organization has verified. | "That address is not managed by this organization." Do not suggest which addresses would work. |
idp_error | The identity provider refused, or returned something unusable. | "Your organization's identity provider could not complete the sign-in." Offer a retry, and point the user at their administrator. |
email_unverified | The provider did not assert the address as verified. | "Your identity provider has not verified this email address." This one is the administrator's to fix at the provider. |
account_conflict | The address belongs to an account the organization may not link. | "This email address is already in use by another account." Route to support. |
state_expired | The sign-in took too long, or the record behind it is gone. It also covers a sign-in that was overtaken by an administrator's change to the organization's configuration — a credential, or the SAML NameID format — while it was at the provider, and a first SAML sign-in that could not be completed safely at that moment — the service could not take or keep the protection such a sign-in needs, which implies no administrator action and no completed change. | "That sign-in expired." Send the user back to the start of the flow: restarting the sign-in is the whole remedy, and it picks up whatever the conditions are by then. |
not_provisioned | The organization provisions its people through its directory only, and this person has no directory record. This includes a returning member who originally arrived through just-in-time creation, if the organization has since switched to directory-only provisioning. | "Your organization provisions access through its directory. Ask an administrator to add you." Do not offer a retry; nothing about retrying changes the answer. |
code_expired | The handoff was missing, already used, or older than 60 seconds. | The same — restart the sign-in. |
browser_mismatch | The sign-in was completed in a different browser than it started in, or no browser key was presented. | "Finish signing in from the browser you started in." Check that cookies are enabled and that the exchange request includes credentials. |
deprovisioned | The organization's directory removed this identity. | "Your access to this organization has been removed." |
Error envelope. The 4xx refusals follow the standard envelope documented in the API overview — result: false and an error object carrying code, text and params:
{
"result": false,
"error": {
"code": 123456,
"text": "This single sign-on session could not be completed.",
"params": {
"reason": "browser_mismatch"
}
}
}
login_options: what an organization's sign-in page may offer
An organization-scoped sign-in page needs to know which buttons to draw before anybody has authenticated. GET /current/org/{org_id}/public/details/ (unauthenticated — see the Organizations reference) carries a login_options block on the org object for exactly that:
{
"result": true,
"org": {
"id": "1234567890123456789",
"domain": "acme-corp",
"name": "Acme Corporation",
"login_options": {
"password": true,
"social": ["google", "microsoft"],
"sso": {
"enabled": true,
"mode": "optional",
"protocol": "oidc",
"display_name": "Acme SSO",
"start_path": "/user/sso/start/?org=acme-corp"
},
"signup": false
}
}
}
Fields
| Field | Type | Description |
|---|---|---|
| password | boolean | Whether an email/password sign-in may be offered for this organization. |
| social | array | Personal social providers that may be offered. Empty when the organization requires SSO. |
| sso.enabled | boolean | Whether an enterprise SSO button should be drawn. |
| sso.mode | string | off, optional or required. Under required, present SSO as the only route; under optional, present it first, alongside the others. |
| sso.protocol | string/null | oidc or saml. Informational. |
| sso.display_name | string/null | The label to put on the button, chosen by the administrator. |
| sso.start_path | string/null | The path to begin the sign-in. Use it as given. |
| signup | boolean | Whether a self-service signup link may be offered. |
Notes
- Every value is already resolved for the organization — you do not combine
modewith the other flags yourself. Underrequiredthe block reportspassword: false,social: []andsignup: false, and the server refuses those routes too, so the page never draws a door that is locked. - The block is omitted entirely when it cannot be determined. Treat an absent
login_optionsas "this backend does not report it" and fall back to your existing behaviour — never as "this organization has no SSO". - There is no list of the organization's email domains here, and nothing about who is exempt. Publishing either would hand an attacker the addresses worth phishing, or tell an unauthenticated caller which organizations have an administrator who can still use a password.
Enterprise SSO Enforcement
An organization on the Enterprise plan can set its SSO mode to required. From that point every email address on one of that organization's verified domains must authenticate through the organization's identity provider: the routes that would issue a session from a password, create an account, or hand out, rotate or move a password refuse. How an administrator gets there — the plan, the verified domain, the configuration check — is in the Enterprise SSO reference, under Enforcement. This section is what a client sees.
Enforcement follows the email domain. An address on a verified domain of an enforcing organization is enforced whether or not that account has joined the organization, and whether or not an account exists at all.
What refuses
| Surface | Route |
|---|---|
| Password sign-in | GET /current/user/auth/ |
| Account signup | POST /current/user/ |
| Social sign-in (Google / Microsoft) | /current/user/sso/signin/{provider}/ |
| Password reset request | POST /current/user/email/reset/ |
| Password reset redemption, and setting a password | POST /current/user/password/{code}/ |
| Rotating an existing password | POST /current/user/update/ |
| Changing an email address | POST /current/user/update/, POST /current/user/email/change/ |
| Creating a new API key | POST /current/user/auth/key/ |
The refusal. Every one of them answers 403 with reason = sso_required:
{
"result": false,
"error": {
"code": 123456,
"text": "This organization requires single sign-on.",
"params": {
"reason": "sso_required",
"org": { "domain": "acme-corp" },
"start_path": "/user/sso/start/?org=acme-corp&login_hint=user%40acme.com"
}
}
}
| Field | Type | Description |
|---|---|---|
| error.params.reason | string | Always sso_required for this refusal. Branch on this, not on the numeric error.code, which is assigned per endpoint and so differs between the routes above. |
| error.params.org.domain | string | The organization's login slug — the organization to name as the one managing this account. |
| error.params.start_path | string | Where to send the browser to sign in instead. It already carries the org and a login_hint for the address that was submitted. Use it as given; do not compose it yourself. |
What to do with it. On a sign-in or signup screen, send the browser to start_path. In a settings screen — where the user already has a session — show that the account is managed by its organization and disable the control, rather than offering a retry that cannot succeed.
The refusal is identical for every address on the domain. It does not depend on whether an account exists, nor on whether the account holds a role that is exempt. That is deliberate: a caller cannot use these endpoints to learn whether an address is registered, or which addresses still hold a usable password. Never read an sso_required refusal as evidence that an account exists.
Break-glass for the owner, administrators and listed addresses
An organization's owner and its admins keep a password path, so an organization can never lock itself out of its own console. An administrator may additionally name individual addresses on the organization's enforcement exception list; a listed address is exempt exactly as an admin is, on the same three surfaces and no others — the break-glass password sign-in; setting or rotating the account password, whether by spending a mailed reset code or by changing an existing password from a signed-in session; and creating a new API key. Everywhere else on the table above — the ordinary password sign-in, signup, the social sign-in, requesting a password reset, and changing an email address — a listed address gets the byte-identical sso_required refusal. Requesting a reset code is not exempt, so an exempt account with no password reaches one only through the break-glass route below. Two routes take a break_glass=true query parameter:
| Route | Behaviour |
|---|---|
GET /current/user/auth/?break_glass=true | The ordinary password check runs first, and only then — on a correct password — does the server check whether the account is the organization's owner, an admin, or an address on the organization's enforcement exception list. A non-exempt account gets the identical 403 sso_required. |
POST /current/user/email/reset/?break_glass=true | Always answers 202, exactly like an ordinary reset request for an address the platform does not recognize. The email is sent only when the account is the organization's owner, an admin, or an address on its enforcement exception list, and the response never reveals which. |
Failures here count toward the ordinary sign-in lockout. Unlike the default sign-in surface, where an enforced address is refused before a password is examined, break_glass=true examines the password — so a wrong one consumes an attempt and can reach the 429 / 10760 lockout documented under GET /current/user/auth/.
Offer it as a deliberate administrator affordance, not as a general fallback. A user who is not exempt gains nothing from it except a consumed sign-in attempt.
What enforcement does not withdraw
- Existing API keys keep working, and exempt accounts can still create new ones. The gate refuses a new key for enforced members; it does not revoke keys already issued, and it does not apply to the organization's owner, its admins, or an address on its enforcement exception list. That surface is reached with a live session for the account in question, so exempting it reveals nothing an ordinary member could use.
- The gate itself ends no session. A session already in hand is not torn down by a request being refused.
Switching an organization to required does revoke web sessions. Every user on the organization's verified domains loses their existing web sessions — except exempt accounts — and gets a 401 on their next call, then signs back in through the identity provider. The same revocation happens when:
- a new domain is verified while the mode is already
required; - a user's email address moves onto an enforced domain;
- an administrator loses their admin role;
- ownership of the organization is transferred away, for the account that used to own it;
- an address is taken off the enforcement exception list while the mode is
required. Only users this organization actually enforces are affected: removing the owner, an administrator, an address on a domain this organization has not verified, or an address with no account is a no-op.
Handle it as you already handle any 401: discard the token and start the sign-in flow again. login_options on the organization, or the sso block below, tells you which route to offer.
The sso block on GET /current/user/details/
GET /current/user/details/ carries an sso block for the caller's own record only, so a client can draw the right interface before it makes a call that would be refused:
"sso": {
"enforced": true,
"exempt": false,
"org_domain": "acme-corp"
}
| Field | Type | Description |
|---|---|---|
| sso.enforced | boolean | Whether the caller's email domain is enforced. |
| sso.exempt | boolean | Whether this caller keeps a password path — because they are the organization's owner or an admin, or because an administrator has named their address on the organization's enforcement exception list. The two are the same exemption and reach the same three surfaces. |
| sso.org_domain | string | The organization's login slug — the same value start_path uses. |
Use it to draw the interface, not to make the decision. Disable the "create API key" and "set password" controls when enforced is true and exempt is false. The server refuses those calls either way; the block only saves the user a round trip that ends in a 403.
The block may be absent — on an older backend, or when the state could not be read. Treat an absent block as "not enforced" and fall back to your current behaviour; never disable a control on the strength of a field that is not there.
It is never rendered for any user other than the caller. Reading another user's details never includes it: who is exempt from enforcement is not a fact one user may learn about another.
When the platform cannot tell
If the enforcement state cannot be determined, the endpoints above answer with a retryable, temporarily-unavailable error rather than letting a password through. Retry it. Do not treat it as "not enforced" — folding an outage into "no enforcement" would, during an incident, invite exactly the password sign-ins that enforcement exists to prevent.
User Search
GET /current/users/search/
Search for people by name or email across your contacts and the people you share access with (members of orgs, workspaces, and shares you belong to).
Auth: Required (JWT)
Query Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
| search | string | Yes | Search term. Matches against user names and email addresses. Must not be blank. |
Request Example
curl -X GET "https://api.fast.io/current/users/search/?search=john" \
-H "Authorization: Bearer {jwt_token}"
Success Response (200 OK)
{
"result": true,
"contacts": {
"john.doe@example.com": "John Doe",
"jane.johnson@example.com": "Jane Johnson"
},
"users": [
{
"id": "1234567890123456789",
"email": "john.doe@example.com",
"name": "John Doe"
},
{
"id": null,
"email": "jane.johnson@example.com",
"name": "Jane Johnson"
}
]
}
Response Fields
| Field | Type | Description |
|---|---|---|
| response.contacts | object | Map of email address (key) to display name (value) for each matched user. Unchanged, backward-compatible. |
| response.users | array | List of matched people as {id, email, name} objects, deduplicated by email. Provides the user id the contacts map cannot. |
| response.users[].id | string | null | The matched person's user id when they are reachable through one of your shared spaces (an org / workspace / share you have in common); null for a contacts-only match that does not resolve to such an account. |
| response.users[].email | string | The matched person's email address. |
| response.users[].name | string | The matched person's display name. |
Error Responses
| Error Code | HTTP Status | Message | Cause |
|---|---|---|---|
10011 | 401 | "Authentication required" | Missing or invalid JWT token |
205516 / 207092 | 406 | "This value should not be blank." | No custom error code is attached to this field — the code is derived from Symfony's validator: 205516 when search is omitted entirely, 207092 when present but blank |
157360 or 120189 | 500 | "Internal error" | 157360 when the contacts search client fails to initialize; 120189 when the user-profile search client fails to initialize |
Notes
- Searches across two sources: the people you share access with and your contacts. Results are merged and deduplicated by email.
contactsis a flat email → name map kept for backward compatibility.usersis the richer, id-bearing list — prefer it when you need to act on a specific account.- A
users[].idis only present for matches reachable through a shared space; pure-contact matches carryid: null. When the same email matches both ways, the id-bearing entry wins.
Response Envelope
Success
{"result": true, ...}
Error
{
"result": false,
"error": {
"code": 195654,
"text": "Human-readable message",
"documentation_url": "https://api.fast.io/llms.txt",
"resource": "POST /current/user/"
}
}
Validation error (HTTP 406) with structured per-parameter detail
{
"result": false,
"error": {
"code": 195654,
"text": "The email parameter is required. The password must be at least 8 characters.",
"documentation_url": "https://api.fast.io/llms.txt",
"resource": "POST /current/user/",
"params": [
{"name": "email", "kind": "missing", "message": "The email parameter is required.", "code": 195654},
{"name": "password", "kind": "invalid", "message": "The password must be at least 8 characters.", "code": 195655, "expected_type": "string"}
]
}
}
error.params is an array of {name, kind, message, code, expected_type?, received_alias?}. It is present on validation errors (HTTP 406) and aggregates every failed parameter so callers see all problems in one round trip. kind is one of missing, invalid, type_mismatch, or unknown_parameter on a validation error, and conflict on a state conflict (HTTP 409). Treat kind as open-ended: handle an unrecognised value as a generic failure rather than rejecting the response. The field is omitted when empty (non-validation errors). The existing text field is retained byte-identically for compatibility and is now advisory — clients should prefer params for programmatic handling.
OPTIONS introspection. Most user/auth endpoints respond to OPTIONS with a JSON description of their accepted parameters (source, required vs optional, expected type, declared constraints). Use this to fetch parameter requirements before issuing a call. Endpoints that don't opt in return 405 Method Not Allowed.
Common Error Codes
| Code | Description | HTTP Status |
|---|---|---|
1600 | Internal Error | 500 Internal Server Error |
1605 | Invalid Input | 406 Not Acceptable |
1658 | Not Acceptable | 406 Not Acceptable |
1607 | Duplicate Entry | 406 Not Acceptable |
1669 | Already Exists | 409 Conflict |
1660 | Conflict | 409 Conflict |
1609 | Not Found / Resource Missing | 404 Not Found |
1610 | General Error | 500 Internal Server Error |
1650 | Authentication Invalid | 401 Unauthorized |
1651 | Invalid Request Type | 405 Method Not Allowed |
1653 | User Not Found | 404 Not Found |
1701 | Gone | 410 Gone — endpoint retired by decision; stop calling the path, do not retry or vary the id |
1671 | Rate Limited | 429 Too Many Requests |
1680 | Access Denied | 401 Unauthorized |
1670 | Restricted | 406 Not Acceptable |
1677 | Locked | 423 Locked |
1673 | SSO Auth Error | 401 Unauthorized |
Scope and access-mode refusals
Three codes report that a credential was understood but is too narrow for what was asked. All three are HTTP 403, never 401 — the credential is valid, so re-authenticating or refreshing will not help; the credential has to be re-issued with wider scopes from a signed-in web session.
| Code | params.reason | When |
|---|---|---|
10767 | scope_admin_required | An administrative operation called with a credential that is not admin-capable |
10768 | scope_exceeds_issuer | The requested scopes are broader than the credential making the request (API-key create/update, OAuth session narrowing) |
10768 | access_mode_exceeds_initiate | An OAuth consent asked for a broader access mode, or for account settings, than the authorization was initiated with |
10769 | userdetails_scope_required | An account-settings operation without userdetails:*:rw |
10770 | scope_write_required | A non-GET method on an account-anchored route, called with a credential that holds no write-capable grant anywhere — user:*:r, or a set every entry of which is :r |
On these four codes error.params is an object (a map), not the per-parameter array returned by validation errors:
| Field | Always present | Value |
|---|---|---|
| reason | Yes | One of the four strings above |
| entity_type | Yes | e.g. org, workspace, share, user, userdetails |
| entity_id | Yes | The entity id as a string, or null for an account-wide refusal. It is quoted because profile ids are 19 digits and exceed the range a JSON number survives in a client that parses numbers as doubles |
| required_access_mode | Yes | e.g. rwa, rw |
| current_access_mode | Yes | What the credential holds for that entity, or null |
| credential_type | Yes | api_key, oauth or session |
| credential_id | No | Present for an API-key caller |
| credential_label | No | Present when the key has an agent name |
On the consent-time 10768 (access_mode_exceeds_initiate) refusals, current_access_mode instead reports the access mode the consent requested, and required_access_mode reports the ceiling the authorization was initiated with.
Branch on error.params.reason, not on the numeric code. Existing entity-permission codes (10545, 10560, 10574, 10753, 10754, 10757, 10175) are unchanged.
Rate Limiting
Response headers: x-ve-limit-avail (requests remaining), x-ve-limit-max (window cap), x-ve-limit-expires (Unix-time the window resets).
When exceeded: HTTP 429 with error code 1671 (Rate Limited). Back off until x-ve-limit-expires.
ID Formats
- User IDs: 19-digit numeric string (e.g.,
"1234567890123456789") "me"can be used as user_id in user endpoints to reference the authenticated user- User endpoints also accept email address as an identifier
Token Types
| Type | Format | Lifetime | Use |
|---|---|---|---|
| JWT (Basic Auth) | RS256-signed JSON Web Token | Configurable (default varies) | General API access |
| JWT (OAuth) | RS256-signed JSON Web Token | 1 hour | OAuth-based API access |
| Refresh Token | Opaque string | Long-lived | Obtaining new access tokens (OAuth only) |
| API Key | Alphanumeric string | Configurable (default: no expiry) | Service-to-service communication. Optionally scoped with permissions, agent name, and expiration. |
Security Best Practices
- Always use HTTPS for all API communication.
- Store refresh tokens and API keys securely (OS keychain, encrypted storage).
- Never log tokens in client-side logs or analytics.
- Persist the
refresh_tokenfrom the response; it is long-lived and returned unchanged on refresh (no rotation needed). - Verify the
stateparameter in OAuth callbacks to prevent CSRF. - Handle 401 responses by attempting a token refresh; if refresh fails, re-authenticate.
- Revoke tokens on logout by calling the revoke endpoint and clearing local storage.