# Management API

> Create, find, update, import, block and delete users, create login links and end sessions over HTTPS.

Source: https://9in.io/docs/reference/management-api/

Manage the users and sessions of one tenant environment over HTTPS. The machine-readable version is [/openapi.json](https://9in.io/openapi.json) (OpenAPI 3.1).

## Base URL and authentication

```text
https://<tenant>.9in.io/api/v1           production
https://<tenant>-sandbox.9in.io/api/v1   sandbox (separate users)
https://login.acme.com/api/v1            a custom domain
```

Every request needs `Authorization: Bearer <token>`, a client-credentials token of a **confidential** application that was given management scopes (**Applications → app → API Access → Management API**):

| Scope | Allows |
|---|---|
| `management:users:read` | List, search and read users and their sessions |
| `management:users:write` | Create, update, import, block and delete users; create login links |
| `management:sessions:write` | End sessions |

```bash
TOKEN=$(curl -s -X POST https://acme.9in.io/oauth/token -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d grant_type=client_credentials -d scope="management:users:read management:users:write" | jq -r .access_token)
```

The application is re-checked on every call: removing a scope or deleting the application takes effect at once. With `@9in-io/sdk/server`, `ManagementClient` gets and caches tokens itself, asking each call for only the scope it needs.

## Endpoints

| Method | Path | Scope | |
|---|---|---|---|
| GET | `/users?q=&limit=&cursor=` | users:read | List or search users |
| GET | `/users/lookup?email=` (or `phone`, `username`) | users:read | Find one user |
| GET | `/users/{id}` | users:read | A user with identities, sessions and two-factor state |
| POST | `/users` | users:write | Create a user |
| PATCH | `/users/{id}` | users:write | Update a user |
| DELETE | `/users/{id}` | users:write | Delete a user |
| POST | `/users/import` | users:write | Import up to 500 users |
| POST | `/users/{id}/login-link` | users:write | Create a single-use sign-in link |
| GET | `/users/{id}/sessions` | users:read | Active sessions |
| DELETE | `/users/{id}/sessions` | sessions:write | End all of a user's sessions |
| DELETE | `/sessions/{sid}` | sessions:write | End one session |

## The user object

What the API returns (OpenID Connect claim names):

```json
{
  "id": "usr_8k2m4q",
  "email": "jane@acme.com",
  "email_verified": true,
  "phone_number": null,
  "phone_number_verified": false,
  "username": null,
  "preferred_username": null,
  "name": "Jane Doe",
  "given_name": "Jane",
  "middle_name": null,
  "family_name": "Doe",
  "nickname": null,
  "gender": null,
  "website": null,
  "picture": null,
  "locale": "en",
  "zoneinfo": null,
  "birthdate": null,
  "blocked": false,
  "metadata": { "plan": "pro", "fields": { "company": "Acme" } },
  "createdAt": 1794200000000,
  "updatedAt": 1794200000000,
  "lastLoginAt": 1794203000000,
  "loginCount": 12
}
```

Times are milliseconds since the epoch. Custom profile fields live in `metadata.fields`.

## Creating and updating

`POST /users` and `PATCH /users/{id}` take these fields. **Three names differ from the output:** `emailVerified`, `phone` and `phoneVerified`.

| Field | Type | |
|---|---|---|
| `email` | string or null | At least one of email, phone or username when creating |
| `emailVerified` | boolean | |
| `phone` | string or null | E.164, e.g. `+447700900123` |
| `phoneVerified` | boolean | |
| `username` | string or null | |
| `name`, `given_name`, `family_name` | string or null | |
| `picture` | https URL or null | |
| `locale`, `zoneinfo` | string or null | |
| `birthdate` | `YYYY-MM-DD` or null | |
| `metadata` | object | **Replaces** the stored metadata: read, merge, then send |
| `blocked` | boolean | `true` ends sessions and stops tokens |
| `password` | string | Plain text; hashed with argon2id |
| `passwordHash` | string | An existing bcrypt, argon2, PBKDF2 or Firebase scrypt hash |

```bash
curl -X POST https://acme.9in.io/api/v1/users -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d '{ "email": "jane@acme.com", "emailVerified": true, "name": "Jane Doe", "password": "…" }'
# → 201 { "user": { … } }
```

Errors: `400 invalid_user` (with `details: [{ path, message }]`), `invalid_email`, `invalid_phone`, `identifier_required`, `unsupported_password_hash`; `409 identifier_taken`; `404 not_found`.

## Listing and searching

`GET /users` returns newest first: `{ "users": [...], "nextCursor": "…" | null, "total": 1234 }`. Pass `nextCursor` back as `cursor` for the next page. `q` matches email, username, phone and name (substring) or an exact id; `limit` is 1 to 200 (default 50).

## Getting one user

`GET /users/{id}`:

```json
{
  "user": { … },
  "identities": [{ "connection": "social:google", "subject": "1098…", "userId": "usr_…", "profile": {}, "createdAt": 1794200000000 }],
  "sessions": [{ "sid": "…", "userId": "usr_…", "authTime": 1794199000, "amr": ["pwd"], "ip": "81.2.69.160", "userAgent": "…", "createdAt": …, "lastSeenAt": …, "expiresAt": …, "revokedAt": null }],
  "hasPassword": true,
  "mfa": { "enabled": true, "since": 1794100000000, "recoveryCodesLeft": 9 }
}
```

## Importing

`POST /users/import` with `{ "users": [...], "on_conflict": "skip" | "update", "source": "legacy" }`. Up to 500 users (50 with plain-text passwords). Records may also carry `id` (kept as the user id), `identities` (`[{ connection, subject }]`), `createdAt` and `lastLoginAt`. Returns `{ "results": [{ index, ok, status: "created" | "updated" | "skipped" | "failed", id?, reason?, error?, details? }] }`, one per record, in order. See [Importing users](https://9in.io/docs/importing-users/).

## Login links

`POST /users/{id}/login-link` with optional `{ "return_to": "https://app.acme.com/callback", "ttl_seconds": 600 }` returns `201 { "url": "…", "expires_at": 1794200600 }`. See [Login links](https://9in.io/docs/login-links/).

## Errors

```json
{ "error": "insufficient_scope", "error_description": "requires management:users:write" }
```

| Status | `error` |
|---|---|
| 400 | `invalid_request`, `invalid_user`, `invalid_email`, `invalid_phone`, `identifier_required`, `unsupported_password_hash` |
| 401 | `invalid_token` (missing, expired, revoked, not a client-credentials token) |
| 403 | `insufficient_scope` |
| 404 | `not_found`, `user_not_found` |
| 409 | `identifier_taken`, or the user can't sign in (login links) |
