Security model
- Create signed catalog and session requests only on your trusted backend. Never embed the App Secret in APK, IPA, Flutter assets or browser JavaScript.
- The server validates active subscription, enabled game, requested mode, timestamp, nonce and HMAC signature before issuing a session.
- Launch URLs are short-lived. Do not log, cache, persist or share them.
- Treat WebView messages as untrusted input; validate the exact CDN origin and allow-list event types.
Production launch flow
- 1Load the dynamic game catalog through your own authenticated backend.
- 2When a user taps a game, your backend signs POST /v1/sessions with X-Fah-* headers.
- 3Your backend returns only launchUrl and expiry to the signed-in app.
- 4Open launchUrl in full-screen or in the lower voice-room panel. Use the per-game view metadata instead of hard-coded heights.
- 5The game exchanges the one-time token and checks subscription state on the server throughout play.
1. Sign the session request on your backend
// server/fahswe-session.ts — trusted backend only
import crypto from "node:crypto";
const API = "https://game-api.fahswe.com/v1";
const APP_ID = process.env.FAHSWE_APP_ID!;
const APP_SECRET = process.env.FAHSWE_APP_SECRET!;
function sha256(value: string) {
return crypto.createHash("sha256").update(value).digest("hex");
}
export async function createGameSession(input: {
externalUserId: string;
gameSlug: string;
mode: "full-screen" | "voice-room";
roomId?: string;
language?: "en" | "bn";
}) {
const path = "/v1/sessions";
const timestamp = String(Date.now());
const nonce = crypto.randomBytes(24).toString("base64url");
const body = JSON.stringify({
...input,
language: input.language ?? "en",
safeArea: { top: 0, bottom: 0, standard: 1 },
});
const canonical = ["POST", path, timestamp, nonce, sha256(body)].join("\n");
const signature = crypto
.createHmac("sha256", APP_SECRET)
.update(canonical)
.digest("hex");
const response = await fetch(API + "/sessions", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Fah-App-Id": APP_ID,
"X-Fah-Timestamp": timestamp,
"X-Fah-Nonce": nonce,
"X-Fah-Signature": signature,
},
body,
cache: "no-store",
});
const data = await response.json();
if (!response.ok) throw new Error(data.error ?? "SESSION_FAILED");
return data; // Return only data.launchUrl to the mobile app.
}2. Expose a protected endpoint to your own app
// POST /api/games/session — your app's authenticated API
const user = await requireSignedInUser(request);
const { gameSlug, mode, roomId } = await request.json();
const session = await createGameSession({
externalUserId: user.id, // stable ID from your database
gameSlug, // from the signed catalog
mode, // full-screen | voice-room
roomId: mode === "voice-room" ? roomId : undefined,
language: user.language,
});
return Response.json({
launchUrl: session.launchUrl, // short-lived; do not store it
expiresAt: session.expiresAt,
view: session.view,
});3. Open the launch URL in the client
final response = await api.post('/api/games/session', data: {
'gameSlug': game.id,
'mode': isVoiceRoom ? 'voice-room' : 'full-screen',
if (isVoiceRoom) 'roomId': roomId,
});
final launchUrl = response.data['launchUrl'] as String;
final controller = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setNavigationDelegate(NavigationDelegate(
onNavigationRequest: (request) {
final uri = Uri.parse(request.url);
return uri.scheme == 'https' && uri.host == 'games.fahswe.com'
? NavigationDecision.navigate
: NavigationDecision.prevent;
},
))
..loadRequest(Uri.parse(launchUrl));// launchUrl comes from YOUR authenticated backend, never from the APK.
webView.settings.javaScriptEnabled = true
webView.settings.domStorageEnabled = true
webView.settings.allowFileAccess = false
webView.settings.allowContentAccess = false
webView.settings.mixedContentMode = WebSettings.MIXED_CONTENT_NEVER_ALLOW
webView.webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(v: WebView, r: WebResourceRequest): Boolean {
val u = r.url
return u.scheme != "https" || u.host != "games.fahswe.com"
}
}
webView.loadUrl(launchUrl)let configuration = WKWebViewConfiguration()
configuration.websiteDataStore = .default()
let webView = WKWebView(frame: .zero, configuration: configuration)
guard launchURL.scheme == "https",
launchURL.host == "games.fahswe.com" else { return }
webView.load(URLRequest(url: launchURL,
cachePolicy: .reloadIgnoringLocalCacheData))<WebView
source={{ uri: launchUrl }}
javaScriptEnabled
domStorageEnabled
originWhitelist={["https://games.fahswe.com"]}
onShouldStartLoadWithRequest={({ url }) =>
new URL(url).origin === "https://games.fahswe.com"
}
onMessage={({ nativeEvent }) => handleAllowListedEvent(nativeEvent.data)}
/><iframe
src={launchUrl}
title="Fah Swe game"
allow="autoplay; fullscreen"
sandbox="allow-scripts allow-same-origin"
referrerPolicy="no-referrer"
/>
window.addEventListener("message", (event) => {
if (event.origin !== "https://games.fahswe.com") return;
if (event.data?.source !== "fahswe-game") return;
// Allow-list: FAHSWE_GAME_LOADED, FAHSWE_GAME_CLOSE,
// FAHSWE_GAME_RECHARGE, FAHSWE_ROUND_RESULT, FAHSWE_GAME_BLOCKED.
});Full-screen and voice-room modes
Send mode=full-screen for a dedicated game page. Send mode=voice-room with a non-empty roomId for an embedded lower panel. Read voiceHeightPercent and minVoiceHeightDp from each catalog item so Teen Patti and future games scale correctly without app updates.
Server-authoritative individual and shared rounds
Each catalog game declares its round mode. INDIVIDUAL games such as Fortune Game start instantly for the player who presses Spin and resolve one secure server outcome for that session. SHARED games synchronize one Fah Swe round ID, UTC-aligned deadlines and a common outcome across players. In both modes, never add a host-side random result, retry result or balance calculation; repeated settlement is idempotent.
Wallet timing and atomic settlement
During betting, the H5 game immediately displays available balance as authoritative balance minus the current round's reserved bets. Do not debit the client wallet for each chip tap. When the secure round resolves, Fah Swe sends one signed result settlement containing stakeMinor, payoutMinor and netMinor = payoutMinor - stakeMinor. Verify the callback, atomically apply netMinor once, and return the updated integer balance. Replays must return the original stored result without changing the balance again.
// One signed callback is sent when a secure round resolves.
// Chip taps reserve balance in the H5 display; they are not backend debits.
const { orderId, idempotencyKey, stakeMinor, payoutMinor, netMinor } = body;
if (netMinor !== payoutMinor - stakeMinor) {
return Response.json({ error: "SETTLEMENT_INVALID" }, { status: 400 });
}
// After verifying the HMAC, timestamp and nonce:
// 1. Atomically apply netMinor exactly once for orderId/idempotencyKey.
// 2. On replay, return the stored result without applying it again.
// 3. Return the authoritative integer balance after settlement.
return Response.json({
status: true,
data: { balanceMinor: updatedBalance },
});Host bridge events
The shared SDK posts versioned messages with source=fahswe-game. Allow only documented FAHSWE_* types, verify event.origin is exactly https://games.fahswe.com, and refresh wallet state from your backend. Never trust a client-supplied balance or result.
Subscription behavior
Only active subscribers receive sessions. A payment-due grace state shows a warning. After grace expires the server blocks new sessions. Once an invoice is verified, backend entitlement becomes active again without changing the mobile app.
Virtual entertainment only
Fah Swe does not provide gambling, cash wagering, cash redemption or transferable-value credits. Integrators remain responsible for age controls, local law, app-store rules and truthful disclosures.