Authentication
An account is created and signed in to by phone: the server sends a code, and receiving it proves the number. There are no passwords. A code to sign in to an account signed in elsewhere goes there, as a message from Mandarin, unless an SMS is asked for; any other goes by SMS. A device keeps the tokens it is given and signs in with them afterwards, so the code is needed once per device. A device without a SIM of its own — a browser — can instead be let in by a phone already signed in, which scans a QR code it shows.
Only connection.hello, connection.ping, connection.close, auth.code,
auth.phone, auth.qr, auth.token, auth.refresh and
invite.get are available before authentication.
Request a Code
{
"id": "1",
"type": "auth.code",
"data": {
"phone": "+79161234567",
"app_hash": "FA+9qCX9VSu",
"language": "ru"
}
}
| Field | Type | Required | Description |
|---|---|---|---|
phone |
string | Yes | The number in E.164 form: +, then the country code and the subscriber number |
app_hash |
string | No | The 11-character hash Android's SMS Retriever derives from the app. The SMS then ends with it, which lets the app read the code without the SMS permission |
language |
string | No | BCP 47 language tag for the SMS text, used when the server has it; the session the code signs in keeps it, unless its client says its own |
sms |
boolean | No | Send the code by SMS even where it could go to the account's devices |
Success:
{
"id": "1",
"type": "auth.code",
"ok": true,
"data": {
"verification_id": "v_7c1e...",
"code_length": 6,
"expires_in": 300,
"resend_in": 60,
"sent_to": "app",
"devices": ["Google Pixel 10 Pro Fold", "Mac"]
}
}
| Field | Type | Description |
|---|---|---|
verification_id |
string | Opaque handle of this attempt, passed to auth.phone |
code_length |
number | Digits in the code, for sizing the input |
expires_in |
number | Seconds the code stays valid |
resend_in |
number | Seconds before another code may be requested for this number |
sent_to |
string | Where the code went: app — the account's devices, as a message from Mandarin — or sms |
devices |
array | With app: the devices the code went to, by the device_name their sessions gave (else the app's name), each once |
A code to sign in to an account that has a session made on a CLEMA client —
by a code or a QR code, not through a transport
— goes to the account's devices, unless the request says "sms": true: the
client says where to look, and once resend_in is over offers an SMS
instead. Any other code goes by SMS, as one to change the
number always does: it proves the number, not the
account. A code in the app is told to the account's devices alone: no
transport shows it.
Requesting again for the same number replaces the previous code: only the
newest one is accepted. A number gets a code a minute, and five SMS a day: a
code in the app costs nothing. A number the server does not send to — a
country it does not serve, for instance — returns not_supported, and so does
a server that sends no SMS when the code would go by SMS. A failure of the SMS
provider returns delivery_failed, and the client may try again later.
sent_to tells whether the number has an account signed in somewhere, as
Telegram's answer does, and devices names its devices to whoever asks; the
answers do not otherwise depend on it. A number gets ten codes a day in the
app: each is five more guesses at its code, and a message to its owner. An
account that blocked Mandarin gets its codes by SMS.
Sign In with the Code
{
"id": "2",
"type": "auth.phone",
"data": {
"verification_id": "v_7c1e...",
"code": "123456",
"client": {
"device_name": "Pixel 8",
"platform": "Android",
"name": "Mandarin",
"version": "1.0.0"
}
}
}
client is optional untrusted display metadata. Each field is trimmed and
must contain valid text without control characters. device_name is limited to
80 characters; platform, name, and version to 40 characters each.
language, a BCP 47 tag, is what its user reads: Mandarin writes
to the account in the language of the session used last that gave one.
Success:
{
"id": "2",
"type": "auth.phone",
"ok": true,
"data": {
"user_id": 12345,
"access_token": "abc123...",
"refresh_token": "xyz789...",
"expires_in": 3600,
"created": true,
"session": {
"session_id": "s_0123...",
"client": {"device_name": "Pixel 8", "platform": "Android", "name": "Mandarin", "version": "1.0.0"},
"created_at": "2026-09-24T10:00:00Z",
"last_active_at": "2026-09-24T10:00:00Z",
"expires_at": "2026-10-24T10:00:00Z",
"current": true,
"online": true,
"push": false
}
}
}
| Field | Type | Description |
|---|---|---|
user_id |
number | User's numeric ID |
access_token |
string | Short-lived token for session authentication (1 hour) |
refresh_token |
string | Long-lived token for obtaining new access tokens (30 days) |
expires_in |
number | Access token lifetime in seconds |
created |
boolean | true when this sign-in created the account, so the client asks for a profile next |
session |
object | Persistent login created for this client |
The first sign-in with a number no account has creates an account for it, which Mandarin welcomes; later ones sign in to the account the number belongs to (see Phone Number), which Mandarin tells of the new session. The connection is then authenticated.
Failure:
{
"id": "2",
"type": "auth.phone",
"ok": false,
"error": {
"code": "invalid_code",
"message": "The code is wrong"
}
}
A wrong code returns invalid_code. The verification stays usable until its
code expires or five wrong codes have been tried; after that, and for an
unknown or already used handle, auth.phone returns verification_expired and
a new code has to be requested.
Sign In with a QR Code
A web client can skip the SMS: it shows a QR code, the user's phone scans it, the user confirms, and the web client receives a session of its own. Signing in with a code keeps working everywhere; the QR code only saves the SMS.
The waiting client asks for a code on an unauthenticated connection:
{"id":"qr-1","type":"auth.qr","data":{"client":{"device_name":"Chrome on Windows","platform":"Windows","name":"Mandarin Web","version":"1.0.0"}}}
{"id":"qr-1","type":"auth.qr","ok":true,"data":{"url":"https://mandarin.im/login#Q2vU9dX1sL8...","expires_in":60}}
| Field | Type | Description |
|---|---|---|
client |
object | Optional display metadata, as in auth.phone: the new session carries it, and the phone shows it before the user agrees |
url |
string | What the QR code shows, as it is: <link_base>/login#<token> on a server with a link base, otherwise https://<server>/login#<token> on the name clients reach the server by |
expires_in |
number | Seconds the code stays valid |
A code lives for expires_in and no longer than its connection. The client asks
for a fresh one before it runs out, and a new auth.qr replaces the
connection's previous code.
A link keeps the token in its fragment, which a browser that opens the link instead of the app never sends anywhere. The phone scans the code and, when it belongs to the server it is signed in to — its link base, or its name — asks who is waiting:
{"id":"qr-2","type":"qr.get","data":{"token":"Q2vU9dX1sL8..."}}
{"id":"qr-2","type":"qr.get","ok":true,"data":{"client":{"device_name":"Chrome on Windows","platform":"Windows","name":"Mandarin Web","version":"1.0.0"},"ip":"203.0.113.7","location":"Moscow, Russia","expires_in":42}}
| Field | Type | Description |
|---|---|---|
client |
object | What the waiting client says about itself; untrusted |
ip |
string | The waiting client's address |
location |
string | Where that address is, when the server can tell |
expires_in |
number | Seconds left |
The phone shows all of it and asks the user to confirm. Only then:
{"id":"qr-3","type":"qr.accept","data":{"token":"Q2vU9dX1sL8..."}}
{"id":"qr-3","type":"qr.accept","ok":true,"data":{"session":{"session_id":"s_4567...","client":{"device_name":"Chrome on Windows","platform":"Windows","name":"Mandarin Web","version":"1.0.0"},"created_at":"2026-09-24T12:00:00Z","last_active_at":"2026-09-24T12:00:00Z","expires_at":"2026-10-24T12:00:00Z","current":false,"online":true,"push":false}}}
The waiting client receives the body auth.phone returns, without created,
as an event, and its connection is authenticated from that moment:
{"type":"qr.accepted","data":{"user_id":12345,"access_token":"abc123...","refresh_token":"xyz789...","expires_in":3600,"session":{"session_id":"s_4567...","client":{"device_name":"Chrome on Windows","platform":"Windows","name":"Mandarin Web","version":"1.0.0"},"created_at":"2026-09-24T12:00:00Z","last_active_at":"2026-09-24T12:00:00Z","expires_at":"2026-10-24T12:00:00Z","current":true,"online":true,"push":false}}}
The new session is an ordinary one: it is refreshed, listed and revoked like any
other. A code is used once. An unknown, expired or used code, or one whose
connection has closed, returns verification_expired from qr.get and
qr.accept.
Any session made on a CLEMA client can let another device in, one a QR code
let in too: a code to sign in with reaches it anyway. A session made through a
transport gets not_authorized: it has no way to see
who is asking. The account hears of the new session from
Mandarin, with the device that let it in. Whoever gets the user to
scan their code gets a session in the user's account, which is why the phone
always shows who is asking, and why a user should scan only a code on a screen
they opened themselves.
Login with Token
Used to authenticate with a previously issued access token (e.g., after page refresh or reconnection).
Request:
{
"id": "2",
"type": "auth.token",
"data": {
"access_token": "abc123..."
}
}
Success:
{"id":"2","type":"auth.token","ok":true,"data":{"session":{"session_id":"s_0123...","client":{"device_name":"Alice's laptop","platform":"macOS","name":"Mandarin Web","version":"0.1.0"},"created_at":"2026-08-05T10:00:00Z","last_active_at":"2026-08-05T10:00:00Z","expires_at":"2026-09-04T10:00:00Z","current":true,"online":true,"push":false}}}
Failure:
{
"id": "2",
"type": "auth.token",
"ok": false,
"error": {
"code": "invalid_token",
"message": "Token is invalid or expired"
}
}
Refresh Token
Used to obtain a new access and refresh token pair using a refresh token.
Request:
{
"id": "3",
"type": "auth.refresh",
"data": {
"refresh_token": "xyz789..."
}
}
Success:
{
"id": "3",
"type": "auth.refresh",
"ok": true,
"data": {
"access_token": "newtoken123...",
"refresh_token": "newrefreshtoken456...",
"expires_in": 3600,
"session": {
"session_id": "s_0123...",
"client": {"device_name": "Alice's laptop", "platform": "macOS", "name": "Mandarin Web", "version": "0.1.0"},
"created_at": "2026-08-05T10:00:00Z",
"last_active_at": "2026-08-05T11:00:00Z",
"expires_at": "2026-09-04T11:00:00Z",
"current": false,
"online": false,
"push": false
}
}
}
A successful refresh rotates both tokens, so the client must replace its stored
access and refresh tokens with this response. The refresh token it was made
with stays good until the client uses what it got — authenticates with the new
access token (auth.token), or refreshes with the new refresh token — as the
answer may be lost on the way and leave the client holding the old one: a
refresh with it meanwhile rotates the tokens again, and the pair given before
stops working. Only the pair given last is ever good; a refresh token older
than the one last refreshed with is refused. Concurrent refreshes are
serialized. On an authenticated connection, the refresh token must belong to
that same persistent session.
Failure:
{
"id": "3",
"type": "auth.refresh",
"ok": false,
"error": {
"code": "invalid_token",
"message": "Refresh token is invalid or expired"
}
}
Logout
Revokes the current persistent session and returns every live connection using
it to the unauthenticated state. Connections stay open: protected commands are
rejected with not_authenticated until the client authenticates again.
If the user can no longer be reached afterwards — no active connection and no
push registration are left — contacts receive a presence.offline event.
Request:
{
"id": "4",
"type": "auth.logout"
}
Success:
{"id": "4", "type": "auth.logout", "ok": true}
Token Security
- Access tokens are valid for 1 hour
- Refresh tokens are valid for 30 days
- Tokens are cryptographically random (32 bytes, hex-encoded)
- Only SHA-256 token digests are stored; session IDs are identifiers, not credentials
- A refresh issues a new pair and voids the one before; the refresh token used stays good only until the client authenticates or refreshes with the new pair
- Logout invalidates all tokens for the session
- A live connection is deauthenticated before processing its first request at
or after the persistent session's
expires_at