:::endpoint POST /v1/auth/token

Answers 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

```bash
curl https://api.proppertrading.com/v1/auth/token \
  -H "Content-Type: application/json" \
  -d '{
    "workspace": "acmefunded.com",
    "email": "jamie@acmefunded.com",
    "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](/docs/api/apps/choose-workspace).

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

```json
{
    "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](/docs/api/apps/workspace), so a client that signed
in without naming it can still look like it.

## Response, second factor needed

```json
{
    "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](/docs/api/apps/two-factor) 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](/docs/api/apps/passkeys), 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

```json
{
    "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](/docs/api/apps/two-factor-setup), add it to an
authenticator, and [confirm it with a code](/docs/api/apps/two-factor-setup-confirm),
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

```json
{
    "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](/docs/api/apps/choose-workspace). 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](/docs/api/apps/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                                                                                                         |

:::warning 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.
:::