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). 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, 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) 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, or Growth) 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, or Growth) 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. - 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, 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, 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,
"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. When true, complete the 2FA step before the token has full access. |
| 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 |
Notes
- 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.
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. |
| 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, or Growth) 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
}
Notes
- If email is validated, all pending invitations matching that email are accepted.
- If email is not validated, use
invitation_keyto identify invitations.
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
}
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). Otherwise has user scope (full access). |
| response.2factor | boolean | true if 2FA is enabled. Token has limited scope until 2FA verification is completed. |
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 |
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. - 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.
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, 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).
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) |
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) |
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.
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). |
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
}
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 |
Notes
- If 2FA is already in the
enabledstate, returns success without modification. - This is the final step of the 2FA setup flow.
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, or admin JWT scope).
/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/{code}/
- 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
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.