# Accounts API

> The control-plane API behind the console: browser contract, sessions, login flow, roles and handles.

Page: https://docs.aggre.org/api/accounts · Section: API reference · All pages: https://docs.aggre.org/llms.txt

`https://accounts.aggre.org` is the **control plane**: sign-in, personal accounts and organizations, invitations, API-key management and usage views. It is what the [console](https://docs.aggre.org/console/overview.md) calls. You do not need it to read data (that is the [data API](https://docs.aggre.org/api/data.md), which takes API keys); you need it only if you build your own console or automate account management from a browser session.

| | Data API | Accounts API |
|---|---|---|
| Host | `api.aggre.org` | `accounts.aggre.org` |
| Credential | API key (`Authorization: Bearer aggre_sk_...`) | Session cookie |
| Browser access (CORS) | none | only `https://app.aggre.org` and `https://admin.aggre.org` |
| Accepts the other credential? | no | no: it never accepts an API key |

The route list with parameters is generated in the [Accounts API reference](https://docs.aggre.org/api/accounts/reference.md); the OpenAPI file is [`/openapi/accounts.yaml`](https://docs.aggre.org/openapi/accounts.yaml). Platform-staff routes (`/v1/admin/*`) are not documented here.

## Browser contract {#browser-contract}

1. Every call is `fetch(url, { credentials: "include" })` from the browser. The cookies belong to `accounts.aggre.org`, so a server on another host never sees them: logged-in data is fetched in the browser, not server-rendered.
2. Every non-GET request needs all three: the `Origin` the browser sends (it must be exactly an allowed app origin), the header `X-Aggre-CSRF: 1` (any non-empty value), and `Content-Type: application/json` when there is a body. Missing or wrong: `403 csrf_required` or `403 origin_not_allowed`.
3. Every authenticated request, GET included, must carry an allowed `Origin`; one without is treated as unauthenticated (`401`).
4. CORS: exact match on the configured origins, `Access-Control-Allow-Credentials: true`, allowed headers `Content-Type, X-Aggre-CSRF`, methods `GET, POST, PATCH, DELETE, OPTIONS`. No wildcard. Responses carry `Vary: Origin` and `Cache-Control: no-store`.
5. Cookies are `HttpOnly; Secure; SameSite=Lax`, host-only, with the `__Host-` prefix: `__Host-aggre_app` for the app audience and `__Host-aggre_admin` for the admin audience. They are never interchangeable.
6. Success bodies are `{ "data": ... }`; a secret or token is a sibling key (`secret`, `token`) and appears **once**. Errors use the shared envelope ([Errors](https://docs.aggre.org/concepts/errors.md)).
7. Times are RFC 3339 UTC, ids are UUID strings, handles are lowercase.

## Signing in {#login}

Login is a **full-page navigation**, not `fetch`:

```text
https://accounts.aggre.org/login/{provider}?audience=app&return_to=https%3A%2F%2Fapp.aggre.org%2F
```

`provider` is one of the ids returned by `GET /v1/config` (for example `google`, `github`, `orbbit`). `return_to` must be an absolute URL on the audience's own origin; anything else is replaced by that origin's root. The browser comes back to `return_to` with the session cookie set, or to `{origin}/login?error=<code>` with one of:

| `error` | Meaning |
|---|---|
| `access_denied` | The person cancelled at the provider. |
| `invalid_state` | The login state is missing, expired, reused, or the browser finishing the login is not the one that started it. |
| `provider_error` | The provider rejected the code, returned an invalid token or was unreachable. |
| `account_disabled` | The account is suspended. |
| `not_staff` | The admin audience was requested by a user who is not platform staff. |
| `identity_in_use` | Linking a provider identity that already belongs to another user. |
| `session_required` | Linking without a valid session. |
| `internal` | Anything else. |

OIDC providers use authorization code with PKCE, `state` and `nonce`, and verify the id token's signature and claims. GitHub uses OAuth 2 with PKCE.

### Who you become {#identity}

An identity is `(provider, subject)`, never the e-mail address. A known identity logs you in as the same user. For a new identity, if the provider reports an e-mail it vouches for (a verified address) and an existing user's verified primary e-mail equals it, the identity is **linked to that user** (auto-merge); otherwise a new user and personal account are created. An unverified or absent e-mail never merges anything, and a later change of e-mail at the provider never moves an identity. You can link another provider while signed in (`link=1`) and unlink one as long as another remains.

## Sessions {#sessions}

A session is an opaque random token in the cookie; only its hash is stored. App sessions live 30 days from login (not sliding) and die after 14 days without use; admin sessions live 8 hours. At most 20 live sessions per user and audience: a login beyond that revokes the oldest. `POST /v1/auth/logout` revokes the current session and `POST /v1/auth/logout-all` all of them. A suspended account cannot sign in and all its sessions stop working.

## Roles {#roles}

On a personal account you are the owner. An organization has `owner`, `admin` and `member`, always with at least one owner. Details of who can do what are in [Organizations](https://docs.aggre.org/console/organizations.md#roles). A caller with no relation to an account gets `404 not_found` (existence is not leaked); one with a relation but too little role gets `403 forbidden`.

## Handles {#handles}

Users and organizations share one namespace of handles: 3 to 39 characters of `a-z`, `0-9` and `-`, not starting or ending with `-`, no `--`. Reserved words (`admin`, `api`, `app`, `docs`, `login`, `settings`, ...) are refused. The first rename of an account is free; after that, once per 30 days (`409 conflict` with the next date). `GET /v1/handles/{handle}` checks availability.

## Rate limits {#rate-limits}

Besides the per-IP limit on every route, the login routes share a tighter per-IP limit; over it the answer is `429 rate_limited` with `Retry-After`.
