SMeetMini Apps

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#

WhenCallWhy
The page brings a login codeexchangeLaunchCodethe person, their chat with the bot and a Mini App session
Before every protected operationcheckSessionwhether the person may still use the app, read now
The person signs out of your apprevokeSessionends the session at once
The person should hear from youthe Bot API, with the revision from the checka 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 with APP_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#

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

JavaScriptdocs/mini-apps/examples/forms/lib/author-api.mjs
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_LIMITED and a 502, 503 or 504, after retry_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_USED with reason: 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_verifier fail with IDEMPOTENCY_CONFLICT. A key belongs to one code: another code is another exchange, whatever its key.
  • A wrong code_verifier revokes the code and fails with CODE_VERIFIER_MISMATCH.

What the exchange gives#

JSON
{
  "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_token stays on your server. Give the page a session of your own (Your app's own session).
  • expires_at is 15 minutes after the exchange at most, and never after the person's SMeet sign-in ends.
  • user.id is the person's id in the Bot API; display_name is there only when the app asks for the name (profile in scopes) and the person agreed. language_code is en or ru.
  • chat is the private chat of the person with your bot; chat.access_revision is the revision of that conversation now.
  • space.kind is public for SMeet's public space and organization for 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.

JavaScriptdocs/mini-apps/examples/forms/lib/login.mjs
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, ...} carries expires_at, environment, app, space, user and chat as they are now; {"active": false, "reason": "..."} carries only the reason.
  • Compare user.id and environment with 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.

reasonMeans
not_foundan unknown token, a wrong secret, or a session of another app or environment
maintenanceMini Apps or bots are paused on the platform
revokedyour server revoked the session with revokeSession
launch_closedthe window was closed: its button or menu, close(), a switch of account or space, or Mini Apps switched off
parent_session_revokedthe person signed out of SMeet, or that sign-in was ended
user_revokedthe person chose Revoke access in the window's menu
chat_stoppedthe person stopped or blocked the bot
membership_revokedthe person is no longer a member of the bot's organisation
account_disabledthe person's account is disabled or deleted
bot_unavailablethe bot is paused, suspended or deleted
app_unavailablethe Mini App is paused, blocked or deleted, or its author is blocked
space_disabledthe organisation switched Mini Apps or bots off
test_access_removedtest version: the person is no longer a tester or a reviewer
config_changedthe configuration changed in a way that closes open windows
operator_sessionthe SMeet session acts on behalf of another account; Mini Apps do not open in it
expiredthe 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:

Pythondocs/mini-apps/examples/python-minimal/server.py
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_revision is chat.access_revision of the checkSession (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 getMe of the bot must list access_revisions; read it at start, every 5 minutes, after 404 METHOD_NOT_FOUND and after a success without result.chat.access_revision. Without it the job waits and the worker raises an alarm.
  • Statuses. PENDING, then SENDING under a lease, then SENT, CANCELLED, PAUSED, FAILED or NEEDS_REVIEW. The status of the request is separate.
  • Decided by error.code, not by the HTTP status alone:
JavaScriptdocs/mini-apps/examples/desk/lib/delivery.mjs
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';
}
AnswerJob
200SENT; keep message_id and chat.access_revision
409 ACCESS_REVISION_CONFLICT, 403 BOT_STOPPED_OR_BLOCKED, 404 CHAT_NOT_FOUNDCANCELLED, 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_revisionPAUSED: 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 errorrepeat with the same key after retry_after
409 IDEMPOTENCY_CONFLICT, any other 400, 413FAILED: 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 LOCKED among those whose lease ran out, and let only the holder of the lease write the outcome.
  • Watch the age of the oldest PENDING and PAUSED job and the number of NEEDS_REVIEW.

The notice of the service desk takes its revision from the request's own check:

JavaScriptdocs/mini-apps/examples/desk/server.mjs
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.