Authentication & User Management Authentication methods, user CRUD, getting started patterns

Base URL: https://api.fast.io/current/ Request format: application/x-www-form-urlencoded (POST) or query string (GET) Response format: JSON

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.

GET /current/user/auth/

Authorization: Basic {base64(email:password)}

Returns auth_token (JWT). If the account has 2FA enabled, the returned token has limited scope until 2FA verification is completed.

Optional revocable=true — pass to mint a session-bound JWT that can be invalidated server-side via POST /current/user/auth/sign-out/. Recommended for browser sessions where users expect a logout button to terminate access. JWTs minted without revocable (the default) are stateless and sign-out has no effect on them — but ALL login tokens (with or without revocable) can still be killed via POST /current/user/auth/invalidate-all/, which terminates every login session for the user. See the session-termination endpoints (sign-out / invalidate-all) below.

Optional x-ve-session-cookie request header — browser clients only. Send x-ve-session-cookie: 1 (or true/yes — the same vocabulary as revocable) to ask the server to ALSO deliver the issued token in an HttpOnly, Secure, SameSite=Lax cookie scoped to the site's registrable domain (e.g. fast.io), so it is available across subdomains on that domain and the session never has to be stored anywhere JavaScript can read it between page loads. The token is still returned in the response body as usual. The browser then calls POST /current/user/auth/bootstrap/ on each page load — the only endpoint that reads the cookie — which returns that same token so the app can bring it into memory. The opt-in is only honoured as a request header; it is deliberately not accepted as a URL or body parameter. Omit the header (the default) and no cookie is set. On a 2FA-enabled account, sign-in returns a pre-2FA token and NO cookie; the cookie is issued by POST /current/user/auth/2factor/auth/{token}/ instead.

Method 2: API Keys

Long-lived tokens for service-to-service communication. Created via the API or the web UI. Used with the same Authorization: Bearer {api_key} header format as JWTs. Keys optionally support scoped permissions (scopes), agent names (agent_name), and expiration (expires). Scoped keys are enforced using the same scope system as v2.0 JWT tokens. A key created or updated without scopes now stores the explicit ["user:*:rw"] — whole-account read and write, no administration and no account settings.

Scope formats: OAuth authorization requests accept named scope strings (user, org, workspace, all_orgs, all_workspaces, all_shares, all_sign_envelopes). API responses return scopes as arrays of entity_type:entity_id:access_mode strings (e.g., ["org:12345:rw"]) — exactly three colon-separated parts. The entity types are user, org, workspace, share, sign_envelope, fileshare, memory and userdetails; the entity id is * (wildcard) or a numeric id. See the OAuth 2.0 reference for full scope format details.

Access modes. The third part of a scope string is one of three modes:

ModeGrants
rRead
rwRead and write
rwaRead, write and administer

rwa implies rw, which implies r. There is no ra — administration always includes write. rwa is accepted on the ordinary entity types (user, org, workspace, share, sign_envelope) but not on fileshare, which has no administrative verb: fileshare:{id}:rwa and any fileshare:* scope are refused when a credential is issued. userdetails is narrower still — the only issuable form is the exact userdetails:*:rw, and userdetails:*:r, userdetails:*:rwa and any numeric id are refused at grant time.

Administration is always capped by the human. rwa never grants more than the person who owns the credential currently holds — it is re-checked on every request against their live role on the entity. If they lose administrative rights on an org or workspace, a credential carrying rwa for it stops administering it.

The user:* grants:

ScopeMeaning
user:*:rWhole 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:*:rwWhole account, read and writenot administration, not account settings
user:*:rwaWhole 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:

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”.

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 (rwarwr), 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 walkorg: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

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.

  1. POST /current/user/ with email_address, password, tos_agree=true, agent=true
  2. GET /current/user/auth/ with Basic Auth to get JWT
  3. Verify email:
    • POST /current/user/email/validate/ with email — sends verification code
    • POST /current/user/email/validate/ with email and email_token — validates the code
  4. POST /current/org/create/ with domain (required, 2-63 chars lowercase alphanumeric + hyphens)
  5. Select a paid plan to activate the org via POST /current/org/{org_id}/billing/ with billing_plan (e.g. solo_monthly) — a new organization must choose a paid plan (Starter, Business, Growth, or Enterprise) before it can be used (see the Organizations reference)
  6. POST /current/org/{org_id}/create/workspace/ with folder_name, name, perm_join, perm_member_manage

New organizations choose a paid plan (Starter, Business, Growth, or Enterprise) to get started; until a paid plan is selected the org is in an upgrade-only state (the same state as an org that has exhausted its credits).

Option 3: Agent Account Invited to a Human's Org

  1. Create an agent account (steps 1-2 from Option 2)
  2. Give the human your agent's email address
  3. Human invites agent to their org or workspace
  4. Accept: POST /current/org/{org_id}/members/join/ or POST /current/workspace/{workspace_id}/members/join/
  5. 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.

  1. Agent initiates PKCE flow via POST /current/oauth/authorize/ with code_challenge, code_challenge_method=S256, client_id, redirect_uri, response_type=code
  2. User opens the returned URL in browser, signs in, approves access
  3. Browser displays authorization code — user copies it to agent
  4. Agent calls POST /current/oauth/token/ with grant_type=authorization_code, code, code_verifier
  5. Access tokens last 1 hour; refresh via POST /current/oauth/token/ with grant_type=refresh_token

Which option to choose

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.

LevelFields returned on each user (cumulative)
terseid, account_type, first_name, last_name, profile_pic
standardterse + 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)
fullstandard + 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

ParameterTypeRequiredConstraintsDescription
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

FieldTypeDescription
resultbooleantrue on success

Error Responses

Error CodeHTTP StatusMessageCause
10025406"An invalid email was supplied."Email format invalid
10025406"The email domain is invalid or cannot receive email."Email domain validation failed
162057406"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
10026406"An invalid password was supplied."Password does not meet requirements
10394406"An invalid tos_agree value was create."TOS value not a valid boolean string
10395406"You declined to accept the terms of service."TOS set to "false"
10027406"An invalid first name was supplied to create."First name fails validation
10027406"An invalid last name was supplied to create."Last name fails validation
10163406"An invalid phone country code was supplied."Invalid phone country code
10029406"An invalid phone number was supplied."Invalid phone number
10165406"An invalid phone number or country code was supplied."Full phone number validation failed
10354401"Your attempt to create an account was not accepted."Risk/fraud check failed
10032500"We were unable to create your user account..."Internal processing failure

Notes

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.

ParameterTypeRequiredConstraintsDescription
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:

FieldWhen presentMeaning
sessions_invalidatedAlways, when the password changedOther sessions on this account have been signed out. Your own credential may be among them — see auth_token.
auth_tokenWhen the password changed and you authenticated with a sign-in session tokenA replacement session token. Your previous one is no longer valid; use this for subsequent requests.
email_send_failedA combined password + email_address change whose confirmation email could not be sentThe 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 CodeHTTP StatusMessageCause
10025406"An invalid email was supplied to update."Invalid email format
10025406"The email domain is invalid or cannot receive email."Invalid email domain
10025409"The email you specified is not available."Email already in use
20544500"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
10164406"You must disable 2-Factor before updating your phone."2FA enabled when trying to change phone
10026406"An invalid password was supplied to update."Invalid password
10026406"The password must be sent in the POST body, not the query string."password was supplied as a query parameter
10770403"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
10769403"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
10766406"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
10766406"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
10759403"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
10027406"An invalid first name was supplied to update."Invalid first name
10027406"An invalid last name was supplied to update."Invalid last name
10731406"Owner-defined properties must be valid JSON."Invalid JSON in owner_defined
10354406"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


POST /current/user/close/

Close (soft-delete) the current user's account.

Auth: Required (JWT)

Request Parameters

ParameterTypeRequiredDescription
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 CodeHTTP StatusMessageCause
10024404"User not found to close."User object invalid
10025406"An invalid email was supplied to close account."Invalid email format
10025406"An incorrect email was supplied to close account."Email does not match user's email
159788406"Cannot close user account that owns active organizations..."User owns active organizations

Notes


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

ParameterTypeRequiredDescription
email 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 CodeHTTP StatusMessageCause
10022406"You provided an invalid email to check."Invalid email format or missing

Notes


POST /current/user/email/reset/

Request a password reset email.

Auth: None (IP-throttled)

Request Parameters

ParameterTypeRequiredDescription
email 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 CodeHTTP StatusMessageCause
10022406"You provided an invalid email to check."Invalid email format
20544500"We were unable to send a verification email."Email send failure

Notes


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.

ParameterTypeRequiredDescription
email 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.

ParameterTypeRequiredDescription
email 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

Error Responses

Error CodeHTTP StatusMessageCause
10011401"Your credentials were not supplied or invalid."User not authenticated
10037406"Your email address is already verified."Email already verified
10023409"Your credentials do not match the email you provided."Email mismatch with authenticated user
10033406"You provided an invalid or expired token to validate email."Invalid or expired code
10199401"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

ParameterTypeRequiredDescription
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

Error Responses

Error CodeHTTP StatusMessageCause
10755406"There is no pending email change to confirm."No pending change exists for this account
10033401"The confirmation link is invalid or has expired."Token invalid, expired, or already used
10023401"This confirmation link does not belong to the signed-in account."Token belongs to a different account than the one signed in
10025409"That email address is no longer available."The pending email was claimed by another account before confirmation
10032500"There was an internal error applying your email change."The change could not be applied

Notes


POST /current/user/password/{code}/

Set a new password using a password reset code.

Auth: None (code-based authentication)

Path Parameters

ParameterTypeRequiredDescription
{code} string Yes Password reset code from the reset email.

Request Parameters

ParameterTypeRequiredDescription
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 CodeHTTP StatusMessageCause
10197401"An invalid code was provided, cannot reset password."Invalid code format
10198401"Provided code was not found or expired, cannot reset password."Code not found or wrong type
10199401"Provided code has expired, get a new code and try again."Code expired
10200409"Provided code belongs to another user account and cannot be used."Code/user mismatch
10201404"The provided code belongs to an invalid user."User not found for code
10202409"The provided passwords don't match."password1 and password2 differ
10204406"Both password fields must be provided and match."Missing password fields
10203500"The provided password could not be processed..."Encryption failure
10204500"The provided password could not be processed..."The new password could not be written
10204500"The provided password could not be processed..."The account could not be reloaded before its sessions were invalidated
10204500"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


GET /current/user/password/{code}/details/

Get details of a password reset code (check if valid/expired).

Auth: None (code-based)

Path Parameters

ParameterTypeRequiredDescription
{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

FieldTypeDescription
response.emailstringThe email address associated with the reset code.

Error Responses

Error CodeHTTP StatusMessageCause
10197401"An invalid code was provided, cannot reset password."Invalid code format
10198401"Provided code was not found or expired, cannot reset password."Code not found
10199401"Provided code has expired, get a new code and try again."Code expired
10200409"Provided code belongs to another user account..."Code/user mismatch
10207423"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

ParameterTypeRequiredDescription
{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 CodeHTTP StatusMessageCause
10022406"You provided an invalid phone number to check."Invalid format
10163406"An invalid phone country code was supplied."Invalid country code
10029406"An invalid phone number was supplied."Invalid phone number
10165406"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

FieldTypeDescription
response.supportcodestring4-digit support PIN. Defaults to "0000" if not set.
response.intercomstringHMAC-SHA256 identity-verification hash of your user ID, for authenticating you to the support widget.

Error Responses

Error CodeHTTP StatusMessageCause
10023404"Unable to fetch the user details."User not found
10541500"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

ParameterTypeRequiredDescription
{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)

ParameterTypeDefaultDescription
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

FieldTypeDescription
response.providerstringThe provider name.
response.redirect_urlstringURL to redirect the user to for SSO authentication.
response.return_urlstringCallback URL the provider will redirect back to.

POST: Process SSO Callback

Processes the OAuth2 callback with the authorization code from the provider.

ParameterTypeRequiredDescription
code string Yes Authorization code from the SSO provider.
state string Yes State token for CSRF protection.

POST Response (200 OK)

{
  "result": true,
  "provider": "google",
  "email": "user@example.com",
  "token": "{jwt_token}",
  "2factor": false,
  "enrol_required": false,
  "account_created": true
}

Response Fields

FieldTypeDescription
response.providerstringThe provider that authenticated the user.
response.emailstringThe email address on the authenticated account.
response.tokenstringThe issued JWT. Send it as Authorization: Bearer {token}.
response.2factorbooleanWhether the account has 2FA enabled, OR enrol_required is true. When true, complete the 2FA step before the token has full access.
response.enrol_requiredbooleanAlways present, exactly like the password-login field of the same name — see Interactive Login & Enrolment. true only when an org this account belongs to requires 2FA and the account holds none; token is then an enrolment token, not a session.
response.account_createdbooleanPresent 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_loginstringOnly present when a return_url was supplied on the GET step; the URL to send the user to after login completes.

Error Responses

Error CodeHTTP StatusMessageCause
10041406"An invalid provider name was supplied."Invalid provider name format
10226406"An unknown provider name was supplied."Provider not in allowed list
10530406"Cookies must be enabled and passed to this API."Missing state cookie
10260401"Permission was not granted by the provider."OAuth error returned from provider
10230401"Invalid or missing input in a required field was received."Missing code or state
145237401"Accounts cannot be registered with this email domain."First-time SSO signup only: the provider-asserted email is at a reserved domain, or a subdomain of one, so no account is created. An account that already exists on such a domain can still sign in
503"Your organization's sign-in policy could not be read. Please try again."An org's Require-2FA policy governing this account could not be resolved, so the sign-in was neither completed nor refused. Retryable, and NOT a credential failure — identical to the password-login case; see the note below

Notes


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


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

FieldTypeDescription
response.has_orgsbooleanWhether the user has access to any organizations.
response.has_workspacesbooleanWhether the user has access to any workspaces.
response.has_sharesbooleanWhether the user has access to any shares.
response.has_pending_invitationsbooleanWhether the user has any pending invitations awaiting their explicit acceptance. Computed for verified-email accounts only (false otherwise).

Error Responses

Error CodeHTTP StatusMessageCause
10023404"Unable to fetch the user details."User not found

GET /current/user/{user_id}/details/

Get user profile details.

Auth: Required (JWT)

Path Parameters

ParameterTypeRequiredDescription
{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

FieldTypeDescription
response.user.idstring19-digit user ID.
response.user.account_typestring"human" or "agent".
response.user.email_addressstringUser's email address.
response.user.first_namestringGiven name.
response.user.last_namestringFamily name.
response.user.lockedbooleanWhether the account is locked.
response.user.profile_picstringProfile photo URL.

Self-Only Fields (included only when viewing your own profile)

FieldTypeDescription
2factorbooleanWhether 2FA is enabled.
closedbooleanWhether the account is closed.
country_codestringCountry of residence.
createdstringRegistration date.
password_setbooleanWhether the account has a password set (false = SSO-only). Self/manager view only.
phone_countrystringPhone country code.
phone_numberstringPhone number.
ssoobjectEnterprise SSO enforcement state for the caller: enforced, exempt, org_domain. Absent when it could not be determined — treat an absent block as not enforced. See Enterprise SSO Enforcement.
suspendedbooleanSuspension status.
tos_agreestringToS agreement date.
updatedstringLast profile update time.
valid_emailbooleanEmail verified status.
valid_phonebooleanPhone verified status.

Error Responses

Error CodeHTTP StatusMessageCause
10023404"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

ParameterTypeRequiredDescription
{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 CodeHTTP StatusMessageCause
135405500"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

FieldTypeDescription
response.allowedbooleanWhether the user's location allows resource creation.
response.reasonsarrayArray of blocked reason strings. Only present when allowed is false.

GET /current/user/me/limits/orgs/

Check free organization creation eligibility.

Auth: Required (JWT)

Request Example

curl -X GET "https://api.fast.io/current/user/me/limits/orgs/" \
  -H "Authorization: Bearer {jwt_token}"

Success Response (200 OK)

{
  "result": true,
  "can_create_free_org": false,
  "existing_free_orgs": 0,
  "cooldown_remaining": 0,
  "max_free_orgs": 1,
  "reason": "The free plan is no longer available. Please choose a paid plan.",
  "free_trial_eligible": true,
  "trial_days": 14
}

The free plan is retired for new organizations, so can_create_free_org is false and a reason is returned. (These field NAMES are unchanged. The plan identifier itself is now reported as unpaid rather than free — an organization with no active subscription is on the unpaid tier.) New organizations select a paid plan (Starter, Business, Growth, or Enterprise) instead. While the free plan is closed, existing_free_orgs and cooldown_remaining are not meaningful (reported as 0/null).

The free_trial_eligible and trial_days fields describe whether a NEW org this user creates can start a free trial of a paid plan (vs. buying immediately). A free trial is only available on a user's first organization, so at this pre-org stage free_trial_eligible is true only when the user owns no organization at all — once the user owns (or has ever owned) any organization, every later org is permanently ineligible regardless of elapsed time. A secondary per-user cooldown (60 days on modern plans, anchored at trial start) also applies on top of this. Use these fields to render the plan-selection cards ("Start N-day free trial" vs. "Buy now") before any org exists. When free_trial_eligible is false, a no_trial_reason string is also returned explaining why; a trial_available_at timestamp is included only when the block is the cooldown (the first-org block is permanent, so it has no future date).

Response Fields

FieldTypeDescription
response.can_create_free_orgbooleanWhether the user can create a free organization. The free plan is retired for new orgs, so this is false.
response.existing_free_orgsintegerNumber of existing free organizations owned by the user.
response.cooldown_remainingintegerSeconds remaining before next creation is allowed.
response.max_free_orgsintegerMaximum number of free organizations allowed.
response.reasonstringReason creation is not allowed. Only present when can_create_free_org is false.
response.free_trial_eligiblebooleanWhether 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_daysintegerLength of the free trial in days for the default paid plan.
response.no_trial_reasonstringWhy the user is not trial-eligible. Present only when free_trial_eligible is false and a reason exists.
response.trial_available_atstringCanonical 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 CodeHTTP StatusMessageCause
141088404"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

ParameterTypeRequiredDefaultDescription
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

FieldTypeDescription
response.sharesarrayArray of share resource objects for the current page. Each includes parent workspace and org info.
response.paginationobjectPagination metadata: total (count of all matching shares before paging), limit, offset, and has_more (boolean — true when more items remain beyond this page).

Notes


GET /current/user/{user_id}/assets/

List set assets (e.g., profile photo) for a user.

Auth: Required (JWT)

Path Parameters

ParameterTypeRequiredDescription
{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

ParameterTypeRequiredDescription
{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.

ParameterTypeRequiredDescription
(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 CodeHTTP StatusMessageCause
10586400"Only user may modify."Non-owner attempting to modify
10418400"Asset upload missing"No file in POST request
156780406"metadata invalid"Invalid metadata parameter

Notes


GET|HEAD /current/user/{user_id}/assets/{asset_name}/read/

Read the binary content of a user asset.

Auth: Required (JWT)

Path Parameters

ParameterTypeRequiredDescription
{user_id} string Yes 19-digit numeric user ID.
{asset_name} string Yes Asset type name (e.g., profile_pic).

Notes

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:

FieldTypeDescription
idstring19-digit installation ID.
app_idstringCaller-defined app identifier this installation belongs to.
app_versionstring or nullApp version last reported, or null if never provided.
platformstring or nullPlatform string last reported (e.g. macos, windows, ios), or null.
statusstring"installed" or "uninstalled".
installed_atstringCanonical Y-m-d H:i:s UTC timestamp of first install.
uninstalled_atstring or nullCanonical Y-m-d H:i:s UTC timestamp of last uninstall, or null if currently installed.
last_heartbeatstring or nullCanonical Y-m-d H:i:s UTC timestamp of the last heartbeat/install check-in, or null.
metadataobject or nullArbitrary 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

FieldTypeDescription
response.appsarrayArray of installation objects (see shape above).

Error Responses

Error CodeHTTP StatusMessageCause
227759500"Internal error initializing app installations."Backend unavailable
218645404"No app installations found."Installation lookup failed

Notes


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

ParameterTypeRequiredDescription
app_idstringYesCaller-defined app identifier. Must not be blank.
app_versionstringNoApp version string.
platformstringNoPlatform string (e.g. macos, windows, ios, android).
metadatastring (JSON)NoArbitrary 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

FieldTypeDescription
response.installationobjectThe created or updated installation object (see shape above).

Error Responses

Error CodeHTTP StatusMessageCause
296856 / 210782 / 224953 / 296358406Variousapp_id blank/missing/too long → 296856; app_version too long → 210782; platform too long → 224953; metadata not valid JSON → 296358
246127500"Internal error initializing app installations."Backend unavailable
217449404"Failed to save app installation."Persisting the installation failed

Notes


POST /current/user/apps/uninstall/

Mark an app installation as uninstalled.

Auth: Required (JWT)

Request Parameters

ParameterTypeRequiredDescription
app_idstringYesCaller-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

FieldTypeDescription
response.installationobjectThe updated installation object; status is now uninstalled.

Error Responses

Error CodeHTTP StatusMessageCause
234003406Variousapp_id blank/missing or too long
226304404"No installation record found for this app."No installation exists for this app_id
233650409"This app is already uninstalled."Installation already in the uninstalled state
237113500"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

ParameterTypeRequiredDescription
app_idstringYesCaller-defined app identifier of the installed app. Must not be blank.
app_versionstringNoUpdated 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

FieldTypeDescription
response.installationobjectThe updated installation object with a refreshed last_heartbeat.

Error Responses

Error CodeHTTP StatusMessageCause
276731 / 272675406Variousapp_id blank/missing/too long → 276731; app_version too long → 272675
245439404"No installation record found for this app."No installation exists for this app_id
231483409"Cannot heartbeat an uninstalled app."Installation is in the uninstalled state
237563500"Failed to save heartbeat."Persisting the change failed

Notes

Invitations

GET /current/user/invitation/{invitation_id}/details/

Get details for a specific invitation.

Auth: Required (JWT)

Path Parameters

ParameterTypeRequiredDescription
{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

FieldTypeDescription
response.invitationobjectInvitation resource. Embeds entity_type and the entity object (org/workspace/share). Includes invitation_key because this is the invitee's own authenticated view.
response.ownerobjectUser resource of the profile owner.
response.orgobject or nullOrg resource if the invitation is for an org-owned entity.

Error Responses

Error CodeHTTP StatusMessageCause
10618406"An invalid invitation ID was supplied."Invalid ID format
10630406"Invitation not found."Invitation does not exist
159135500"Failed to load the invitation profile or its owner."Profile or owner load failure

Notes


GET /current/user/invitation/{invitation_id}/public/details/

Get public details for an invitation without authentication.

Auth: None (IP-throttled)

Path Parameters

ParameterTypeRequiredDescription
{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


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

ParameterTypeRequiredDescription
{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

FieldTypeDescription
response.invitationobjectThe updated invitation resource; state is now accepted.

Error Responses

Error CodeHTTP StatusMessageCause
10618406"An invalid invitation ID was supplied."Malformed ID
10631406"This invitation can no longer be accepted."Already accepted/declined, or expired
10630404"Invitation not found."No such invitation
127827401"You are not authorized to act on this invitation."The invitation is not addressed to the authenticated user

Notes


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

ParameterTypeRequiredDescription
{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

FieldTypeDescription
response.invitationobjectThe updated invitation resource; state is now declined.

Error Responses

Error CodeHTTP StatusMessageCause
10618406"An invalid invitation ID was supplied."Malformed ID
10631406"This invitation can no longer be declined."Already accepted/declined
10630404"Invitation not found."No such invitation
127827401"You are not authorized to act on this invitation."The invitation is not addressed to the authenticated user

Notes


POST /current/user/invitations/acceptall/

Accept all pending invitations.

Auth: Required (JWT)

Request Parameters

ParameterTypeRequiredDescription
invitation_key string No Optional invitation key. If the user's email is not validated, this key can identify invitations.

Request Example

curl -X POST "https://api.fast.io/current/user/invitations/acceptall/" \
  -H "Authorization: Bearer {jwt_token}"

Success Response (200 OK)

{
  "result": true,
  "accepted_invitations": ["1234567890123456789"],
  "refused": []
}

Response Fields

FieldTypeDescription
accepted_invitationsarray of stringIDs of invitations that were accepted.
refusedarray of objectInvitations skipped by the collaboration policy (see the Organizations reference — Collaboration Policies): {invitation_id, target_type, target_id, reason}, reason is external_invites_denied or external_invites_object_denied. Always present; empty when nothing was refused. A refused invitation stays pending, not failed — no membership is created for it, and it can be retried later if the policy changes.
failed_invitationsarray of objectPresent only when something went wrong outside the policy (e.g. the policy could not be evaluated, or an unsupported invitation type): {invitation_key, error}.

Notes


GET /current/user/invitations/list/

List all pending invitations for the current user.

Auth: Required (JWT)

Query Parameters

ParameterTypeRequiredDescription
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

FieldTypeDescription
response.invitationsarrayArray of invitation resource objects.

Notes

User Authentication Endpoints

GET /current/user/auth/

Authenticate via HTTP Basic Auth. Returns JWT token.

Auth: HTTP Basic Auth (email:password)

Query Parameters

ParameterTypeRequiredDefaultDescription
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

HeaderTypeRequiredDefaultDescription
x-ve-session-cookie boolean No (absent) Browser clients only. When truthy (1, true or yes — the same vocabulary as revocable), the issued token is ALSO returned in an HttpOnly, Secure, SameSite=Lax cookie scoped to the site's registrable domain (e.g. fast.io), with the same lifetime as the token. auth_token is still returned in the body. On later page loads, bring the cookie's token back into memory with POST /current/user/auth/bootstrap/. The opt-in is only honoured as a request header — there is no URL or body parameter equivalent. On a 2FA-enabled account no cookie is set here — see the notes below.

Request Example

curl -X GET "https://api.fast.io/current/user/auth/" \
  -u "jane.doe@example.com:$PASSWORD"

To request a revocable session-bound JWT (recommended for browser logins):

curl -X GET "https://api.fast.io/current/user/auth/?revocable=true" \
  -u "jane.doe@example.com:$PASSWORD"

To additionally receive the token in an HttpOnly session cookie (browser logins that keep the token out of client storage):

curl -X GET "https://api.fast.io/current/user/auth/" \
  -u "jane.doe@example.com:$PASSWORD" \
  -H "x-ve-session-cookie: 1" \
  --cookie-jar cookies.txt

Success Response (200 OK)

{
  "result": true,
  "expires_in": 86400,
  "auth_token": "{jwt_token}",
  "2factor": false,
  "enrol_required": false
}

Response Fields

FieldTypeDescription
response.expires_inintegerJWT token expiration time in seconds.
response.auth_tokenstringJWT access token. If 2FA is enabled, has twofactor scope (restricted). If an org requires 2FA and the account holds none, has enrol scope (restricted to the enrolment surfaces — see Interactive Login & Enrolment). Otherwise has user scope (full access).
response.2factorbooleantrue if 2FA is enabled, OR if enrol_required is true — so a client that does not yet read enrol_required still shows its existing code-entry screen rather than treating the login as complete.
response.enrol_requiredbooleanAlways present. true only when an org this account belongs to requires 2FA and the account holds none. When true, auth_token is an enrolment token, not a session — see Interactive Login & Enrolment.

Error Responses

Error CodeHTTP StatusMessageCause
10454405"The expires time specified is invalid."Invalid expires parameter
10001401"Your credentials were not supplied or invalid."Missing Basic Auth header
10004401"Username is not valid."Invalid email format
10005401"Password is not valid."Invalid password format
10008401"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
10105401"Your account is suspended..."Account suspended (only after a correct password)
10104401"Your account is locked..."Account locked
10106401"Your account is suspended due to abuse."Account flagged for abuse
10103401"Your account is closed by you."Account closed
10103401"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
10760429"Too many failed sign-in attempts. Try again in N minutes."Too many consecutive failed sign-in attempts for this account — a temporary, self-clearing lockout
503"Your organization's sign-in policy could not be read. Please try again."An org's Require-2FA policy governing this account could not be resolved, so the sign-in was neither completed nor refused. Retryable, and NOT a credential failure — see the note below

Notes


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

FieldTypeDescription
response.idstringThe 19-digit numeric user ID the cookie authenticated as.
response.auth_tokenstringThe session token held in the cookie — the same credential the cookie carries, not a separately issued one.
response.expires_inintegerSeconds 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 CodeHTTP StatusMessageCause
100966401"This endpoint requires a browser session cookie."The request authenticated with an Authorization header, or carried no session cookie at all

Notes


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


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


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

FieldTypeDescription
response.idstringThe 19-digit numeric user ID.

Notes


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

FieldTypeDescription
response.auth_typestringToken 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.scopesarrayArray 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_detailarrayHydrated 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_agentbooleanWhether the token represents an agent.
response.agent_namestring or nullAgent display name. null if not set or not an agent.
response.full_accessbooleanWhether the credential is account-wide and may writeuser:*: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.adminbooleanWhether the credential can perform administrative operations. true for a browser login session and for any credential holding an rwa scope. Always present.
response.legacybooleanWhether 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

Credentialfull_accessadminlegacyauth_type
user:*:rwatruetruefalsejwt_v2 / api_key_scoped
user:*:rwtruefalsefalsejwt_v2 / api_key_scoped
user:*:rfalsefalsefalsejwt_v2 / api_key_scoped
Scoped, e.g. org:123:rwafalsetruefalsejwt_v2 / api_key_scoped
Legacy key with no scopestruefalsetrueapi_key
Browser login sessiontruetruetruejwt_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

ParameterTypeRequiredDescription
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}/):

SubmittedCreateUpdate
OmittedStored as ["user:*:rw"]Left as-is
"" or "null"Stored as ["user:*:rw"]Clears to ["user:*:rw"]
"[]"Refused — 406 190363Refused — 406 190363
Malformed JSON, a JSON object, a non-string member, or an invalid scope string406 197558406 121158
A non-empty JSON list of valid scope stringsIssuance checks belowIssuance 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:

  1. Empty set406 190363 — "The scopes provided must contain at least one scope."
  2. Broader than the calling credential403 10768, error.params.reason scope_exceeds_issuer — "The requested scopes are broader than the credential making this request." A browser login session skips this check; it is unbounded.
  3. Not grantable to this user406 185003 — the human does not hold the entity, or the scope string is not issuable at all (for example userdetails:*:rwa or fileshare:*:rw).
  4. Exceeds the governing org's credential policy403, error.params.reason credential_policy_mode or credential_policy_scope — "The requested scopes exceed this organization's credential policy." Each attributable scope is checked against the policy of its own owning org, and a policy that is corrupt, names a deleted organization, or could not be read answers with its own reason instead (credential_policy_unreadable, credential_policy_org, credential_policy_unavailable). See Org Credential Policy below.

Request Example

curl -X POST "https://api.fast.io/current/user/auth/key/" \
  -H "Authorization: Bearer {jwt_token}" \
  -d "memo=CI/CD Pipeline Key"

Request Example (scoped key with expiration)

curl -X POST "https://api.fast.io/current/user/auth/key/" \
  -H "Authorization: Bearer {jwt_token}" \
  -d "memo=Workspace Agent" \
  -d 'scopes=["workspace:1234567890123456789:rw"]' \
  -d "agent_name=my-agent" \
  -d "expires=2026-12-31 23:59:59 UTC"

Success Response (200 OK)

{
  "result": true,
  "api_key": "{the raw key, shown only here}",
  "key": {
    "id": "{key_id}",
    "memo": "Workspace Agent",
    "scopes": "[\"workspace:1234567890123456789:rw\"]",
    "agent_name": "my-agent",
    "created": "2026-09-09 14:03:11 UTC",
    "expires": "2026-12-31 23:59:59 UTC",
    "admin": false,
    "legacy": false,
    "api_key": "****************************abcd"
  }
}

Response Fields

api_key is the secret. key is the created row, in the same shape the read and update calls return — including its own masked api_key. The two are siblings so that reading the secret stays exactly as simple as it was.

FieldTypeDescription
response.api_keystringThe raw key. Only shown here, only once — it is never retrievable again, so store it before you discard the response.
response.keyobjectThe created key. Carries id, memo, scopes, agent_name, created, expires, admin, legacy and a masked api_key.
response.key.idstringThe key id, for the update, read and delete calls.
response.key.scopesstringThe stored scopes claim, as a JSON list string.
response.key.adminbooleanWhether the key's scopes confer administration. A user:*:rw key is not admin.
response.key.legacybooleanWhether 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 CodeHTTP StatusMessageCause
10011401"Your credentials were not supplied or invalid."Missing or invalid JWT
10175403"The scope of your credentials are not sufficient."JWT scope not user or admin
10015429"You are at the maximum number of API keys, {max}."Maximum key limit reached
10016406"You provided an invalid Memo."Invalid memo format
190363406"The scopes provided must contain at least one scope."scopes was an explicit empty array ([])
197558406invalid scopes valueMalformed JSON, a JSON object, a non-string member, or an invalid scope string
10770403"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
10768403"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
185003406scopes not grantableA scope the human does not hold, or one that is not issuable at all (e.g. userdetails:*:rwa, fileshare:*:rw)
158000403"The requested scopes exceed this organization's credential policy."A requested scope exceeds the governing org's credential_policy (error.params.reason credential_policy_mode or credential_policy_scope) — see Org Credential Policy below
158000403"This organization's credential policy could not be read, so no credential may be issued for it."The governing org's stored credential_policy is corrupt (error.params.reason credential_policy_unreadable) — permanent
158000403"This grant names an organization that no longer exists."The requested grant names an entity whose owning organization has been deleted (error.params.reason credential_policy_org) — permanent
158000503"The organization credential policy is temporarily unavailable. Please try again shortly."The governing org, or its policy, could not be READ (error.params.reason credential_policy_unavailable) — the one case worth retrying

Notes


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

ParameterTypeRequiredDescription
{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

FieldTypeDescription
response.api_key.idstringUnique key identifier.
response.api_key.api_keystringMasked API key (only last 4 characters visible).
response.api_key.memostringKey description/label.
response.api_key.createdstringKey creation timestamp in UTC.
response.api_key.scopesstring or nullJSON 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_namestring or nullAgent/application name, or null if not set.
response.api_key.expiresstring or nullExpiration datetime in canonical Y-m-d H:i:s UTC format, or null for no expiration.
response.api_key.adminbooleanWhether 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.legacybooleanWhether 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 CodeHTTP StatusMessageCause
10019406"You provided an invalid Token to get details of."Invalid key ID format
10175403"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)404no error body — result: false onlyKey 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

ParameterTypeRequiredDescription
{key_id} string Yes The API key's unique identifier.

Request Parameters

ParameterTypeRequiredDescription
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 CodeHTTP StatusMessageCause
187851 or 100527404"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 / 163622406Various121158 invalid scopes JSON (malformed, an object, a non-string member, or an invalid scope string); 107184 invalid agent_name; 163622 invalid/past expires
190363406"The scopes provided must contain at least one scope."scopes was an explicit empty array ([])
10770403"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
10768403"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
185003406scopes not grantableA scope the human does not hold, or one that is not issuable at all (e.g. userdetails:*:rwa, fileshare:*:rw)
158000403"The requested scopes exceed this organization's credential policy."The key's effective scopes exceed the governing org's credential_policy (error.params.reason credential_policy_mode or credential_policy_scope) — see Org Credential Policy below
158000403"This organization's credential policy could not be read, so no credential may be issued for it."The governing org's stored credential_policy is corrupt (error.params.reason credential_policy_unreadable) — permanent
158000403"This grant names an organization that no longer exists."The effective scopes name an entity whose owning organization has been deleted (error.params.reason credential_policy_org) — permanent
158000503"The organization credential policy is temporarily unavailable. Please try again shortly."The governing org, or its policy, could not be READ (error.params.reason credential_policy_unavailable) — the one case worth retrying

Notes


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

ParameterTypeRequiredDescription
{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 CodeHTTP StatusMessageCause
10019406"You provided an invalid Token to Delete."Invalid key ID format
10020404"You provided a Token that was not found."Key not found or belongs to another user
10021500"There was an error deleting the API Key."Internal deletion failure
10175403"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


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

FieldTypeDescription
response.resultsintegerNumber of API keys.
response.api_keysarray or nullArray of API key objects, or null if none exist.
response.api_keys[].idstringUnique key identifier.
response.api_keys[].api_keystringMasked API key (only last 4 characters visible).
response.api_keys[].memostringKey description/label.
response.api_keys[].createdstringKey creation timestamp in UTC.
response.api_keys[].scopesstring or nullJSON array of scope strings. null only on a legacy key that declares no scopes claim at all.
response.api_keys[].agent_namestring or nullAgent/application name, or null if not set.
response.api_keys[].expiresstring or nullExpiration datetime in canonical Y-m-d H:i:s UTC format, or null for no expiration.
response.api_keys[].adminbooleanWhether the key carries at least one rwa scope.
response.api_keys[].legacybooleanWhether the key declares no scopes claim at all (it then behaves as user:*:rw).

Org Credential Policy

Organizations on the Enterprise plan can cap what API keys and OAuth grants issued inside the org may hold, and constrain them on every later request, not only when they are issued. This is the org's credential_policy setting — see Credential Policy in the Organizations reference for the envelope shape, the org update/details fields, and capabilities.credential_policy_api_keys / _oauth. This section documents what it changes for API keys.

These refusal reasons carry the ordinary scope-error params (entity_type, entity_id, required_access_mode, current_access_mode, credential_type, and reason). Most are 403; credential_policy_unavailable is always 503, and credential_policy_sso can be either, depending on which of the two things it is reporting:

Branch on params.reason, never on the numeric code. A code identifies the individual place the refusal was raised, not the reason it carries — one reason is raised from several places and therefore has several codes, and more can appear at any time without notice. The codes below are examples for correlating a single response with a support request; they are not an enumeration, and matching on them will miss refusals your client must handle.

params.reasonExample codeHTTPWhen
credential_policy_mode169015, 133221403The access mode the credential holds for an entity exceeds the org's configured ceiling (max_mode) for that credential family. This inverts the ordinary scope error above, where the held mode is too low — here it is too high. Do not render it as "insufficient scope."
credential_policy_scope169015, 133221403The credential names an entity type the org's policy does not allow that family to hold at all (scope_types). These two reasons share their codes: the same check reports whichever of them applies.
credential_policy_sso117677, 187061403Single sign-on is required for the organization and the credential's owner is not exempt.
credential_policy_sso167474, 130525503The organization's sign-on policy could not be read — the check itself failed, not the sign-on requirement. Retry.
credential_policy_unavailable142426, 114359, 139632, 171492, 145667, 120029503The organization's credential policy could not be read. Transient — the one case where retrying is the correct client behaviour.
credential_policy_unreadable138730, 177544403The organization's stored credential policy is corrupt. Permanent — retrying will not help; an administrator must rewrite the policy.
credential_policy_org155387, 127015, 131925403The resource's owning organization no longer exists. Permanent — retrying will never come good.

Note the same credential_policy_sso reason on two HTTP statuses: the status is what separates "sign on through your organization" (403) from "we could not tell, try again" (503). Read both together.

required_access_mode and current_access_mode can both be null on credential_policy_unavailable, credential_policy_unreadable, and credential_policy_org — when the org's policy could not be read, or the stored policy will not parse, there is no ceiling left to quote. Treat null as unknown, never as a mode, and never respond to it by re-minting the credential: on these three reasons the credential was never the problem.

A refusal of an account-wide grant is reported against the organization that refused it. When the credential holding the refused grant is user:*:*, entity_type is org and entity_id is the id of the organization whose policy refused it — not entity_type user with a null entity_id. Read it as "this organization's ceiling is lower than your account-wide grant," never as "your credential is refused everywhere."

Where these are raised.

This reaches credentials that already exist. A stored API key or OAuth grant that predates a policy, or predates a tightening of one, can begin returning 403 on its very next call with no change to the key itself, no revocation event, and no grace period — the policy is evaluated live, against the credential's currently-held authority. Treat credential_policy_mode / credential_policy_scope the way any other scope refusal is treated: surface it as "this credential no longer meets your organization's policy," route to key management, and never treat it as a sign-in failure. A signed-in browser session is never affected — it carries no scopes claim and is exempt from credential_policy by construction, the same way it is exempt from the ordinary scope checks above.

What is evaluated is only the authority actually used for the request — the concrete or wildcard grant that satisfied this request's check, never the credential's whole scope set and never an inherited parent grant. A key holding org:A:rwa and org:B:r is checked against org A's policy only when it acts in org A.

What is not covered by the request-time check. Three collection endpoints that list an account's own orgs and shares in bulk (orgs/list, orgs/all, shares/all) resolve no single governing org per result and are permanently outside this check. A public File Share single-file link carries no API key and no account session, so it is not an org-governed credential and is never subject to credential_policy, at issuance or at request time. A small number of late-loaded cloud write-back endpoints are checked, if at all, at their own endpoint rather than on this shared path.

Enterprise SSO enforcement. When the credential owner's organization has SSO set to mode=required and the owner is not exempt, a separate request-time check can also refuse with reason = credential_policy_sso — see Credential-request enforcement in the SSO reference. A lookup failure on that check is 503, never a silent pass and never a 401.


Two-Factor Authentication (2FA)

GET /current/user/auth/2factor/

Get current 2FA status.

Auth: Required (JWT, scope: user or admin)

Request Example

curl -X GET "https://api.fast.io/current/user/auth/2factor/" \
  -H "Authorization: Bearer {jwt_token}"

Success Response (200 OK)

{
  "result": true,
  "state": "enabled",
  "totp": false
}

Response Fields

FieldTypeDescription
response.statestring2FA status: "enabled" (fully verified), "unverified" (added but not verified), or "disabled" (not configured).
response.totpbooleanWhether 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

ParameterTypeRequiredDefaultDescription
{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)

FieldTypeDescription
response.binding_uristringTOTP provisioning URI for QR code display.

Error Responses

Error CodeHTTP StatusMessageCause
10167409"2Factor already added, please remove first."2FA already enabled
10173406"An invalid channel was supplied."Invalid channel name
10168406"2Factor cannot be added, you need a valid phone_number and phone_country..."No phone number configured

Notes


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

ParameterTypeRequiredDescription
{token} string Yes 2FA verification code (e.g., 6-digit code).

Request Example

curl -X POST "https://api.fast.io/current/user/auth/2factor/verify/123456/" \
  -H "Authorization: Bearer {jwt_token}"

Success Response (202 Accepted)

{
  "result": true
}

Success Response — enrolment token, factor persisted (202 Accepted)

When the inbound token was an enrol-scoped enrolment token (see Interactive Login & Enrolment) AND this call actually verified the code and persisted the enrolment, the response carries a full session instead of the plain accepted body:

{
  "result": true,
  "expires_in": 86400,
  "auth_token": "{jwt_token}"
}

auth_token is a complete user-scope session — the enrolment interstitial is over. It is also delivered as an HttpOnly cookie when x-ve-session-cookie was sent, subject to the same opt-in as GET /current/user/auth/. This does not happen when the account was already enrolled (that branch returns the plain accepted body without checking the submitted code). Nor does it happen when the code verified but the enrolment write did not land — that arm answers 503 with a machine-readable reason instead, documented immediately below. An ordinary user-scoped self-service caller confirming their own 2FA setup is unaffected: it already holds a session and gets the plain response.

Enrolment NOT saved (503 Temporarily Unavailable)

A code the delivery side accepted, whose enrolment then failed to save, is its own outcome and is not the same as a wrong code. Either write behind the confirmation can fail, and both answer 503 carrying the same error.params.reason = two_factor_enrolment_not_persisted:

The correct client action for both is to REQUEST A NEW CODE and confirm again — not to resubmit the same one. The code was right, and by this point it may already have been consumed on the delivery side, so re-sending it can only fail again, for ever, with the account left short of enrolled. Send GET /current/user/auth/2factor/send/{channel}/ (or read a fresh code from the authenticator app for totp) and call this endpoint again with the new code.

This is deliberately distinguishable from the plain verification failure below. That 406 means what it says — the submitted code itself was not accepted — it carries no reason, and it is the one failure retried by asking the user to re-enter the code they already have.

Verification Failed (406 Not Accepted)

{
  "result": false
}

Error Responses

Error CodeHTTP StatusMessageCause
10173406"An invalid token was supplied to validate."Invalid token format
10170406"2Factor is not enabled."2FA not configured
503"Your code could not be confirmed because the enrolment could not be saved. Request a new code and confirm again."Every caller. The stored factor could not be read or saved, so the confirmation did not complete (params.reason = two_factor_enrolment_not_persisted). Request a NEW code and confirm again — this is NOT a wrong code, and the submitted one may already have been consumed
503"Your code was accepted but the enrolment could not be saved. Request a new code and confirm again."Enrolment tokens only. The submitted code verified, but the account write confirming the factor did not land, so no session was minted (params.reason = two_factor_enrolment_not_persisted). Request a NEW code and confirm again — the submitted one has already been consumed and resubmitting it can only fail again

Notes


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

ParameterTypeRequiredDescription
{token} string Yes Valid 2FA verification code (e.g., 6-digit TOTP or SMS code).

Request Headers

HeaderTypeRequiredDefaultDescription
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

FieldTypeDescription
response.expires_inintegerJWT expiration time in seconds.
response.auth_tokenstringNew JWT with full user scope.

Error Responses

Error CodeHTTP StatusMessageCause
10173406"An invalid token was supplied to authenticate."Invalid token format
10172406"2Factor is not enabled on this account."2FA not enabled
10174406"The supplied token failed to authenticate."Wrong 2FA code
10009401"Internal Error."JWT creation failure

Notes


DELETE /current/user/auth/2factor/{token}/

Disable (remove) 2FA from the account.

Auth: Required (JWT, scope: user or admin)

Path Parameters

ParameterTypeRequiredDescription
{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 CodeHTTP StatusMessageCause
10173406"An invalid token was supplied, valid token required to remove 2Factor."Invalid token format
10174406"The supplied token failed to authenticate."Token verification failed
10169500"2Factor could not be removed, please contact support."Internal removal failure

Notes


2FA Code Delivery Endpoints

Request a 2FA code via different channels. All require auth (accepts user, twofactor, admin, or enrol JWT scope — the last one lets an enrolment token request a code for the factor it is enrolling; see Interactive Login & Enrolment below).

GET /current/user/auth/2factor/send/sms/

Send code via SMS

GET /current/user/auth/2factor/send/call/

Send code via voice call

GET /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 CodeHTTP StatusMessageCause
10011401"Your credentials were not supplied or invalid."Invalid JWT
10175403"The scope of your credentials are not sufficient."Wrong JWT scope
10170406"2Factor is not enabled."2FA not configured on account

Notes


Complete 2FA Flows

Complete 2FA Login Flow

1. GET /current/user/auth/
   - Send email:password via HTTP Basic Auth
   - Response includes "2factor": true and limited-scope auth_token

2. GET /current/user/auth/2factor/send/sms/  (or /call/ or /whatsapp/)
   - Request a fresh 2FA code
   - Uses the limited-scope (twofactor) JWT

3. POST /current/user/auth/2factor/auth/{code}/
   - Submit the 2FA code
   - Receive a new JWT with full "user" scope
   - Use this token for all subsequent requests

Complete 2FA Setup Flow

1. POST /current/user/auth/2factor/{channel}/
   - Choose channel: sms, call, whatsapp, or totp
   - Phone number must be configured on account (for non-TOTP)
   - State becomes "unverified"
   - For TOTP: receive binding_uri for QR code

2. Receive code via selected channel (or scan QR code for TOTP)

3. POST /current/user/auth/2factor/verify/{token}/
   - Submit the verification code
   - State becomes "enabled"
   - 2FA is now active on the account

Complete 2FA Removal Flow

1. DELETE /current/user/auth/2factor/{code}/
   - Must provide valid 2FA code if state is "enabled"
   - Can remove without code if state is "unverified"
   - 2FA is fully removed from the account

Interactive Login & Enrolment (org-required 2FA)

An organization can require its members to hold a second factor before they may sign in — see Require-2FA Policy in the Organizations reference for the org-level setting. This section is what a client sees when that policy applies to an account that has not yet enrolled a factor, at the two login surfaces the policy governs.

Scope — password and social login only. The policy is checked at the moment GET /current/user/auth/ (Basic Auth) or POST /current/user/sso/signin/{provider}/ (personal Google/Microsoft sign-in) mints a session, because those are the only two surfaces where a factor can be demanded at that moment. It has no effect on API keys, OAuth grants, or MCP tokens — none of them is challenged for a factor at request time, policy or no policy. Enterprise SSO (POST /current/user/sso/exchange/) is unaffected regardless of this or any other org's requirement — see the note on that endpoint below.

The signal: enrol_required. Both GET /current/user/auth/ and the social sign-in callback always carry a boolean enrol_required field — present and false on every ordinary login. It is true only when: the account holds no factor, AND at least one org the account belongs to requires one. When true:

What the enrolment token can do — five endpoints, nothing else:

EndpointPurpose
POST /current/user/auth/2factor/{channel}/Add a factor — every channel, including totp (the only channel available to an account with no stored phone number).
POST /current/user/auth/2factor/verify/{token}/Confirm the factor. On success this call also upgrades the enrolment token into a full session — see the response documented on that endpoint above.
GET /current/user/auth/2factor/send/sms/Request a code for the factor being enrolled.
GET /current/user/auth/2factor/send/call/Same, via voice call.
GET /current/user/auth/2factor/send/whatsapp/Same, via WhatsApp.

It is refused everywhere else — GET/DELETE /current/user/auth/2factor/, POST /current/user/auth/2factor/auth/{token}/, POST /current/user/update/, and GET /current/user/auth/check/ all reject it with 403 and a params.reason (never a 401). The enrolment interstitial takes the user's identity from the login response, not from any of those calls.

The refusal reason is two_factor_enrolment_only, and it is always a 403 — never a 401. That distinction is the whole point: the credential is valid, correctly signed and unexpired, and is simply not admitted on the endpoint that was called. A client that reads it as 401 will discard a live enrolment token and strand the user mid-enrolment with no way back but a fresh sign-in. The refusal carries the ordinary scope-error params, with this shape:

"params": {
  "reason": "two_factor_enrolment_only",
  "entity_type": "userdetails",
  "entity_id": null,
  "required_access_mode": null,
  "current_access_mode": null
}

required_access_mode and current_access_mode are both null, and that is correct rather than missing data: no access mode would have made this call succeed, because the enrolment token is restricted by which endpoint it may call, not by how much authority it holds. Treat null as not applicable — never re-mint the credential asking for a wider mode. Branch on reason, never on the numeric code. The remedy is always the same: finish enrolling the factor, then use the full session the confirmation returns.

The flow — one code, not two:

1. GET /current/user/auth/                         (or the social callback)
   - Response: "2factor": true, "enrol_required": true, auth_token = enrolment token

2. POST /current/user/auth/2factor/{channel}/       (with the enrolment token)
   - Choose channel: sms, call, whatsapp, or totp
   - State becomes "unverified"

3. GET /current/user/auth/2factor/send/{channel}/   (skip for totp -- scan the QR code instead)
   - Request a fresh code

4. POST /current/user/auth/2factor/verify/{token}/  (with the enrolment token)
   - Submit the code
   - Response: full session -- auth_token, expires_in -- not the plain accepted body

A client that predates enrol_required is not broken by this policy, only stalled: it sees "2factor": true exactly as it does for an already-2FA-enabled account and shows its code screen, but has no code to submit — it cannot enrol from that screen. Reading enrol_required is required to route such a user into an enrolment UI instead.

What this policy does NOT do — four limits a login client must not assume away:

See Require-2FA Policy in the Organizations reference for the org-side setting, its refusals, and the populations the revocation covers.

Enterprise SSO Sign-In

Enterprise SSO signs a user in against their organization's own identity provider instead of against a Fastio password or a personal social account. An org administrator connects one identity provider (OpenID Connect or SAML 2.0) to the org and proves ownership of the email domains it speaks for; see the Enterprise SSO reference for that configuration surface. This section is the sign-in flow — how a browser is handed to that provider and comes back holding a JWT.

This is not GET|POST /current/user/sso/signin/{provider}/. That route is the personal Google / Microsoft convenience sign-in available to any individual account, and it needs no org configuration. The routes below are org-wide and organization-configured, even though they sit beside it under /current/user/sso/.

The flow

1. discover   POST /current/user/sso/discover/   (optional) an email address -> which org, if any
2. start      GET  /current/user/sso/start/      -> redirect_url at the organization's provider
3. provider   the user authenticates at their own identity provider
4. callback   the provider returns the browser to Fastio, which redirects it to
              {your_origin}/signin/sso and sets a short-lived, single-use handoff cookie
5. exchange   POST /current/user/sso/exchange/   empty body, cookies included -> JWT

Steps 1, 2 and 5 are calls your application makes. Steps 3 and 4 happen in the browser, and your application never calls the callback routes itself — they are the stable URLs the organization's administrator registers inside the identity provider.

Two browser requirements, both non-negotiable:

What the handoff is. On a successful callback, Fastio redirects the browser to {your_origin}/signin/sso carrying nothing in the URL and sets a ve_sso_ex cookie: HttpOnly, Secure, SameSite=Lax, Path=/, scoped to the registrable domain, and valid for 60 seconds and exactly one use. Because it never appears in a Location header, a URL or browser history, it is not exposed in any of the places a redirect is recorded. Your landing route reads nothing from the URL — it simply posts the exchange.

Failures come back in the fragment. A callback that cannot complete redirects to {your_origin}/signin/sso#error={reason}&org={org_domain} and sets no cookie. A reason string carries nothing sensitive, which is why it is allowed in a URL where the handoff is not. Clear any stale #error= fragment when your landing route mounts. The org parameter is present only once the organization is known — a refusal that happens before that, such as a sign-in record that has expired or already been used, carries the reason alone.

A third outcome lands on the same route: an administrator's test sign-in. An organization's administrator can run a test sign-in while SSO is still switched off (see the Enterprise SSO reference, The Test Sign-In). It completes the whole protocol and then deliberately stops — no account, no membership, no identity, no session and no handoff cookie. The browser arrives at:

{your_origin}/signin/sso?sso-test=1&ok=<0|1>&message=<urlencoded>

in the query string, not the fragment. Check for sso-test=1 before your sign-in handling runs. It is neither of the two sign-in outcomes: there is nothing to exchange, and a failure here is a test result rather than a login error. Show the ok and message values as the result of the test, and let the administrator read the full outcome from GET /current/org/{org_id}/sso/. Treat an unrecognised message as a generic failure — the values are a closed vocabulary.


POST /current/user/sso/discover/

"I typed this email address — where do I sign in?" Maps an email domain to the organization that federates it, for a platform-wide sign-in page that does not yet know which organization the user belongs to.

Auth: None (IP-throttled)

Request Parameters

ParameterTypeRequiredDescription
email string Yes The address the user typed. Max 320 characters.

Request Example

curl -X POST "https://api.fast.io/current/user/sso/discover/" \
  --data-urlencode "email=user@acme.com"

Success Response — the domain federates (200 OK)

{
  "result": true,
  "sso": true,
  "mode": "optional",
  "org": {
    "domain": "acme-corp",
    "name": "Acme Corporation"
  },
  "start_path": "/user/sso/start/?org=acme-corp&login_hint=user%40acme.com"
}

Success Response — the domain does not federate (200 OK)

{
  "result": true,
  "sso": false
}

Response Fields

FieldTypeDescription
ssobooleanWhether this email domain is federated to an organization.
modestringPresent only when sso is true: optional or required.
org.domainstringThe organization's URL-safe domain (slug), for the org parameter on start.
org.namestringThe organization's display name, for the sign-in button.
start_pathstringThe path to begin the sign-in, with the typed address already supplied as login_hint. Use it as given rather than composing your own.

Error Responses

HTTP Statusparams.reasonCause
429Over the per-IP throttle. The standard x-ve-limit-avail, x-ve-limit-max and x-ve-limit-expires headers and error code 1671 apply, exactly as documented in the global rate-limiting section.
503The lookup itself could not answer. Retry — this is never reported as sso: false.

Notes


GET /current/user/sso/start/

Begin an enterprise SSO sign-in for one organization. Returns the URL at that organization's identity provider to send the browser to.

Auth: None, but the browser key is required (ve_br_key cookie, or the x-ve-br-key request header). IP-throttled.

Query Parameters

ParameterTypeRequiredDescription
org string Yes The organization's domain (slug) — the same value as org.domain on the discovery and public-details responses. This route does not accept a 19-digit numeric org ID. Max 255 characters.
return_url string No An https:// URL on a Fastio-hosted origin. Only its origin is kept — any path, query or fragment is discarded, and the sign-in always lands on {origin}/signin/sso. Must be https, on the default port, and on an allow-listed platform domain; local development origins may include a port. Not echoed back. Defaults to the organization's own origin. Max 1024 characters.
login_hint string No The address the user typed. Passed to the identity provider so it can pre-fill, and checked up front against the organization's verified domains. Max 320 characters.

Request Example

curl -X GET "https://api.fast.io/current/user/sso/start/?org=acme-corp&return_url=https%3A%2F%2Fround-lake.dustinice.workers.dev%3A443%2Fhttps%2Facme-corp.fast.io&login_hint=user%40acme.com" \
  -b "ve_br_key={browser_key}"

Success Response (200 OK)

{
  "result": true,
  "provider": "sso",
  "protocol": "oidc",
  "redirect_url": "https://idp.example.com/authorize?response_type=code&client_id={client_id}&state={state}"
}

Response Fields

FieldTypeDescription
providerstringAlways sso, distinguishing an enterprise sign-in from google / microsoft.
protocolstringoidc or saml — which protocol the organization is configured for. Informational; the client treats both identically.
redirect_urlstringWhere to send the browser. Navigate the top-level window to it — this is a redirect flow, exactly like the social sign-in. Do not fetch it, and do not open it in an iframe.

Error Responses

HTTP Statusparams.reasonCause
406sso_not_configuredNo such organization, or it has no usable identity-provider configuration. The two are deliberately the same answer.
406sso_disabledThe organization has a configuration but its mode is off.
406domain_not_permittedThe login_hint address is not on a domain the organization has verified.
406browser_mismatchNo browser key was presented.
503Temporary failure opening the sign-in. Retry.

Notes


The identity-provider callbacks

These two routes are the URLs an organization's administrator registers inside their identity provider. Your application does not call them; the browser arrives at them from the provider. They are described here so client authors recognise what happens between start and exchange.

GET  /current/user/sso/oidc/callback/     OIDC redirect URI (code + state)
POST /current/user/sso/saml/acs/          SAML assertion consumer service (HTTP-POST binding)

Auth: None. On the OIDC route the state value, and on the SAML route the RelayState value, is the credential: each names a single-use record Fastio created at start, and the organization is resolved from that record alone. Both routes are IP-throttled.

Fastio validates what came back (state, nonce and PKCE for OIDC; signature, audience, InResponseTo, Recipient and Destination for SAML), resolves, links or creates the user's identity, and mints the one-time handoff.

Neither route ever returns a JSON body — a browser is waiting on the other end of it, so every outcome is a redirect:

OutcomeRedirectCookie
Success302 {your_origin}/signin/ssove_sso_ex, HttpOnly, Secure, SameSite=Lax, 60 seconds, single use
Failure302 {your_origin}/signin/sso#error={reason}&org={org_domain}none

{your_origin} is the origin of the validated return_url supplied at start, defaulting to the organization's own origin.

Notes


GET /current/user/sso/saml/metadata/

Service-provider (SP) metadata for one organization, for SAML identity providers that import a metadata document rather than taking fields one at a time.

Auth: None. IP-throttled.

Query Parameters

ParameterTypeRequiredDescription
org string Yes The organization's domain (slug). Max 255 characters.

Request Example

curl -X GET "https://api.fast.io/current/user/sso/saml/metadata/?org=acme-corp"

Success Response (200 OK)

application/samlmetadata+xml — a SAML 2.0 EntityDescriptor carrying the SP entity ID, the assertion consumer service URL and its HTTP-POST binding, and the emailAddress name-ID format.

Error Responses

HTTP StatusCause
404No such organization, the organization has no configuration, or it is not configured for SAML. All three are the same answer.
503Temporary failure building the document. Retry.

Notes


POST /current/user/sso/exchange/

Turn the 60-second handoff into a session. This is the call that actually signs the user in: the account, its organization membership and any role it inherits are all committed here, so an abandoned sign-in leaves nothing behind.

Auth: None. The ve_sso_ex handoff cookie and the ve_br_key browser key are the credentials, so the request must be sent with credentials included. Send no Authorization header. IP-throttled.

Request Parameters: None. The body is empty — the handoff and the browser key travel as cookies.

Request Example

curl -X POST "https://api.fast.io/current/user/sso/exchange/" \
  -b "ve_sso_ex={handoff_cookie}; ve_br_key={browser_key}"

The body is empty. From a browser, the same call is a POST with credentials: "include" and no body.

Success Response (200 OK)

{
  "result": true,
  "provider": "sso",
  "email": "user@acme.com",
  "token": "{jwt_token}",
  "account_created": false,
  "org": {
    "id": "1234567890123456789",
    "domain": "acme-corp"
  }
}

Response Fields

FieldTypeDescription
providerstringAlways sso.
emailstringThe address on the signed-in account.
tokenstringThe issued JWT. Send it as Authorization: Bearer {token}.
account_createdbooleanAlways present, always a booleantrue only when this exchange created the account. Note the difference from the social sign-in, where the field is omitted for a returning user.
org.idstring19-digit numeric ID of the organization the user signed into.
org.domainstringThat organization's URL-safe domain (slug). Land the user here first; treat anything you read from the account's profile afterwards as secondary.

Error Responses

HTTP Statusparams.reasonCause
406code_expiredNo handoff was presented, it had already been used, or its 60 seconds elapsed.
406browser_mismatchThe handoff was presented from a different browser than the one that called start.
406state_expiredThe sign-in record behind the handoff is gone — or the organization's configuration moved underneath the sign-in while it was in flight. Either way it means "start again".
406domain_not_permittedThe address the provider asserted is not on a domain the organization has verified.
406not_provisionedThe organization provisions through its directory only, and this person has not been provisioned. Nothing was created, linked or claimed.
406email_unverifiedThe provider did not assert the address as verified, and the address matches an existing account or a pending invitation to this organization. Linking an unverified address to either is how an account takeover is spelled, so it is refused. A brand-new account for an address nobody has invited is still created.
406account_conflictThe address belongs to an account this organization may not link to the federated identity.
406deprovisionedThe identity was deprovisioned by the organization's directory; a sign-in must not step over that.
406sso_disabled / sso_not_configuredThe configuration changed underneath an in-flight sign-in.
503Temporary failure, carrying no reason. Most of these have already spent the handoff, so restart the sign-in from start. The exception is a storage fault while the handoff is being claimed: nothing was consumed and the cookie is deliberately left in place, so repeating the exchange itself can succeed. Retry the exchange once; if it comes back code_expired, start again at start.

Notes


Sign-in error reasons

Every sign-in refusal — whether it arrives in a callback's #error= fragment or as error.params.reason on a 4xx from start or exchange — uses this closed vocabulary and no other value. Branch on reason. Do not branch on the numeric error.code (assigned per endpoint, so the same condition reports different numbers from different routes) and do not branch on the HTTP status alone.

reasonWhat happenedWhat to show the user
sso_not_configuredThe organization named has no usable identity-provider configuration — including the case where no such organization exists."Single sign-on is not set up for this organization." Offer the other sign-in methods.
sso_disabledThe organization has a configuration but has switched SSO off.The same message. Offer the other sign-in methods.
domain_not_permittedThe email address is not on a domain the organization has verified."That address is not managed by this organization." Do not suggest which addresses would work.
idp_errorThe identity provider refused, or returned something unusable."Your organization's identity provider could not complete the sign-in." Offer a retry, and point the user at their administrator.
email_unverifiedThe provider did not assert the address as verified."Your identity provider has not verified this email address." This one is the administrator's to fix at the provider.
account_conflictThe address belongs to an account the organization may not link."This email address is already in use by another account." Route to support.
state_expiredThe sign-in took too long, or the record behind it is gone. It also covers a sign-in that was overtaken by an administrator's change to the organization's configuration — a credential, or the SAML NameID format — while it was at the provider, and a first SAML sign-in that could not be completed safely at that moment — the service could not take or keep the protection such a sign-in needs, which implies no administrator action and no completed change."That sign-in expired." Send the user back to the start of the flow: restarting the sign-in is the whole remedy, and it picks up whatever the conditions are by then.
not_provisionedThe organization provisions its people through its directory only, and this person has no directory record. This includes a returning member who originally arrived through just-in-time creation, if the organization has since switched to directory-only provisioning."Your organization provisions access through its directory. Ask an administrator to add you." Do not offer a retry; nothing about retrying changes the answer.
code_expiredThe handoff was missing, already used, or older than 60 seconds.The same — restart the sign-in.
browser_mismatchThe sign-in was completed in a different browser than it started in, or no browser key was presented."Finish signing in from the browser you started in." Check that cookies are enabled and that the exchange request includes credentials.
deprovisionedThe organization's directory removed this identity."Your access to this organization has been removed."

Error envelope. The 4xx refusals follow the standard envelope documented in the API overview — result: false and an error object carrying code, text and params:

{
  "result": false,
  "error": {
    "code": 123456,
    "text": "This single sign-on session could not be completed.",
    "params": {
      "reason": "browser_mismatch"
    }
  }
}

login_options: what an organization's sign-in page may offer

An organization-scoped sign-in page needs to know which buttons to draw before anybody has authenticated. GET /current/org/{org_id}/public/details/ (unauthenticated — see the Organizations reference) carries a login_options block on the org object for exactly that:

{
  "result": true,
  "org": {
    "id": "1234567890123456789",
    "domain": "acme-corp",
    "name": "Acme Corporation",
    "login_options": {
      "password": true,
      "social": ["google", "microsoft"],
      "sso": {
        "enabled": true,
        "mode": "optional",
        "protocol": "oidc",
        "display_name": "Acme SSO",
        "start_path": "/user/sso/start/?org=acme-corp"
      },
      "signup": false
    }
  }
}

Fields

FieldTypeDescription
passwordbooleanWhether an email/password sign-in may be offered for this organization.
socialarrayPersonal social providers that may be offered. Empty when the organization requires SSO.
sso.enabledbooleanWhether an enterprise SSO button should be drawn.
sso.modestringoff, optional or required. Under required, present SSO as the only route; under optional, present it first, alongside the others.
sso.protocolstring/nulloidc or saml. Informational.
sso.display_namestring/nullThe label to put on the button, chosen by the administrator.
sso.start_pathstring/nullThe path to begin the sign-in. Use it as given.
signupbooleanWhether a self-service signup link may be offered.

Notes


Enterprise SSO Enforcement

An organization on the Enterprise plan can set its SSO mode to required. From that point every email address on one of that organization's verified domains must authenticate through the organization's identity provider: the routes that would issue a session from a password, create an account, or hand out, rotate or move a password refuse. How an administrator gets there — the plan, the verified domain, the configuration check — is in the Enterprise SSO reference, under Enforcement. This section is what a client sees.

Enforcement follows the email domain. An address on a verified domain of an enforcing organization is enforced whether or not that account has joined the organization, and whether or not an account exists at all.

What refuses

SurfaceRoute
Password sign-inGET /current/user/auth/
Account signupPOST /current/user/
Social sign-in (Google / Microsoft)/current/user/sso/signin/{provider}/
Password reset requestPOST /current/user/email/reset/
Password reset redemption, and setting a passwordPOST /current/user/password/{code}/
Rotating an existing passwordPOST /current/user/update/
Changing an email addressPOST /current/user/update/, POST /current/user/email/change/
Creating a new API keyPOST /current/user/auth/key/

The refusal. Every one of them answers 403 with reason = sso_required:

{
  "result": false,
  "error": {
    "code": 123456,
    "text": "This organization requires single sign-on.",
    "params": {
      "reason": "sso_required",
      "org": { "domain": "acme-corp" },
      "start_path": "/user/sso/start/?org=acme-corp&login_hint=user%40acme.com"
    }
  }
}
FieldTypeDescription
error.params.reasonstringAlways sso_required for this refusal. Branch on this, not on the numeric error.code, which is assigned per endpoint and so differs between the routes above.
error.params.org.domainstringThe organization's login slug — the organization to name as the one managing this account.
error.params.start_pathstringWhere to send the browser to sign in instead. It already carries the org and a login_hint for the address that was submitted. Use it as given; do not compose it yourself.

What to do with it. On a sign-in or signup screen, send the browser to start_path. In a settings screen — where the user already has a session — show that the account is managed by its organization and disable the control, rather than offering a retry that cannot succeed.

The refusal is identical for every address on the domain. It does not depend on whether an account exists, nor on whether the account holds a role that is exempt. That is deliberate: a caller cannot use these endpoints to learn whether an address is registered, or which addresses still hold a usable password. Never read an sso_required refusal as evidence that an account exists.

Break-glass for the owner, administrators and listed addresses

An organization's owner and its admins keep a password path, so an organization can never lock itself out of its own console. An administrator may additionally name individual addresses on the organization's enforcement exception list; a listed address is exempt exactly as an admin is, on the same three surfaces and no others — the break-glass password sign-in; setting or rotating the account password, whether by spending a mailed reset code or by changing an existing password from a signed-in session; and creating a new API key. Everywhere else on the table above — the ordinary password sign-in, signup, the social sign-in, requesting a password reset, and changing an email address — a listed address gets the byte-identical sso_required refusal. Requesting a reset code is not exempt, so an exempt account with no password reaches one only through the break-glass route below. Two routes take a break_glass=true query parameter:

RouteBehaviour
GET /current/user/auth/?break_glass=trueThe ordinary password check runs first, and only then — on a correct password — does the server check whether the account is the organization's owner, an admin, or an address on the organization's enforcement exception list. A non-exempt account gets the identical 403 sso_required.
POST /current/user/email/reset/?break_glass=trueAlways answers 202, exactly like an ordinary reset request for an address the platform does not recognize. The email is sent only when the account is the organization's owner, an admin, or an address on its enforcement exception list, and the response never reveals which.

Failures here count toward the ordinary sign-in lockout. Unlike the default sign-in surface, where an enforced address is refused before a password is examined, break_glass=true examines the password — so a wrong one consumes an attempt and can reach the 429 / 10760 lockout documented under GET /current/user/auth/.

Offer it as a deliberate administrator affordance, not as a general fallback. A user who is not exempt gains nothing from it except a consumed sign-in attempt.

What enforcement does not withdraw

Switching an organization to required does revoke web sessions. Every user on the organization's verified domains loses their existing web sessions — except exempt accounts — and gets a 401 on their next call, then signs back in through the identity provider. The same revocation happens when:

Handle it as you already handle any 401: discard the token and start the sign-in flow again. login_options on the organization, or the sso block below, tells you which route to offer.

The sso block on GET /current/user/details/

GET /current/user/details/ carries an sso block for the caller's own record only, so a client can draw the right interface before it makes a call that would be refused:

"sso": {
  "enforced": true,
  "exempt": false,
  "org_domain": "acme-corp"
}
FieldTypeDescription
sso.enforcedbooleanWhether the caller's email domain is enforced.
sso.exemptbooleanWhether this caller keeps a password path — because they are the organization's owner or an admin, or because an administrator has named their address on the organization's enforcement exception list. The two are the same exemption and reach the same three surfaces.
sso.org_domainstringThe organization's login slug — the same value start_path uses.

Use it to draw the interface, not to make the decision. Disable the "create API key" and "set password" controls when enforced is true and exempt is false. The server refuses those calls either way; the block only saves the user a round trip that ends in a 403.

The block may be absent — on an older backend, or when the state could not be read. Treat an absent block as "not enforced" and fall back to your current behaviour; never disable a control on the strength of a field that is not there.

It is never rendered for any user other than the caller. Reading another user's details never includes it: who is exempt from enforcement is not a fact one user may learn about another.

When the platform cannot tell

If the enforcement state cannot be determined, the endpoints above answer with a retryable, temporarily-unavailable error rather than letting a password through. Retry it. Do not treat it as "not enforced" — folding an outage into "no enforcement" would, during an incident, invite exactly the password sign-ins that enforcement exists to prevent.


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

ParameterTypeRequiredDescription
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

FieldTypeDescription
response.contactsobjectMap of email address (key) to display name (value) for each matched user. Unchanged, backward-compatible.
response.usersarrayList of matched people as {id, email, name} objects, deduplicated by email. Provides the user id the contacts map cannot.
response.users[].idstring | nullThe 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[].emailstringThe matched person's email address.
response.users[].namestringThe matched person's display name.

Error Responses

Error CodeHTTP StatusMessageCause
10011401"Authentication required"Missing or invalid JWT token
205516 / 207092406"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 120189500"Internal error"157360 when the contacts search client fails to initialize; 120189 when the user-profile search client fails to initialize

Notes

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

CodeDescriptionHTTP Status
1600Internal Error500 Internal Server Error
1605Invalid Input406 Not Acceptable
1658Not Acceptable406 Not Acceptable
1607Duplicate Entry406 Not Acceptable
1669Already Exists409 Conflict
1660Conflict409 Conflict
1609Not Found / Resource Missing404 Not Found
1610General Error500 Internal Server Error
1650Authentication Invalid401 Unauthorized
1651Invalid Request Type405 Method Not Allowed
1653User Not Found404 Not Found
1701Gone410 Gone — endpoint retired by decision; stop calling the path, do not retry or vary the id
1671Rate Limited429 Too Many Requests
1680Access Denied401 Unauthorized
1670Restricted406 Not Acceptable
1677Locked423 Locked
1673SSO Auth Error401 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.

Codeparams.reasonWhen
10767scope_admin_requiredAn administrative operation called with a credential that is not admin-capable
10768scope_exceeds_issuerThe requested scopes are broader than the credential making the request (API-key create/update, OAuth session narrowing)
10768access_mode_exceeds_initiateAn OAuth consent asked for a broader access mode, or for account settings, than the authorization was initiated with
10769userdetails_scope_requiredAn account-settings operation without userdetails:*:rw
10770scope_write_requiredA 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:

FieldAlways presentValue
reasonYesOne of the four strings above
entity_typeYese.g. org, workspace, share, user, userdetails
entity_idYesThe 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_modeYese.g. rwa, rw
current_access_modeYesWhat the credential holds for that entity, or null
credential_typeYesapi_key, oauth or session
credential_idNoPresent for an API-key caller
credential_labelNoPresent 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

Token Types

TypeFormatLifetimeUse
JWT (Basic Auth)RS256-signed JSON Web TokenConfigurable (default varies)General API access
JWT (OAuth)RS256-signed JSON Web Token1 hourOAuth-based API access
Refresh TokenOpaque stringLong-livedObtaining new access tokens (OAuth only)
API KeyAlphanumeric stringConfigurable (default: no expiry)Service-to-service communication. Optionally scoped with permissions, agent name, and expiration.

Security Best Practices

  1. Always use HTTPS for all API communication.
  2. Store refresh tokens and API keys securely (OS keychain, encrypted storage).
  3. Never log tokens in client-side logs or analytics.
  4. Persist the refresh_token from the response; it is long-lived and returned unchanged on refresh (no rotation needed).
  5. Verify the state parameter in OAuth callbacks to prevent CSRF.
  6. Handle 401 responses by attempting a token refresh; if refresh fails, re-authenticate.
  7. Revoke tokens on logout by calling the revoke endpoint and clearing local storage.
↑ Back to top