/v1/auth/tokenAnswers with one of three things: a token, a two-factor challenge, or a choice
between environments. Branch on status, never on the HTTP code.
The token appears exactly once, here. There is no endpoint that hands it out again. Store it where the platform keeps secrets, the Keychain on iOS and the Keystore on Android, and never in ordinary storage.
Request
curl https://api.proppertrading.com/v1/auth/token \
-H "Content-Type: application/json" \
-d '{
"workspace": "acmefunded.com",
"email": "[email protected]",
"password": "the password they typed",
"app": "mail",
"scopes": ["me:read", "mail:write", "notifications:write", "devices:write"],
"device_name": "Jamie iPhone",
"platform": "ios",
"os_version": "26.0",
"app_version": "1.0.0"
}'
| Field | Type | Description |
|---|---|---|
workspace | string | The address of the environment. Optional |
email | string | Required |
password | string | Required |
app | string | mail, admin or personal. Required |
scopes | array | What this client wants. Required, at least one |
device_name | string | Shown on the owner's security screen. Optional |
platform | string | ios, android or web. Optional |
os_version | string | Optional |
app_version | string | Optional |
Without an environment
Leave workspace out and the address and password decide. An address is unique
per environment rather than across the platform, so it can belong to several
accounts: the password is checked against each of them, and only the ones it is
right for go on. One is an ordinary sign-in. More than one answers with a
choice.
An environment is only ever named to somebody who just proved they have an
account there. A wrong password and an unknown address answer invalid_login,
in the same time, exactly as they do with an environment.
The device fields are decoration for a human reading a device list. They decide nothing, so there is no harm in them being wrong, and no point in making them clever.
Response, signed in
{
"data": {
"object": "access_token",
"status": "signed_in",
"token": "ptat_9f2c1a44_3b8e7d2f5c9a1b4e6d8f0a2c4e6b8d1f",
"session": {
"id": "9f2c1a44",
"object": "session",
"name": "Jamie iPhone",
"app": "mail",
"platform": "ios",
"device_name": "Jamie iPhone",
"os_version": "26.0",
"app_version": "1.0.0",
"current": true,
"two_factor": false,
"last_seen_at": null,
"created_at": "2026-08-19T15:00:22+00:00",
"expires_at": null
},
"workspace": {
"object": "workspace",
"id": "acmefunded",
"name": "Acme Funded",
"platform": false,
"url": "https://acmefunded.com",
"branding": {
"logo_url": "https://acmefunded.com/storage/logo.webp",
"logo_dark_url": null,
"icon_url": null,
"primary_color": "#3b82f6",
"primary_color_dark": "#60a5fa",
"primary_foreground": "#ffffff",
"primary_foreground_dark": "#0b1530"
},
"maintenance": false
}
},
"meta": { "request_id": "req_01kz4bkr1aj1gv63d8fv3133ey" }
}
Status is 201. workspace is the environment the account belongs to, in the
same shape as resolving one, so a client that signed
in without naming it can still look like it.
Response, second factor needed
{
"data": {
"object": "two_factor_challenge",
"status": "two_factor_required",
"challenge": "a14ea608e89261062f4e90792701ef2a7c5ad1188c8537108efd788d1390ce03",
"expires_in": 300,
"methods": ["passkey", "totp", "recovery_code"],
"workspace": { "object": "workspace", "id": "acmefunded", "name": "Acme Funded", "platform": false, "url": "https://acmefunded.com", "branding": { "logo_url": null, "logo_dark_url": null, "icon_url": null, "primary_color": "#3b82f6", "primary_color_dark": null, "primary_foreground": "#ffffff", "primary_foreground_dark": null }, "maintenance": false }
},
"meta": { "request_id": "req_01kz4bkr1aj1gv63d8fv3133ey" }
}
Status is 200. Send the challenge on to
the second step with one of the answers methods
names.
methods lists only what this client can actually present, in the order worth
offering. passkey appears when the account has a passkey registered
through an app, which is not the same credential as a
passkey on the firm's own website: a native app may only ask for domains built
into it, so the two are separate. totp and recovery_code appear together or
not at all.
Response, two-factor to set up
{
"data": {
"object": "two_factor_setup",
"status": "two_factor_setup_required",
"challenge": "c3a91f0e5b7d2c4e6a8f1b3d5c7e9a0b2d4f6a8c1e3b5d7f9a0c2e4b6d8f1a3c",
"expires_in": 600,
"methods": ["totp"],
"workspace": { "object": "workspace", "id": "acmefunded", "name": "Acme Funded", "platform": false, "url": "https://acmefunded.com", "branding": null, "maintenance": false }
},
"meta": { "request_id": "req_01kz4bkr1aj1gv63d8fv3133ey" }
}
Status is 200, and only with "app": "admin". The account has to have
two-factor authentication (an owner, our own staff, or a role that demands it)
and has none yet, so it sets one up now, as the website makes it do after the
password: ask for a secret, add it to an
authenticator, and confirm it with a code,
which finishes the sign-in. Every other app is refused with
two_factor_setup_required instead, because the clients built before this step
expect that.
Response, several environments
{
"data": {
"object": "workspace_choice",
"status": "workspace_choice_required",
"choice": "5d0c3f2e9a7b4c1d8e6f0a2b4c6d8e1f3a5b7c9d0e2f4a6b8c1d3e5f7a9b0c2d",
"expires_in": 300,
"workspaces": [
{ "object": "workspace", "id": "acmefunded", "name": "Acme Funded", "platform": false, "url": "https://acmefunded.com", "branding": { "logo_url": null, "logo_dark_url": null, "icon_url": null, "primary_color": "#3b82f6", "primary_color_dark": null, "primary_foreground": "#ffffff", "primary_foreground_dark": null }, "maintenance": false },
{ "object": "workspace", "id": "northfunding", "name": "North Funding", "platform": false, "url": "https://northfunding.com", "branding": null, "maintenance": false }
]
},
"meta": { "request_id": "req_01kz4bkr1aj1gv63d8fv3133ey" }
}
Status is 200, and only without workspace. Show the list and send the pick to
the choice step. A switched-off account is
left out of the list while another is on it.
Scopes you asked for and scopes you got
Read scopes back from your profile rather than
assuming the list you sent. Anything a person's device may never hold is dropped
silently, so an over-broad client keeps working and simply gets less.
Errors
| Status | Code | Meaning |
|---|---|---|
| 400 | workspace_not_found | Nothing is served from that address |
| 401 | invalid_login | Wrong address, wrong password, or no such account here |
| 401 | account_deactivated | The account exists and has been switched off |
| 403 | admin_access_required | Signing in to the Propper app with an account that does not help run the environment |
| 403 | two_factor_setup_required | The account has to have two-factor and has none. Set it up on the web first. With "app": "admin" this is a setup step instead |
| 403 | two_factor_method_unavailable | Every second factor on the account is bound to a domain an app cannot ask for. Register a passkey through the app, or add an authenticator |
| 403 | block_anonymizer | The firm blocks VPN, proxy and Tor connections |
| 403 | block_country | The firm blocks connections from this country |
| 422 | validation_failed | A field is missing or malformed |
| 429 | rate_limit_exceeded | Ten attempts per ten minutes per address, five per email |
| 503 | tenant_in_maintenance | The firm is closed for maintenance |
A wrong password and an unknown address answer the same
Both give invalid_login, and both take the same time. This endpoint is not a
way to find out who has an account with which firm.