CLEMA protocol

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