Management API
Create, find, update, import, block and delete users, create login links and end sessions over HTTPS.
Manage the users and sessions of one tenant environment over HTTPS. The machine-readable version is /openapi.json (OpenAPI 3.1).
#Base URL and authentication
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 domainEvery 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 |
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):
{
"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 |
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}:
{
"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.
#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.
#Errors
{ "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) |