X2Chat Mini Apps

Подключите своё веб-приложение к мессенджеру X2Chat. Мини-апп открывается во встроенном WebView, получает бесшовный вход по аккаунту пользователя, может запрашивать данные через защищённый Host API и отправлять карточки-ссылки в чат.

Проще всего — раздел разработчика: зайди по QR из своего X2Chat, заполни форму и опубликуй. Там же — список приложений со статусом, метрики и удаление. Без токенов и команд: /miniapps/dev/

Удаление — только по QR-подтверждению: удалить приложение можно лишь отсканировав QR в X2Chat со своего телефона-владельца — чужой удалить не сможет.

1. Что такое мини-апп

Мини-апп — это размещённый по HTTPS веб-фронтенд (HTML/JS/CSS, любой стек). X2Chat хранит о нём запись в реестре (slug, name, icon_url, launch_url, allowed_origins, available_scopes) и открывает launch_url во WebView. Приложение может быть полностью самостоятельным (свой бэкенд, своя БД, свой бренд) — X2Chat выступает «лаунчером» и поставщиком идентичности пользователя.

Модель доверия. В JavaScript мини-аппа нельзя класть основной Bearer-JWT пользователя или ключи E2E. Для обращения к X2Chat используется короткая launch-сессия (JWT типа mini_app_launch, ~5 минут) и заголовок X-X2Chat-Mini-App-Session.

2. Быстрый старт — самостоятельная регистрация

Подключение полностью self-service: размещаете манифест на своём домене и шлёте одну заявку. Подключать вас вручную никто не должен.

  1. Разместите фронтенд по HTTPS (свой домен) — либо попросите проксировать под путём https://ru.x2chat.com/apps/<slug>/ (см. §8).
  2. Положите манифест и иконку рядом с приложением (см. ниже).
  3. Отправьте одну заявку POST /api/v1/mini-apps/register из своего аккаунта X2Chat.
  4. Готово: без запрошенных прав — приложение в каталоге сразу; с доступом к данным — после одобрения (1 клик оператора).

2.1. Манифест

Файл x2chat-manifest.json в корне приложения (на том же домене, что и launch_url — это и есть подтверждение, что приложение ваше):

{
  "slug": "otc",
  "name": "OTC Desk",
  "description": "OTC-сделки и котировки",
  "launch_url": "https://ru.x2chat.com/apps/otc/",
  "scopes": ["profile:read"]
}

Иконку положите рядом — icon.png (см. §9), отдельно указывать не нужно.

2.2. Регистрация — одна команда

Из аккаунта X2Chat (нужен ваш токен авторизации; запрос идёт от вас — вы становитесь владельцем приложения):

curl -X POST https://ru.x2chat.com/api/v1/mini-apps/register \
  -H "Authorization: Bearer <ВАШ_ТОКЕН_X2CHAT>" \
  -H "Content-Type: application/json" \
  -d '{"manifest_url":"https://ru.x2chat.com/apps/otc/x2chat-manifest.json"}'

Что делает сервер сам: тянет манифест → сверяет, что домен ваш → проверяет, что launch_url отвечает (200), есть icon.png, запрошенные права поддерживаются → принимает решение:

Запись создаётся на обоих дата-центрах автоматически. Обновить приложение (имя/иконку/права) — поменяйте манифест и повторите ту же команду.

Не хотите возиться с токеном? Сгенерируйте QR-код и отсканируйте его в приложении X2Chat — приложение подключится без токена: /miniapps/qr/ (требует свежей версии приложения X2Chat).

3. JavaScript-мост (WebView ↔ X2Chat)

После загрузки страницы X2Chat инжектит window.X2Chat.launch и шлёт событие x2chat:ready:

window.addEventListener('x2chat:ready', (e) => {
  const { sessionToken, context } = window.X2Chat.launch;
  // context = { app_id, slug, user_id, slot_id, chat_id, scopes, theme, locale }
  // slot_id — crypto-идентификатор юзера (null для обычных аккаунтов)
  applyTheme(context.theme);     // напр. 'blue'
  applyLocale(context.locale);   // напр. 'ru'
});

Если событие уже прошло к моменту подписки — проверьте наличие window.X2Chat?.launch напрямую.

Методы моста

ВызовНазначение
window.X2Chat.postMessage({ method: 'close' })Закрыть мини-апп.
window.X2Chat.postMessage({ method: 'sendMessageDraft', payload: { text } })Вставить текст черновиком в текущий чат и закрыть мини-апп (пользователь подтверждает и отправляет сам).
window.X2Chat.postMessage({ method: 'getContext' })Повторно инжектит window.X2Chat.launch.
await window.x2.requestCapability(scope, payload)Запрос к Host API (Promise). Внутри идёт через мост с requestId.
window.X2Chat.postMessage({ method: '_refreshHandled' })ACK на pull-to-refresh — мини-апп сам обновил текущую страницу (см. §3.1). Гасит спиннер хоста и отменяет полную перезагрузку.
await window.x2.requestCapability('message:notify', { to:{slot_id}, text })Отправить получателю по slot_id E2E-сообщение (пуш придёт штатно). Гейт по scope message:send (см. §3.5).
await window.x2.requestCapability('session:refresh', {})Форсировать переиздание launch-сессии (token-broker) — обновляет sessionToken в контейнере (см. §3.6).

Картинки/вложения мост не принимает — только текст. Для «картинки-карточки» используйте превью ссылок (§6).

3.1. Pull-to-refresh — постраничное обновление

Когда пользователь тянет контейнер мини-аппа вниз (на вершине прокрутки), X2Chat не перезагружает весь WebView — это выкидывало бы из SPA и сбрасывало навигацию. Вместо этого хост шлёт в страницу событие x2chat:refresh, а мини-апп сам обновляет свою ТЕКУЩУЮ страницу и подтверждает это.

window.addEventListener('x2chat:refresh', async () => {
  try {
    await refreshCurrentPage();      // ваш re-fetch/re-render активного экрана
  } finally {
    // ОБЯЗАТЕЛЬНО: иначе через 2 с хост перезагрузит весь WebView (fallback)
    window.X2Chat?.postMessage?.({ method: '_refreshHandled' });
  }
});

Совместимость. Если ACK _refreshHandled не пришёл за 2 секунды, хост делает полный reload() — старые мини-аппы, не знающие про x2chat:refresh, продолжают работать (просто без постраничности). Доступно с контейнера iOS 2.27.4+1442.

3.2. «Назад» — навигация по иерархии

Кнопка/жест «назад» по умолчанию закрывает мини-апп. Чтобы «назад» сначала ходил по внутренней иерархии мини-аппа (деталь → список → корень) и закрывал его только с корневого экрана, мини-апп сообщает хосту своё навигационное состояние при каждой смене экрана:

function syncNav() { window.x2.setBackState(router.canGoBack(), hasUnsavedChanges()); }
// вызывать syncNav() на каждой смене экрана и на x2chat:ready

window.addEventListener('x2chat:back', () => { router.back(); syncNav(); });

window.addEventListener('x2chat:exitRequest', () => {
  if (confirm('Выйти из приложения?')) window.X2Chat.postMessage({ method: 'close' });
});
СигналСмысл
window.x2.setBackState(canGoBack, confirmExit)Мини-апп → хост: можно ли идти назад внутри (canGoBack) и нужно ли подтверждать выход (confirmExit).
событие x2chat:backХост → мини-апп: уйти на предыдущий экран по своей иерархии (приходит при canGoBack=true).
событие x2chat:exitRequestХост → мини-апп: пользователь хочет выйти, а вы выставили confirmExit=true — покажите подтверждение и на согласие вызовите postMessage({method:'close'}).

iOS: при наличии «назад»-цели нативный edge-swipe подавляется — внутренний «назад» через шеврон в шапке или свой UI; на корне свайп закрывает мини-апп штатно. На Android системный жест/кнопка делегируются всегда. Дефолт (мини-апп не шлёт состояние) — «назад» сразу закрывает. Доступно с iOS 2.27.4+1442.

3.3. Камера и микрофон (getUserMedia)

Камера и микрофон внутри мини-аппа работают через стандартный navigator.mediaDevices.getUserMedia — отдельный мост не нужен. X2Chat выдаёт WebView веб-разрешение на медиа автоматически. Типичный сценарий — сканер QR внутри приложения.

// задняя камера для сканера
const stream = await navigator.mediaDevices.getUserMedia({
  video: { facingMode: { ideal: 'environment' } }, audio: false
});
videoEl.srcObject = stream; await videoEl.play();
// декодировать QR любой библиотекой: @zxing/browser | html5-qrcode | jsQR
stream.getTracks().forEach(t => t.stop());   // по завершении — ОБЯЗАТЕЛЬНО погасить

Требования и совместимость. Только HTTPS (на http getUserMedia не работает); камеру дёргать по действию пользователя (клик), не на загрузке. На Android доступ к камере во WebView выдаётся с контейнера после фикса 2026-06-19 — на старых сборках сделайте мягкий fallback (ручной ввод / вставка из буфера) на ошибки NotAllowedError / NotFoundError / NotReadableError и проверяйте navigator.mediaDevices?.getUserMedia перед вызовом. На iOS работает по системному запросу доступа (первый запуск спросит).

3.4. Поделиться и выбор контакта

Для крипто-кошельков и приложений, которым нужна идентичность собеседника или системное «поделиться» — через window.x2.requestCapability(scope, payload):

scopepayloadЧто делаетВозврат
share:external{ text, url, title }Системный share-лист (Android Intent / iOS UIActivityViewController). Без отдельного права.{ success: true }
share:forward{ text, url }Пикер чатов X2Chat → отправляет текст выбранному контакту (E2E через ChatService). Без отдельного права.{ success: true, sentTo: [chat_id] } / { success: false, error }
contacts:pick{}Пикер контактов → выбранный собеседник (1:1). Нужен scope contacts:pick. Для группы slot_id=null, group=true.{ user_id, slot_id, display_name, group } / { cancelled }
// поделиться системно
await window.x2.requestCapability('share:external', { text: 'TRC20 адрес', url: 'https://…' });

// выбрать получателя (нужен scope contacts:pick)
const c = await window.x2.requestCapability('contacts:pick', {});
if (!c.cancelled && c.slot_id) resolveAndSend(c.slot_id, c.display_name);

Идентичность: slot_id текущего юзера приходит в context.slot_id (см. §3); contacts:pick отдаёт slot_id собеседника (раскрытие → требует scope, уходит на одобрение оператора). Совместимость: доступно с Android-сборки после 2026-06-19; на старых сборках requestCapability вернёт ошибку — предусмотрите fallback (ручной ввод / буфер).

3.5. Уведомить получателя по slot_id (message:notify)

Хост резолвит/создаёт 1:1-чат с получателем по slot_id и отправляет ему E2E-сообщение text. Пуш приходит штатно — как на любое новое сообщение, отдельный notify слать не нужно. Тело сообщения = текст пуша. Главный сценарий — кошелёк, уведомляющий получателя перевода.

const r = await window.x2.requestCapability('message:notify', {
  to:   { slot_id: 'YQS4C0' },   // получатель по slot_id (из contacts:pick / формы)
  text: '💸 Перевод 2 USDT'       // тело сообщения = текст пуша
});
// → { success: true, chat_id, message_id }
// → { success: false, error: 'recipient_not_found' | 'no_permission' | 'empty_text' }

Гейт по уже выданному scope message:send — отдельный scope не заводите (иначе нужна переустановка мини-аппа, которой у пользователей нет). Сейчас поддержан только to:{slot_id}; to:{user_id}/to:{username}, deeplink, dedup_key пока не поддержаны. Отличие от серверного host/notify (§7a): тот шлёт только push офлайн-получателю по app-key, а message:notify — bridge из WebView и основной путь для отправки по slot_id (создаёт реальное сообщение). Доступно с Android 2.27.2+1455.

3.6. Управление сессией (token-broker)

Launch-сессия мини-аппа короткоживущая (30 мин). Контейнер X2Chat работает как token-broker: при истечении токена (host-API вернул 401) или по запросу контейнер сам переиздаёт сессию через настоящую авторизацию пользователя, ре-инжектит свежий window.X2Chat.launch.sessionToken и шлёт событие. Перелогиниваться или перезапускать приложение не нужно — вызовы переживают истечение прозрачно (контейнер делает авто-retry один раз).

// форсировать переиздание проактивно (не дожидаясь 401)
const r = await window.x2.requestCapability('session:refresh', {});
// → { success: true, sessionToken }

// хост шлёт событие после успешного переиздания;
// к этому моменту window.X2Chat.launch.sessionToken уже свежий
window.addEventListener('x2chat:session', (e) => {
  if (e.detail.state === 'active') { /* токен обновлён */ }
});

session:refresh обрабатывается в контейнере (не уходит в backend). Backend поддерживает sliding — каждый вызов продлевает ещё живую сессию. Рекомендация: на ответ host-API с 401/expired вызовите session:refresh один раз и повторите запрос. Доступно с Android 2.27.2+1454.

4. Права (scopes)

При установке пользователь соглашается с подмножеством available_scopes; в launch-сессию попадают только выданные granted_scopes.

ScopeНазначение
profile:readПрофиль пользователя (id, email, username, display_name, avatar, language, theme).
chat:contextКонтекст чата привязки (id, title, type).
message:sendЧерновик сообщения в чат.
contacts:pickВыбор контакта → slot_id + display_name собеседника (см. §3.4).
voice:personas:readКаталог голосовых персонажей (официальный сценарий звонков).
voice:persona:setВыбор голосового персонажа.

Re-consent. Если вы расширили available_scopes уже установленного приложения — пользователь должен переустановить его, иначе requestCapability(новый_scope) вернёт 403.

5. Бесшовный вход (SSO)

Самостоятельное приложение может логинить пользователя без отдельного входа, доверившись X2Chat-сессии:

  1. Фронт берёт window.X2Chat.launch.sessionToken и отправляет его на свой бэкенд вместе с window.location.origin (JWT на клиенте не валидируем).
  2. Бэкенд по origin выбирает дата-центр и делает server-to-server запрос:
GET  <X2CHAT_HOST_API>/api/v1/mini-apps/host/me
Header: X-X2Chat-Mini-App-Session: <sessionToken>

→ { success, profile: { id, email, username, display_name, avatar_url, language, theme } }
  401 — токен невалиден / истёк / чужой ДЦ
  403 — не выдан scope profile:read

По стабильному profile.id заведите/найдите своего пользователя и выдайте свою сессию. Токен X2Chat живёт ~5 минут и больше не нужен.

Dual-DC. Токен валиден только на том ДЦ, что его выпустил. Выбирайте Host API по origin: ru.x2chat.com → RU, ix.x2chat.com → IX. Внутренние адреса Host API выдаёт платформа.

6. «Поделиться» в чат (карточка + ссылка)

Отдельного метода «вложить картинку» нет — используйте превью ссылок (OpenGraph):

  1. Отдавайте публичную (по неугадываемому токену) страницу с OG-тегами: og:title, og:description, og:image (абсолютный URL), og:site_name.
  2. Положите ссылку в чат черновиком:
window.X2Chat.postMessage({
  method: 'sendMessageDraft',
  payload: { text: 'Подпись\nhttps://ru.x2chat.com/apps/<slug>/s/<token>' }
});

Клиент X2Chat сам анфёрлит ссылку и рисует карточку с картинкой. Превью фетчит устройство каждого получателя (User-Agent X2Chat/LinkPreview, таймаут 5 с, кэш 6 ч) — поэтому страница и картинка должны быть публично доступны. Рекомендуемая картинка: PNG/JPEG, ~1.91:1.

7. Host API

Базовый путь: /api/v1/mini-apps/host. Заголовок сессии: X-X2Chat-Mini-App-Session: <JWT>.

МетодПутьScope
GET/meprofile:read
GET/chatchat:context
POST/message-draftmessage:send
POST/capabilities/requestтело { scope, payload }; scope должен быть в сессии

7a. Server-to-server notify (push офлайн-получателю)

Серверный бэкенд приложения может отправить получателю системный push (работает офлайн). Аутентификация — app-key (выдаёт платформа), не launch-сессия.

POST <X2CHAT_HOST_API>/api/v1/mini-apps/host/notify
Headers: X-X2Chat-App-Key: <секрет>
Body: { "to": {"user_id"|"slot_id"}, "push": {"title","body"},
        "deeplink"?: "https://… (только https/x2chat)", "dedup_key"?: "…" }
→ { "success": true, "pushed": true, "deduped": false }

host/notify vs message:notify. Этот серверный host/notify шлёт только push офлайн-получателю (app-key, без сообщения в чате). Чтобы из WebView отправить реальное E2E-сообщение получателю по slot_id (пуш придёт штатно) — используйте bridge-capability message:notify (см. §3.5); для slot_id это основной путь.

8. Хостинг под под-путём (без своего домена)

Приложение можно проксировать под путём на доменах X2Chat: https://ru.x2chat.com/apps/<slug>/ и https://ix.x2chat.com/apps/<slug>/.

9. Иконка — автоподтягивание из приложения

Каталог мини-аппов в клиенте — сетка иконок. Иконку поставляет само приложение по соглашению (как favicon) — никуда отдельно загружать не нужно.

Положите файл icon.png рядом с вашим launch_url (в той же папке):

launch_url:  https://ru.x2chat.com/apps/otc/
иконка:      https://ru.x2chat.com/apps/otc/icon.png

launch_url:  https://cdn.example.com/app/index.html
иконка:      https://cdn.example.com/app/icon.png

X2Chat подтянет её автоматически (icon_url вычисляется из launch_url). Клиент грузит иконку вживую — заменили файл, иконка обновилась без переустановки. Требования: квадрат, ≥ 256×256, PNG. Нет файла (404) → показывается градиент с первой буквой названия. name держите коротким (2 строки под иконкой).

10. Частые ошибки

СимптомПричина
Приложения нет в каталогеНет записи на этом ДЦ, либо statusenabled / review_statusapproved
403 на Host APIScope не выдан при установке (нужен re-consent)
401 на Host APILaunch-сессия истекла/отозвана, либо запрос ушёл на чужой ДЦ
WebView блокирует переходURL вне allowed_origins
Ломаются ассеты/API под путёмФронт ходит абсолютными путями вместо относительных (§8)