SMeet Mini Apps
Your server
Your server holds the key of the Mini App and talks to SMeet on the person's behalf. It turns a login code into a session, checks that session before every operation, and sends what it owes the person through the bot.
What the server does#
| When | Call | Why |
|---|---|---|
| The page brings a login code | exchangeLaunchCode | the person, their chat with the bot and a Mini App session |
| Before every protected operation | checkSession | whether the person may still use the app, read now |
| The person signs out of your app | revokeSession | ends the session at once |
| The person should hear from you | the Bot API, with the revision from the check | a message in their chat, only while the conversation is the same |
The base address is https://messenger.scrile.com/mini-app-api/v1. Only your server calls it: the API sends no CORS headers, so a page cannot read its answers.
Keys#
The server authenticates with a key of one environment of one Mini App, smac1_<key id>_<secret>, issued in Keys of your server on the Mini App's screen.
- The key is shown once. Put it into the environment of the server at once, for example
MINI_APP_KEY; never into the page, a link, a repository or a log. - Each environment has keys of its own: the test server holds a test key, the production server a production key. A code of the test version exchanged with a production key fails with
ENVIRONMENT_MISMATCH, a code of another app withAPP_MISMATCH. - An environment has at most two active keys, so a key can be replaced without a pause (Rotating keys).
- A revoked or unknown key gets
401 INVALID_CREDENTIALS. Do not retry it.
Calling the API#
POST /mini-app-api/v1/exchangeLaunchCode HTTP/1.1
Host: messenger.scrile.com
Authorization: Bearer smac1_<key id>_<secret>
Content-Type: application/json
Idempotency-Key: login:Qy8nT2xbV0pJcWFkM2Rf
{"code": "smlc_...", "code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"}Success is {"ok": true, "result": {...}}. A failure is {"ok": false, "error": {"code": "...", "message": "...", "retry_after": 1}, "request_id": "..."} with the HTTP status of the code (Error codes). A body is at most 64 KiB. A 502, 503 or 504 without this JSON comes from the proxy in front of SMeet: treat it as TEMPORARILY_UNAVAILABLE.
Do not let your HTTP client follow redirects or retry by itself: your code decides what to repeat and with which key.
Exchanging the code#
Exchange each code once, with an Idempotency-Key that belongs to this one sign-in, for example login:<attempt id>. When the answer is lost or SMeet asks you to come back, repeat the same request with the same key and the same body:
async exchange({ code, codeVerifier, idempotencyKey, deadlineMs = 40_000 }) {
const started = Date.now();
for (let attempt = 1; ; attempt += 1) {
try {
return await this.call('exchangeLaunchCode', { code, code_verifier: codeVerifier }, { idempotencyKey });
} catch (error) {
if (!(error instanceof AuthorApiError) || !RETRYABLE.has(error.code)) throw error;
const wait = Math.min(5, error.retryAfter ?? attempt) * 1000;
if (Date.now() - started + wait > deadlineMs) throw error;
await delay(wait);
}
}
}- Repeat on a network error,
TEMPORARILY_UNAVAILABLE,RATE_LIMITEDand a 502, 503 or 504, afterretry_after. - A repeat within 60 seconds of the exchange, with the same key of your server, returns the same session; SMeet checks again that the person may use the app, so a repeat after a revocation fails. Later the answer is
CODE_ALREADY_USED, and when SMeet cannot reproduce the session any more (its signing key was replaced)CODE_ALREADY_USEDwithreason: replay_unavailable. Either way the page signs in again: SMeet never gives a new secret for an old exchange. - The same key and the same code with another
code_verifierfail withIDEMPOTENCY_CONFLICT. A key belongs to one code: another code is another exchange, whatever its key. - A wrong
code_verifierrevokes the code and fails withCODE_VERIFIER_MISMATCH.
What the exchange gives#
{
"ok": true,
"result": {
"session_token": "smas1_<session id>_<secret>",
"expires_at": "2026-10-10T12:15:00Z",
"environment": "production",
"app": {"id": "1000", "bot_id": "70051", "title": "Request form"},
"space": {"id": "1", "kind": "public"},
"user": {"id": "103", "display_name": "Dmitry", "language_code": "en"},
"chat": {"id": "5501", "type": "private", "access_revision": "1088"},
"scopes": ["profile"],
"start_param": "vpn-access"
}
}session_tokenstays on your server. Give the page a session of your own (Your app's own session).expires_atis 15 minutes after the exchange at most, and never after the person's SMeet sign-in ends.user.idis the person's id in the Bot API;display_nameis there only when the app asks for the name (profileinscopes) and the person agreed.language_codeisenorru.chatis the private chat of the person with your bot;chat.access_revisionis the revision of that conversation now.space.kindispublicfor SMeet's public space andorganizationfor an organisation.
Checking the session#
Call checkSession before every operation that acts for the person: creating or changing a request, uploading or downloading a file, reading their data. Without a cache: a cached answer says what was true then, not now. A cached answer may only show a name.
export async function checkedSession({ tokenOfRequest, sessions, authorApi }) {
const session = sessions.get(tokenOfRequest);
if (!session) throw new HttpError(401, 'SESSION_EXPIRED', 'Sign in again');
let check;
try {
check = await authorApi.checkSession(session.smeetToken);
} catch (error) {
throw pageError(error);
}
if (!check.active) {
sessions.drop(tokenOfRequest);
if (check.reason === 'maintenance') throw new HttpError(503, 'MAINTENANCE', 'SMeet Mini Apps are paused', { retry_after: 30 });
throw new HttpError(401, 'SESSION_INACTIVE', 'The SMeet session ended', { reason: check.reason });
}
if (check.user?.id !== session.user?.id || check.environment !== session.environment) {
sessions.drop(tokenOfRequest);
throw new HttpError(401, 'SESSION_INACTIVE', 'The SMeet session changed', { reason: 'mismatch' });
}
return { session, check };
}- Every state is an answer 200.
{"active": true, ...}carriesexpires_at,environment,app,space,userandchatas they are now;{"active": false, "reason": "..."}carries only the reason. - Compare
user.idandenvironmentwith the session the page claims; a mismatch is not this person's session. - An unknown token, a wrong secret and a session of another app or environment all answer
reason: not_found, so the answer tells nothing about other apps. - During a pause of the platform the answer is
active: false, reason: maintenance: keep the person's draft and say that the app is unavailable for a while. - An inactive session does not come back, except with
reason: maintenance: a pause does not end the session, so keep your session and the draft and check again later; after the pause the answer is the session's state then. Every other reason is final: drop your own session, and let the page sign in again if the window is still open.
Reasons#
Why a session is not active. When several apply, SMeet names the first in this order: not_found, maintenance, the reason the session or its window ended, expired, then what the live checks find now. An exchange refused with ACCESS_DENIED carries the same reasons, except not_found, maintenance, expired and revoked; a window closed because its 12 hours were over gives launch_closed there.
reason | Means |
|---|---|
not_found | an unknown token, a wrong secret, or a session of another app or environment |
maintenance | Mini Apps or bots are paused on the platform |
revoked | your server revoked the session with revokeSession |
launch_closed | the window was closed: its button or menu, close(), a switch of account or space, or Mini Apps switched off |
parent_session_revoked | the person signed out of SMeet, or that sign-in was ended |
user_revoked | the person chose Revoke access in the window's menu |
chat_stopped | the person stopped or blocked the bot |
membership_revoked | the person is no longer a member of the bot's organisation |
account_disabled | the person's account is disabled or deleted |
bot_unavailable | the bot is paused, suspended or deleted |
app_unavailable | the Mini App is paused, blocked or deleted, or its author is blocked |
space_disabled | the organisation switched Mini Apps or bots off |
test_access_removed | test version: the person is no longer a tester or a reviewer |
config_changed | the configuration changed in a way that closes open windows |
operator_session | the SMeet session acts on behalf of another account; Mini Apps do not open in it |
expired | the session's 15 minutes, or the window's 12 hours, are over; the page renews the session with a new sign-in |
Accept reasons you do not know: new ones may come.
Ending a session#
When the person signs out of your app, call revokeSession with the SMeet session behind your session. The answer is {"revoked": true} for any token, also an unknown or already revoked one, and it works during a pause of the platform. A later checkSession answers reason: revoked.
Answering in the chat#
Your server answers the person through the bot, in chat.id of the check, with chat.access_revision of that same check as expected_access_revision. If the person stopped the bot or the conversation changed since, SMeet refuses the message instead of delivering it into a conversation that is not the same any more:
def reply(self, token: Optional[str], body: Dict[str, Any]) -> Dict[str, Any]:
request_id = body.get("request_id")
if not isinstance(request_id, str) or not REQUEST_RE.match(request_id):
raise PageError(400, "INVALID_REQUEST")
_, check = self.checked(token)
chat = check.get("chat") or {}
if not self.settings.bot_token or not chat.get("id"):
raise PageError(500, "SERVER_MISCONFIGURED")
message = {"chat_id": str(chat["id"]),
"text": "Ответ бота из минимального примера на Python.\nThe bot answers from the minimal Python example."}
if chat.get("access_revision"):
# The revision of this very check; never a newer one (19.1).
message["expected_access_revision"] = str(chat["access_revision"])
try:
sent = self.call(f"{self.settings.smeet_origin}/bot-api/v1/sendMessage", self.settings.bot_token, message,
f"reply:{request_id}")
except SMeetError as error:
raise PageError(409 if error.code in ("ACCESS_REVISION_CONFLICT", "BOT_STOPPED_OR_BLOCKED") else 502,
error.code)
return {"message_id": (sent or {}).get("message_id")}409 ACCESS_REVISION_CONFLICT and 403 BOT_STOPPED_OR_BLOCKED are final for this message (Conditional requests). Send the condition only when the bot's last getMe lists access_revisions in capabilities (Capabilities).
The outbox of notifications#
A notification that must not be lost, such as "your request is received", goes through an outbox: a table your server writes in the same transaction as the request itself, and a worker that sends from it. The service desk example is built this way (Examples). The rules:
- The revision comes from the operation.
expected_access_revisionischat.access_revisionof thecheckSession(or the exchange) of the very operation that created the request, and it is written into the job in the same transaction as the request. Nobody stamps a job with a newer revision: not the worker, not a repeat, not an operator. A notice built this way reaches the person only while the conversation is the one in which they made the request. - An operator's change sends nothing to the chat. An operator has no session of the person, so no checked revision; the person sees the new status in the app.
- One key per notification.
Idempotency-Key: ntf:<notification id>; the body is stored once as canonical JSON, the revision included, and sent as it is. Switch off the retries of your HTTP library: only the worker's schedule repeats. - Only with revisions on. Before sending, the last
getMeof the bot must listaccess_revisions; read it at start, every 5 minutes, after404 METHOD_NOT_FOUNDand after a success withoutresult.chat.access_revision. Without it the job waits and the worker raises an alarm. - Statuses.
PENDING, thenSENDINGunder a lease, thenSENT,CANCELLED,PAUSED,FAILEDorNEEDS_REVIEW. The status of the request is separate. - Decided by
error.code, not by the HTTP status alone:
export function classify(error) {
const code = error?.code;
if (CANCELLED_CODES.has(code)) return 'CANCELLED';
if (PAUSED_CODES.has(code)) return 'PAUSED';
// Conditions switched off for this bot (a 1.2.0 server refuses the parameter instead of ignoring it).
if (code === 'INVALID_REQUEST' && error.field === 'expected_access_revision') return 'PAUSED';
// An older server without this method: read getMe again before anything else is sent.
if (code === 'METHOD_NOT_FOUND') return 'PAUSED';
if (RETRY_CODES.has(code)) return 'RETRY';
if (code === 'UNEXPECTED_RESPONSE' && error.status >= 500) return 'RETRY';
// IDEMPOTENCY_CONFLICT, any other 400, 413, FILE_REJECTED: a visible failure, never a new key.
return 'FAILED';
}| Answer | Job |
|---|---|
| 200 | SENT; keep message_id and chat.access_revision |
409 ACCESS_REVISION_CONFLICT, 403 BOT_STOPPED_OR_BLOCKED, 404 CHAT_NOT_FOUND | CANCELLED, never resent with another revision |
401 INVALID_TOKEN, 403 BOT_SUSPENDED, 403 SPACE_BOTS_DISABLED, 404 BOT_NOT_FOUND, 400 INVALID_REQUEST with field: expected_access_revision | PAUSED: the same key later, within the deadline; these answers come before SMeet looks at the key and do not prove that nothing went out |
429, 503 TEMPORARILY_UNAVAILABLE, 503 BOTS_MAINTENANCE, a network error | repeat with the same key after retry_after |
409 IDEMPOTENCY_CONFLICT, any other 400, 413 | FAILED: a visible failure |
- A deadline. Automatic repeats stop 6 days after the first attempt (SMeet keeps a key for 7); a restart does not extend it. After that the job is
NEEDS_REVIEW, and it never gets a new key. - A lease longer than the HTTP timeout plus 2 seconds; take jobs with
FOR UPDATE SKIP LOCKEDamong those whose lease ran out, and let only the holder of the lease write the outcome. - Watch the age of the oldest
PENDINGandPAUSEDjob and the number ofNEEDS_REVIEW.
The notice of the service desk takes its revision from the request's own check:
export function ticketNotice(ticket, { chatId, accessRevision, language, attachments = true }) {
const subject = ticket.subject.length > 200 ? `${ticket.subject.slice(0, 199)}…` : ticket.subject;
const text = language === 'ru'
? `Заявка №${ticket.id} принята: ${subject}\nСтатус виден в приложении «Заявки».`
: `Ticket #${ticket.id} received: ${subject}\nIts status is shown in the Tickets app.`;
if (ticket.attachment_name && attachments) {
return { method: 'sendDocument', body: canonicalJson({ chat_id: chatId, caption: text, expected_access_revision: accessRevision }) };
}
return { method: 'sendMessage', body: canonicalJson({ chat_id: chatId, text, expected_access_revision: accessRevision }) };
}Errors#
Every code, its HTTP status and what it means are in the API reference. In short: 401 never repeats; 400 and 403 repeat only after something changes; 409 IDEMPOTENCY_CONFLICT is a bug of the caller; 429 and 503 repeat with the same key after retry_after.
Logs#
Never log a login code, a verifier, a session token, a key, an attempt secret or a body that carries one. Log the method, the code of the answer and request_id, which support can look up. The examples log no more than that.