X2Chat Mini Apps

Connect your web app to the X2Chat messenger. A mini app opens in a built-in WebView, signs the user in seamlessly with their X2Chat account, can request data through the secure Host API and can post link cards to a chat.

The easiest way is the developer console: sign in with a QR code from your X2Chat app, fill in the form and publish. The console also lists your apps with their status and metrics and lets you delete them. No tokens, no commands: /miniapps/dev/

Deletion requires QR confirmation: an app can only be deleted by scanning a QR code in X2Chat on the owner's phone, so nobody else can delete it.

1. What is a mini app

A mini app is a web front end served over HTTPS (HTML/JS/CSS, any stack). X2Chat keeps a registry entry for it (slug, name, icon_url, launch_url, allowed_origins, available_scopes) and opens launch_url in a WebView. The app can be fully independent (its own back end, database and brand); X2Chat acts as the launcher and as the user's identity provider.

Trust model. Mini app JavaScript must never hold the user's main Bearer JWT or E2E keys. Calls to X2Chat use a short-lived launch session (a JWT of type mini_app_launch, about 5 minutes) and the X-X2Chat-Mini-App-Session header.

2. Quick start: self-service registration

Onboarding is fully self-service: you host a manifest on your domain and send a single request. Nobody has to connect your app by hand.

  1. Host your front end over HTTPS (on your own domain), or ask for it to be proxied under https://ru.x2chat.com/apps/<slug>/ (see §8).
  2. Put the manifest and the icon next to your app (see below).
  3. Send one POST /api/v1/mini-apps/register request from your X2Chat account.
  4. Done: an app that requests no scopes appears in the catalog immediately; an app that needs user data goes live after approval (one click by an operator).

2.1. Manifest

The x2chat-manifest.json file at the root of your app (on the same domain as launch_url; this is what proves the app is yours):

{
  "slug": "otc",
  "name": "OTC Desk",
  "description": "OTC deals and quotes",
  "name_i18n": { "zh": "OTC 交易台" },
  "description_i18n": { "es": "Operaciones OTC y cotizaciones", "de": "OTC-Geschäfte und Kurse" },
  "launch_url": "https://ru.x2chat.com/apps/otc/",
  "scopes": ["profile:read"]
}

Put the icon next to it as icon.png (see §9); you don't need to declare it anywhere.

2.2. Registration: a single command

From your X2Chat account (you need your auth token; the request is made on your behalf, so you become the app's owner):

curl -X POST https://ru.x2chat.com/api/v1/mini-apps/register \
  -H "Authorization: Bearer <YOUR_X2CHAT_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{"manifest_url":"https://ru.x2chat.com/apps/otc/x2chat-manifest.json"}'

What the server does on its own: fetches the manifest → checks that the domain is yours → checks that launch_url responds (200), that icon.png exists and that the requested scopes are supported → decides:

The entry is created in both data centers automatically. To update the app (name, icon, scopes), change the manifest and run the same command again.

Don't want to deal with a token? Generate a QR code and scan it in the X2Chat app; the app is connected without a token: /miniapps/qr/ (requires a recent version of the X2Chat app).

3. JavaScript bridge (WebView ↔ X2Chat)

Once the page has loaded, X2Chat injects window.X2Chat.launch and fires the x2chat:ready event:

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 is the user's crypto identifier (null for regular accounts)
  applyTheme(context.theme);     // e.g. 'blue'
  applyLocale(context.locale);   // X2Chat interface language: 'en', 'ru', 'ar'…
});

If the event has already fired by the time you subscribe, check window.X2Chat?.launch directly.

X2Chat theme colors

The host passes the exact colors of the user's current theme, so your mini app can look like part of X2Chat. The simplest way is the CSS variables on <html> (with a fallback for older clients):

body  { background: var(--x2-bg, #111318); color: var(--x2-text, #e9edf2); }
.card { background: var(--x2-card, #1d2027); border: 1px solid var(--x2-border, #2b3038); }
.btn  { background: var(--x2-accent, #3aa8ff); color: var(--x2-accent-text, #fff); }

Variables: --x2-bg, --x2-surface, --x2-card, --x2-text, --x2-hint, --x2-accent, --x2-accent-text, --x2-border, --x2-danger, --x2-scheme (dark/light). The same values are in window.X2Chat.themeParams, and the x2chat:theme event fires after the bridge is injected and every time the user changes the theme.

On desktop, the mini app panel takes the color at the top edge of your page and the strip below it takes the color at the bottom edge, so the X2Chat header and your page blend seamlessly and the header text gets a contrasting color. The scrollbar is thin and semi-transparent (you can override it with your own styles).

X2Chat interface language

A mini app should speak the language the user sees X2Chat in: ru, en, es, de, fr, zh, ar (ar is right-to-left; any other language → en). The host reports the language like this:

<h1 data-i18n="title"></h1>
<script src="https://ix.x2chat.com/mini-apps/x2-i18n.js"></script>
<script>
  X2I18n.init({ en: { title: 'Games' }, es: { title: 'Juegos' }, ar: { title: 'الألعاب' } },
              (lang) => render());   // called whenever the language changes
</script>

The name and description shown in the catalog come from the manifest fields name_i18n and description_i18n (maps like { "en": "…", "es": "…" }): the catalog shows them in the user's language.

Bridge methods

CallPurpose
window.X2Chat.postMessage({ method: 'close' })Close the mini app.
window.X2Chat.postMessage({ method: 'sendMessageDraft', payload: { text } })Insert text into the current chat as a draft and close the mini app (the user reviews and sends it).
window.X2Chat.postMessage({ method: 'getContext' })Injects window.X2Chat.launch again.
await window.x2.requestCapability(scope, payload)Host API request (returns a Promise). Goes through the bridge with a requestId under the hood.
window.X2Chat.postMessage({ method: '_refreshHandled' })Pull-to-refresh ACK: the mini app has refreshed the current page itself (see §3.1). Stops the host spinner and cancels the full reload.
await window.x2.requestCapability('message:notify', { to:{slot_id}, text })Send an E2E message to a recipient by slot_id (the push notification arrives as usual). Gated by the message:send scope (see §3.5).
await window.x2.requestCapability('session:refresh', {})Force the launch session to be reissued (token broker); updates sessionToken in the container (see §3.6).

The bridge does not accept images or attachments, only text. For an "image card", use link previews (§6).

3.1. Pull-to-refresh: per-page refresh

When the user pulls the mini app container down (at the top of the scroll), X2Chat does not reload the whole WebView; that would kick the user out of your SPA and reset navigation. Instead, the host sends the x2chat:refresh event to the page, and the mini app refreshes its CURRENT page itself and acknowledges it.

window.addEventListener('x2chat:refresh', async () => {
  try {
    await refreshCurrentPage();      // your re-fetch/re-render of the active screen
  } finally {
    // REQUIRED: otherwise the host reloads the whole WebView after 2 s (fallback)
    window.X2Chat?.postMessage?.({ method: '_refreshHandled' });
  }
});

Compatibility. If the _refreshHandled ACK doesn't arrive within 2 seconds, the host does a full reload(), so older mini apps that don't know about x2chat:refresh keep working (just without per-page refresh). Available from the iOS 2.27.4+1442 container.

3.2. "Back": navigating the hierarchy

By default the back button or gesture minimizes the mini app (see 3.7). To make "back" first walk the mini app's internal hierarchy (detail → list → root) and minimize it only from the root screen, the mini app reports its navigation state to the host on every screen change:

function syncNav() { window.x2.setBackState(router.canGoBack(), hasUnsavedChanges()); }
// call syncNav() on every screen change and on x2chat:ready

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

window.addEventListener('x2chat:exitRequest', () => {
  if (confirm('Leave the app?')) window.X2Chat.postMessage({ method: 'close' });
});
SignalMeaning
window.x2.setAttention(on, text)Mini app → host: a minimized app asks for attention, showing a dot on the "return" bar and on the icon, with text (up to 80 characters) instead of the caption. Cleared when the user comes back. See 3.7.
window.x2.setBackState(canGoBack, confirmExit)Mini app → host: whether it can go back internally (canGoBack) and whether closing with the cross needs confirmation (confirmExit).
x2chat:back eventHost → mini app: go to the previous screen in your own hierarchy (sent when canGoBack=true).
x2chat:exitRequest eventHost → mini app: the user tapped the cross (in the header or on the "return" bar) and you have set confirmExit=true; show a confirmation and, if the user agrees, call postMessage({method:'close'}).
x2chat:visibility eventHost → mini app: detail.state = 'hidden' (minimized) or 'visible' (restored). See 3.7.

iOS: when there is a "back" target, the native edge swipe is suppressed; internal "back" works via the chevron in the header or your own UI, and on the root screen the swipe minimizes the mini app. On Android the system back gesture/button is always delegated. By default (the mini app sends no state), "back" minimizes right away. Hierarchy navigation is available from iOS 2.27.4+1442; minimizing, from the first build after 3.1.0+1765.

3.3. Camera and microphone (getUserMedia)

The camera and microphone inside a mini app work through the standard navigator.mediaDevices.getUserMedia; no separate bridge is needed. X2Chat grants the WebView the media permission automatically. A typical use case is a QR scanner inside the app.

// rear camera for the scanner
const stream = await navigator.mediaDevices.getUserMedia({
  video: { facingMode: { ideal: 'environment' } }, audio: false
});
videoEl.srcObject = stream; await videoEl.play();
// decode the QR with any library: @zxing/browser | html5-qrcode | jsQR
stream.getTracks().forEach(t => t.stop());   // when done, ALWAYS stop the tracks

Requirements and compatibility. HTTPS only (getUserMedia doesn't work over http); request the camera in response to a user action (a click), not on page load. On Android, camera access in the WebView is granted from the container with the 2026-06-19 fix onwards; on older builds, provide a graceful fallback (manual entry / paste from the clipboard) for NotAllowedError / NotFoundError / NotReadableError and check navigator.mediaDevices?.getUserMedia before calling it. On iOS it works through the system permission prompt (asked on first use).

3.4. Sharing and picking a contact

For crypto wallets and apps that need the identity of the person on the other side or the system share sheet, use window.x2.requestCapability(scope, payload):

scopepayloadWhat it doesReturns
share:external{ text, url, title }System share sheet (Android Intent / iOS UIActivityViewController). No separate scope needed.{ success: true }
share:forward{ text, url }X2Chat chat picker → sends the text to the selected contact (E2E via ChatService). No separate scope needed.{ success: true, sentTo: [chat_id] } / { success: false, error }
contacts:pick{}Contact picker → the selected contact (1:1). Requires the contacts:pick scope. For a group, slot_id=null, group=true.{ user_id, slot_id, display_name, group } / { cancelled }
// system share
await window.x2.requestCapability('share:external', { text: 'TRC20 address', url: 'https://…' });

// pick a recipient (requires the contacts:pick scope)
const c = await window.x2.requestCapability('contacts:pick', {});
if (!c.cancelled && c.slot_id) resolveAndSend(c.slot_id, c.display_name);

Identity: the current user's slot_id comes in context.slot_id (see §3); contacts:pick returns the other person's slot_id (disclosing it requires a scope, which goes to operator approval). Compatibility: available in Android builds after 2026-06-19; on older builds requestCapability returns an error, so provide a fallback (manual entry / clipboard).

3.5. Notify a recipient by slot_id (message:notify)

The host resolves or creates a 1:1 chat with the recipient by slot_id and sends them an E2E message with text. The push notification arrives as usual, as for any new message; you don't need to send a separate notify. The message body is the push text. The main use case is a wallet notifying the recipient of a transfer.

const r = await window.x2.requestCapability('message:notify', {
  to:   { slot_id: 'YQS4C0' },   // recipient by slot_id (from contacts:pick / a form)
  text: '💸 Transfer 2 USDT'      // message body = push text
});
// → { success: true, chat_id, message_id }
// → { success: false, error: 'recipient_not_found' | 'no_permission' | 'empty_text' }

Gated by the already granted message:send scope; don't introduce a separate scope (that would require reinstalling the mini app, which users won't do). Only to:{slot_id} is supported for now; to:{user_id}/to:{username}, deeplink and dedup_key are not supported yet. Difference from the server-side host/notify (§7a): that one only sends a push to an offline recipient using an app key, while message:notify is a bridge call from the WebView and the main way to send by slot_id (it creates a real message). Available from Android 2.27.2+1455.

3.6. Session management (token broker)

The mini app launch session is short-lived (30 min). The X2Chat container works as a token broker: when the token expires (the host API returned 401) or on request, the container reissues the session itself using the user's real authorization, re-injects a fresh window.X2Chat.launch.sessionToken and fires an event. There's no need to sign in again or restart the app; calls survive expiry transparently (the container retries once automatically).

// force a reissue proactively (without waiting for a 401)
const r = await window.x2.requestCapability('session:refresh', {});
// → { success: true, sessionToken }

// the host fires an event after a successful reissue;
// by then window.X2Chat.launch.sessionToken is already fresh
window.addEventListener('x2chat:session', (e) => {
  if (e.detail.state === 'active') { /* token refreshed */ }
});

session:refresh is handled in the container (it doesn't reach the back end). The back end supports sliding sessions: every call extends a session that is still alive. Recommendation: when the host API responds with 401/expired, call session:refresh once and retry the request. Available from Android 2.27.2+1454.

3.7. Minimize and return

A mini app doesn't die when the user goes back to a chat. "Back" on the root screen and the "⌄" button in the header minimize it: the page stays alive with all its state, a "return" bar appears under the X2Chat header, and tapping it reopens the mini app exactly where it was, without a reload. Only the cross (or your own postMessage({method:'close'})) closes the mini app.

window.addEventListener('x2chat:visibility', (e) => {
  if (e.detail.state === 'hidden') { pausePolling(); }
  else { resumePolling(); if (scannerOpen) restartCamera(); }
});
// current state: window.X2Chat.visibility

Available from the first build after 3.1.0+1765.

4. Permissions (scopes)

At install time the user agrees to a subset of available_scopes; only the granted granted_scopes make it into the launch session.

ScopePurpose
profile:readUser profile (id, email, username, display_name, avatar, language, theme).
chat:contextContext of the chat the app was opened from (id, title, type).
message:sendMessage draft in the chat.
contacts:pickContact picker → the other person's slot_id + display_name (see §3.4).
voice:personas:readCatalog of voice personas (official calling scenario).
voice:persona:setChoosing a voice persona.

Re-consent. If you have added to available_scopes of an app that is already installed, the user has to reinstall it; otherwise requestCapability(new_scope) returns 403.

5. Seamless sign-in (SSO)

An independent app can sign the user in without a separate login by trusting the X2Chat session:

  1. The front end takes window.X2Chat.launch.sessionToken and sends it to your own back end together with window.location.origin (don't validate the JWT on the client).
  2. Based on origin, the back end picks the data center and makes a server-to-server request:
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 — token invalid / expired / issued by the other DC
  403 — profile:read scope not granted

Use the stable profile.id to create or look up your own user and issue your own session. The X2Chat token lives for about 5 minutes and isn't needed after that.

Dual DC. A token is valid only in the data center that issued it. Pick the Host API by origin: ru.x2chat.com → RU, ix.x2chat.com → IX. Internal Host API addresses are provided by the platform.

6. Sharing to a chat (card + link)

There is no separate "attach an image" method; use link previews (OpenGraph):

  1. Serve a public page (behind an unguessable token) with OG tags: og:title, og:description, og:image (absolute URL), og:site_name.
  2. Put the link into the chat as a draft:
window.X2Chat.postMessage({
  method: 'sendMessageDraft',
  payload: { text: 'Caption\nhttps://ru.x2chat.com/apps/<slug>/s/<token>' }
});

The X2Chat client unfurls the link itself and renders a card with the image. The preview is fetched by each recipient's device (User-Agent X2Chat/LinkPreview, 5 s timeout, 6 h cache), so the page and the image must be publicly reachable. Recommended image: PNG/JPEG, about 1.91:1.

7. Host API

Base path: /api/v1/mini-apps/host. Session header: X-X2Chat-Mini-App-Session: <JWT>.

MethodPathScope
GET/meprofile:read
GET/chatchat:context
POST/message-draftmessage:send
POST/capabilities/requestbody { scope, payload }; the scope must be in the session

7a. Server-to-server notify (push to an offline recipient)

Your app's back end can send a recipient a system push notification (works offline). Authentication is an app-key (issued by the platform), not a launch session.

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

host/notify vs message:notify. This server-side host/notify sends only a push to an offline recipient (app key, no message in the chat). To send a real E2E message from the WebView to a recipient by slot_id (the push arrives as usual), use the message:notify bridge capability (see §3.5); for slot_id this is the main way.

8. Hosting under a subpath (no domain of your own)

Your app can be proxied under a path on X2Chat domains: https://ru.x2chat.com/apps/<slug>/ and https://ix.x2chat.com/apps/<slug>/.

9. Icon: picked up from your app automatically

The mini app catalog in the client is an icon grid. The icon is provided by the app itself by convention (like a favicon); you don't need to upload it anywhere.

Put an icon.png file next to your launch_url (in the same folder):

launch_url:  https://ru.x2chat.com/apps/otc/
icon:        https://ru.x2chat.com/apps/otc/icon.png

launch_url:  https://cdn.example.com/app/index.html
icon:        https://cdn.example.com/app/icon.png

X2Chat picks it up automatically (icon_url is derived from launch_url). The client loads the icon live: replace the file and the icon updates without a reinstall. Requirements: square, ≥ 256×256, PNG. No file (404) → a gradient with the first letter of the name is shown. Keep name short (2 lines under the icon).

10. Common problems

SymptomCause
The app is not in the catalogNo entry in this DC, or status ≠ enabled / review_status ≠ approved
403 from the Host APIThe scope was not granted at install time (re-consent needed)
401 from the Host APIThe launch session has expired or been revoked, or the request went to the other DC
The WebView blocks navigationThe URL is outside allowed_origins
Assets/API break under a subpathThe front end uses absolute paths instead of relative ones (§8)