Reference

Management API

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

View .md llms.txt

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 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):

ScopeAllows
management:users:readList, search and read users and their sessions
management:users:writeCreate, update, import, block and delete users; create login links
management:sessions:writeEnd 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

MethodPathScope
GET/users?q=&limit=&cursor=users:readList or search users
GET/users/lookup?email= (or phone, username)users:readFind one user
GET/users/{id}users:readA user with identities, sessions and two-factor state
POST/usersusers:writeCreate a user
PATCH/users/{id}users:writeUpdate a user
DELETE/users/{id}users:writeDelete a user
POST/users/importusers:writeImport up to 500 users
POST/users/{id}/login-linkusers:writeCreate a single-use sign-in link
GET/users/{id}/sessionsusers:readActive sessions
DELETE/users/{id}/sessionssessions:writeEnd all of a user's sessions
DELETE/sessions/{sid}sessions:writeEnd 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.

FieldType
emailstring or nullAt least one of email, phone or username when creating
emailVerifiedboolean
phonestring or nullE.164, e.g. +447700900123
phoneVerifiedboolean
usernamestring or null
name, given_name, family_namestring or null
picturehttps URL or null
locale, zoneinfostring or null
birthdateYYYY-MM-DD or null
metadataobjectReplaces the stored metadata: read, merge, then send
blockedbooleantrue ends sessions and stops tokens
passwordstringPlain text; hashed with argon2id
passwordHashstringAn 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.

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

json
{ "error": "insufficient_scope", "error_description": "requires management:users:write" }
Statuserror
400invalid_request, invalid_user, invalid_email, invalid_phone, identifier_required, unsupported_password_hash
401invalid_token (missing, expired, revoked, not a client-credentials token)
403insufficient_scope
404not_found, user_not_found
409identifier_taken, or the user can't sign in (login links)