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.
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.
mini_app_launch, about 5 minutes) and the X-X2Chat-Mini-App-Session header.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.
https://ru.x2chat.com/apps/<slug>/ (see §8).POST /api/v1/mini-apps/register request from your X2Chat account.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.
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:
profile:read/chat:context/message:send) → accepted and waiting for operator approval (HTTP 202).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).
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.
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).
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:
x2_lang=<code> parameter in the launch URL: the language is known before the bridge is injected, so the page renders in the right language straight away (look up the offline cache with ignoreSearch: true);window.X2Chat.language, window.X2Chat.isRtl and context.locale, after injection;x2chat:language event (detail = { language, rtl }): the user has switched languages, re-render on the fly.<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.
| Call | Purpose |
|---|---|
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).
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.
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' });
});
| Signal | Meaning |
|---|---|
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 event | Host → mini app: go to the previous screen in your own hierarchy (sent when canGoBack=true). |
x2chat:exitRequest event | Host → 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 event | Host → 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.
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).
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):
| scope | payload | What it does | Returns |
|---|---|---|---|
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).
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.
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.
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
getUserMedia streams are stopped). On visible, request the stream again if you need it.requestCapability calls from a minimized mini app wait until the user comes back; the promise is not rejected.window.x2.setAttention(true, 'Transfer awaiting confirmation'); a dot lights up on the bar and the icon, and the text replaces the caption. setAttention(false) clears it; the mark also clears itself when the user comes back. Available from 3.1.0+1768.resize).chat_id).Available from the first build after 3.1.0+1765.
At install time the user agrees to a subset of available_scopes; only the granted granted_scopes make it into the launch session.
| Scope | Purpose |
|---|---|
profile:read | User profile (id, email, username, display_name, avatar, language, theme). |
chat:context | Context of the chat the app was opened from (id, title, type). |
message:send | Message draft in the chat. |
contacts:pick | Contact picker → the other person's slot_id + display_name (see §3.4). |
voice:personas:read | Catalog of voice personas (official calling scenario). |
voice:persona:set | Choosing 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.
An independent app can sign the user in without a separate login by trusting the X2Chat session:
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).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.
There is no separate "attach an image" method; use link previews (OpenGraph):
og:title, og:description, og:image (absolute URL), og:site_name.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.
Base path: /api/v1/mini-apps/host. Session header: X-X2Chat-Mini-App-Session: <JWT>.
| Method | Path | Scope |
|---|---|---|
| GET | /me | profile:read |
| GET | /chat | chat:context |
| POST | /message-draft | message:send |
| POST | /capabilities/request | body { scope, payload }; the scope must be in the session |
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 }
dedup_key.401 invalid key · 403 no permission/IP · 429 rate limit.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.
Your app can be proxied under a path on X2Chat domains: https://ru.x2chat.com/apps/<slug>/ and https://ix.x2chat.com/apps/<slug>/.
fetch('api/...'), src="static/...", without a leading /). An absolute /api/... would hit X2Chat's own API.launch_url must end with a slash; add a redirect /apps/<slug> → /apps/<slug>/.mini_apps registry is not replicated between DCs; register in each DC with its own launch_url.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).
| Symptom | Cause |
|---|---|
| The app is not in the catalog | No entry in this DC, or status ≠ enabled / review_status ≠ approved |
403 from the Host API | The scope was not granted at install time (re-consent needed) |
401 from the Host API | The launch session has expired or been revoked, or the request went to the other DC |
| The WebView blocks navigation | The URL is outside allowed_origins |
| Assets/API break under a subpath | The front end uses absolute paths instead of relative ones (§8) |