:::endpoint POST /v1/auth/token

Answers with one of two things: a token, or a two-factor challenge. 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. Required       |
| `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                                       |

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
        }
    },
    "meta": { "request_id": "req_01kz4bkr1aj1gv63d8fv3133ey" }
}
```

Status is `201`.

## Response, second factor needed

```json
{
    "data": {
        "object": "two_factor_challenge",
        "status": "two_factor_required",
        "challenge": "a14ea608e89261062f4e90792701ef2a7c5ad1188c8537108efd788d1390ce03",
        "expires_in": 300,
        "methods": ["passkey", "totp", "recovery_code"]
    },
    "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.

## 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    | `two_factor_setup_required`     | The firm requires two-factor and this account has none. It has to be set up on the web first                                               |
| 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.
:::