Baziness Wiki
فارسی
Getting started

Introduction

What Baziness gives your game, how the parts fit together, and how this wiki is organised.

What Baziness is

Baziness is the game platform of Gita Games. When your game integrates it, your players get:

  • A Baziness account: they sign in with their phone number, and the same account works in every Baziness game.
  • Gita coins (گیتا کوین): the platform currency. Players earn it in games and spend it on real prizes.
  • Baziness pages inside your game: sign-in, profile, the «انتخاب جایزه» reward shop, and your game's own offer page.
  • Baziness Pay: real-money purchases through a hosted payment page.

These come as two separate integrations. You can ship either one alone, or both together:

Integration What the player gets Who talks to Baziness What you need
Baziness platform accounts, Gita coins, Baziness pages your server calls the platform API; your client shows the pages and reads the coin balance client id, client secret, platform package name
Baziness Pay real-money purchases your server calls the Pay API; your client opens the payment page Pay package name, SKUs registered in the Baziness dashboard

How the parts fit together

flowchart LR
  subgraph Device["Player device"]
    G["Your game client"]
    S["Baziness sheet<br/>iframe → baziness.com"]
    P["Payment tab<br/>pay.baziness.com/buy"]
  end
  subgraph Backend["Your backend"]
    B["Game server<br/>(holds clientId + clientSecret)"]
  end
  K[("Baziness platform API")]
  Y[("Baziness Pay API")]
  G -- "/api/platform/*" --> B
  G -- "/api/shop/purchase/*" --> B
  B -- "client/auth · create-user · add-coin …" --> K
  B -- "get_payment · verify_payment · consume" --> Y
  G -- "user/get-coin (user token)" --> K
  G -- "opens" --> S
  S -- "postMessage: login · logout · close" --> G
  G -- "opens" --> P

The client calls Baziness directly in only two cases: it shows Baziness pages, and it reads the coin balance (user/get-coin) with the player's own token. Everything else goes through your server, because only your server holds the client secret.

The five golden rules

  1. The client secret never leaves your server. The browser or app gets only public settings and the player's own token.
  2. The server decides and the client only displays. Account links, coin grants and purchases are always checked on the server.
  3. Every Baziness page link carries packageName and sdkType=js. Without sdkType=js the page can't talk to your game, so it can't even close itself.
  4. The game keeps working without Baziness. If the platform is switched off or unreachable, hide the Baziness parts and carry on.
  5. Every payout has a unique, repeatable id. A retry, a double tap or a crash must never pay twice.

Environments

Development (test games) Production
Platform API https://platform-dev.karizmastudios.org/api https://platform.karizmastudio.com/api
Baziness pages https://platform-web-dev.karizmastudios.org https://baziness.com
Baziness Pay https://pay-dev.karizmastudios.org https://pay.baziness.com
Signing in any phone number, code 111111 (no SMS is sent) real phone number and SMS code
Test purchases «خرید آزمایشی» on the payment page (no real payment) real payment

Each environment has its own client id and secret.

How this wiki is organised

Section What is in it Code?
Getting started this page, and the integration step by step no
API every endpoint in order: what it does, when to call it, request, response and rules only requests and responses
Guides the flows: login, Gita coins, the reward page, the tutorial offer page, links no
Code samples ready-made server code (TypeScript, C#, Python) and client code (web, React, Cocos Creator, Unity), including the web view yes
Example game a complete open-source game that uses everything here: play it and download it the whole project
Reference the go-live checklist, troubleshooting, and every endpoint in one table no

Every page can be downloaded as Markdown or PDF from the buttons under its title. The sidebar has the full guide in both formats.

Getting started

Getting started, step by step

What to ask the platform team for, where each value goes, and the whole integration as a short list of steps, each linking to its details.

1. Ask for your credentials

The Baziness platform team gives you these values, one set per environment (development and production):

Value Example Used by Secret?
Client id 12 your server, to get a client token no, but keep it private
Client secret •••••••• your server, to get a client token yes — server only
Platform package name com.karizmastudio.example every Baziness page link (packageName) no
Game name example the reward page and the game offer page (game_name) no
Pay package name com.karizmastudio.example every Baziness Pay call (pname); may differ from the platform package no
SKUs example_pack the products you sell with Baziness Pay no

Warning

The client secret must never reach a browser or an app build: not in client code, not in a VITE_* or NEXT_PUBLIC_* variable, not in git. Anyone holding it can create players and give Gita coins as your game.

2. Understand the two tokens

Everything on the platform uses one of two tokens. Keeping them apart is the key to the whole integration:

Token Who has it How it is obtained What it is for
Client token your server only client id + client secret → client/auth every server-to-server call: creating players, checking tokens, giving coins
User token the player's client (from your server) client/create-user or client/get-user-token opening Baziness pages as this player (linkedToken), reading their coin balance

3. Follow the steps

# Step Where Details
1 Your server gets a client token with the client id and secret server Platform API → step 1
2 On a player's first visit, your server creates their Baziness user and saves its id. On later visits it gets a fresh user token server steps 2–3
3 Your client shows Baziness pages (sign-in, profile, rewards) with the user token, and listens to their messages client Baziness pages
4 When the player signs in on a page, your server checks the token and links the account (or switches to the player's other account) server Login, step 4
5 Your client shows the Gita coin balance, and your server gives Gita coins for wins and rewards both Gita coins, steps 5–6
6 The «انتخاب جایزه» button opens the reward page; the tutorial can open your game offer page client Reward page, Tutorial offer page
7 Sell items with Baziness Pay both Baziness Pay
8 Tag the links that bring players in with UTM parameters both Links & UTM

Ready-made code for every step is in Server code and Client code. The example game puts it all together.

4. Configure your server

The example game and the code samples read these settings. Use the same names, so samples can be copied unchanged:

Setting Example Meaning
BAZINESS_PLATFORM_ENABLED true master switch. While false, the game shows no Baziness UI at all
BAZINESS_PLATFORM_USE_PRODUCTION false false = development URLs, true = production URLs
BAZINESS_CLIENT_ID 12 client id
BAZINESS_CLIENT_SECRET •••• client secret (secret)
BAZINESS_PLATFORM_PACKAGE_NAME com.karizmastudio.example packageName on page links
BAZINESS_GAME_NAME example game_name on the reward and offer pages
BAZINESS_PAY_ENABLED true turns the buy buttons on
BAZINESS_PAY_BASE_URL https://pay-dev.karizmastudios.org the Pay server for this environment
BAZINESS_PAY_PACKAGE_NAME com.karizmastudio.example pname on every Pay call
BAZINESS_SKU_PREFIX (empty) optional: SKU = prefix + product id

The platform counts as on only when BAZINESS_PLATFORM_ENABLED=true and both the client id and the secret are set. A half-configured server then behaves as if Baziness didn't exist.

API

Platform API, step by step

Every platform endpoint in the order you use it — what it does, when to call it, what to send and what comes back. No code; ready-made code is in the Code samples section.

Before you start

Important

User tokens are moving to 15-minute access tokens, refresh tokens kept on your server, and one-time link codes. The steps below still work during the migration. What to change, and by when: Token migration.

Base URL {api} development: https://platform-dev.karizmastudios.org/api
production: https://platform.karizmastudio.com/api
Format JSON request and response bodies (Content-Type: application/json), field names in camelCase
Who calls it your server, except step 6 (user/get-coin), which the player's client calls
Authentication steps 2–5 need Authorization: Bearer <client token> from step 1; step 6 needs the player's user token

The whole sequence for a new player:

sequenceDiagram
  autonumber
  participant S as Your server
  participant K as Platform API
  participant C as Player's client
  S->>K: client/auth (client id + secret)
  K-->>S: client token
  S->>K: client/create-user (device id)
  K-->>S: userId + user token
  S-->>C: user token
  C->>K: user/get-coin (user token)
  K-->>C: balance
  Note over C: the player signs in on a Baziness page → the page gives the client a token
  C->>S: that token
  S->>K: client/validate-user-token
  K-->>S: userId (who signed in)
  S->>K: client/add-coin (when the player wins)
  K-->>S: new balance

Step 1 — Get a client token

What it does: exchanges your client id and client secret for a client token. Every other server call (steps 2–5) sends this token in the Authorization header.

When to call it: on the first platform call after your server starts, and again whenever any call answers 401, because the token has expired.

HTTP
POST {api}/client/auth
Content-Type: application/json

{ "clientId": 12, "clientSecret": "••••••••••••" }

→ 200 OK
{ "clientId": 12, "token": "eyJhbGciOiJIUzI1NiIs…" }

Rules

  • Keep the token in your server's memory and reuse it for every call. Don't request a new one per call.
  • When a call answers 401 Unauthorized, call client/auth again and retry that call once.
  • The client token is as sensitive as the secret: never send it to a client.

Step 2 — Create a Baziness user for a player

What it does: gives one of your players a Baziness user. It returns:

  • the Baziness userId — save it on your player's record; you need it for every later call
  • a user token for this player — send it to your client
  • isLinkedToAccount — whether this user has signed in with a phone number (true) or is still anonymous (false)

When to call it: the first time a player of yours needs Baziness, which is usually their first launch while the platform is on. If you already have a userId saved for the player, use step 3 instead.

HTTP
POST {api}/client/create-user
Authorization: Bearer <client token>
Content-Type: application/json

{ "deviceId": "8f0c2b7e-…", "platform": "Web", "store": "Direct", "linkedToken": null }

→ 200 OK
{ "userId": 239236, "token": "eyJhbGciOiJIUzI1NiIs…", "isLinkedToAccount": false }
Field Value
deviceId your player's stable id, for example the device id your game generated on first launch
platform Web, Android, Ios or Editor
store Direct (web), GooglePlay, AppStore, CafeBazaar or Myket
linkedToken a user token the player already has, or null. The Baziness app opens games with ?linkedToken=…; passing it here binds the player to that Baziness account

Important

If the returned userId is already saved on another of your players, it is the same person, for example on a new phone. Don't save it a second time. Switch this device to that player instead (see Login → account switching). Put a unique constraint on the column that stores the userId.

Step 3 — Get a fresh user token

What it does: returns a new user token for a Baziness user you already know, and their current isLinkedToAccount.

When to call it: on every later session of the player, typically each time your client boots. Then the client always has a valid token, and you learn when an anonymous user has signed in (isLinkedToAccount became true).

HTTP
GET {api}/client/get-user-token/239236
Authorization: Bearer <client token>

→ 200 OK
{ "userId": 239236, "token": "eyJhbGciOiJIUzI1NiIs…", "isLinkedToAccount": true }

Step 4 — Verify a token from the client

What it does: tells you whether a user token is genuine, and which Baziness user it belongs to.

When to call it: whenever a token arrives from a client and you're about to act on it. The main case: the player signs in on the Baziness login page, the page gives your client a token, and your client sends it to your server. Never link an account or give anything based on a token you haven't verified.

HTTP
GET {api}/client/validate-user-token/eyJhbGciOiJIUzI1NiIs…
Authorization: Bearer <client token>

→ 200 OK
{ "userId": 239236, "isValid": true }

→ 200 OK   (an invalid or expired token is NOT an HTTP error)
{ "userId": 0, "isValid": false }

Warning

An invalid token is not an HTTP error: the answer is 200 with "isValid": false and "userId": 0. Always check isValid.

Step 5 — Give Gita coins

What it does: adds Gita coins to a player's Baziness balance and returns the new balance.

When to call it: when the player earns coins in your game, for example by winning a match, ranking in a tournament or opening a gift.

HTTP
POST {api}/client/add-coin
Authorization: Bearer <client token>
Content-Type: application/json

{
  "userId": 239236,
  "amount": 50,
  "transactionId": "239236_50_9f2c1a7d0b4e8c3f5a6d2e1b",
  "metaData": { "source": "match_win", "matchId": 17 }
}

→ 200 OK
{ "resourceId": 1, "amount": 2450 }          ← the player's new balance

→ 400 Bad Request   (this transactionId was already credited)
{ "success": false, "error": "add resource failed" }
Field Value
userId the Baziness user id from step 2
amount how many coins to add (positive)
transactionId a unique id for this payout (see below). The platform credits each id only once
metaData any JSON you want stored with the transaction (source, match id …), useful for reports

Making transactionId safe: build it from what the payout is, not from the time:

Text
transactionId = <userId>_<amount>_<first 24 hex chars of sha1(grantKey)>
grantKey      = "match:17" | "reward_claim:42" | "payment:88:GITA_COIN" | "tutorial_gift:<userId>"
  • The same payout always produces the same id. A retry, a double tap or a repeated request is refused with 400 and never pays twice.
  • Treat that 400 as "already paid" and finish your own bookkeeping, for example marking the reward as claimed. Otherwise a crash between the credit and your save leaves the player stuck on an error.
  • An id made from the clock (<userId>_<amount>_<time>) credits again on every call. Use it only for grants that are genuinely repeatable.

Step 6 — Read the balance (client)

What it does: returns the player's Gita coin balance. This is the only platform call the client makes, with the player's user token. Cross-origin requests are allowed, so a browser game can call it directly.

When to call it: after the client receives the token, when a Baziness page closes (the player may have spent coins), when the game comes back to the foreground, and after your server gave coins.

HTTP
GET {api}/user/get-coin
Authorization: Bearer <user token>

→ 200 OK
{ "resourceId": 1, "amount": 2450 }

Errors at a glance

Answer Where Meaning What to do
401 Unauthorized steps 2–5 missing or expired client token call step 1 again, retry once
200 + "isValid": false step 4 the token is not genuine or has expired refuse the request; the client should sign in again
400 + "add resource failed" step 5 this transactionId was already credited treat it as paid
network error / 5xx any the platform is unreachable keep the game running without Baziness features; retry later
API

Baziness pages & their messages

The web pages your game shows inside itself — sign-in, profile, the reward shop and your game's offer page — the parameters each one needs, and the messages they send back.

What the pages are

Some parts of Baziness are web pages that your game shows inside itself, for example the sign-in screen and the «انتخاب جایزه» reward shop. You open a page's URL in an in-game web view: an iframe on the web, a WebView component in an engine. The page talks back to your game with messages, for example "the player signed in" or "close me".

All the pages are hosted on one site:

Environment Pages base {web}
Development https://platform-web-dev.karizmastudios.org
Production https://baziness.com

How to build the in-game view (a sheet that slides up and is swiped down to close) is in Bottom sheet / web view.

Parameters

Important

linkedToken is being replaced by linkCode, a one-time code from your server (v2/client/link-code/{userId}). Pages accept both during the migration. See Token migration.

Parameter When Value Why
packageName always your platform package name identifies your game
sdkType always js tells the page it is inside a game, so it sends you messages. Without it, even the page's close button can't close your view
linkedToken whenever you have one the player's user token the page acts as this player, and a sign-in on the page is bound to them
redirect profile, rewards profile / shop where the page returns after a sign-in inside it
allowGuest sign-in, profile, game offer false hides the page's own "continue as guest"; your game already offers that choice
game_name rewards, game offer your game name, URL-encoded selects your game's prizes and offer
buildStore store builds only CafeBazaar / Myket the page hides links to the other stores

Encode every value, because game_name may be Persian and tokens may contain + or /.

The pages

Page Path Open it when Parameters
Sign-in / the player taps «ورود با حساب بازینس», or must sign in to claim a reward packageName, sdkType=js, linkedToken, allowGuest=false
Profile /profile Settings → connect or manage the account packageName, sdkType=js, linkedToken, redirect=profile, allowGuest=false
Reward shop «انتخاب جایزه» /shop the player taps your reward button game_name, packageName, sdkType=js, linkedToken, redirect=shop. No allowGuest
Game offer /game-offer once, during your tutorial (details) game_name, packageName, sdkType=js, linkedToken, allowGuest=false
Text
Login        https://baziness.com/?packageName=com.karizmastudio.example&sdkType=js&linkedToken=eyJ…&allowGuest=false
Profile      https://baziness.com/profile?packageName=com.karizmastudio.example&sdkType=js&linkedToken=eyJ…&redirect=profile&allowGuest=false
Rewards      https://baziness.com/shop?game_name=example&packageName=com.karizmastudio.example&sdkType=js&linkedToken=eyJ…&redirect=shop
Game offer   https://baziness.com/game-offer?game_name=example&packageName=com.karizmastudio.example&sdkType=js&linkedToken=eyJ…&allowGuest=false

Important

For the reward shop, open /shop. Don't open the site root (/): the root sends a player inside a game to the sign-in screen, while /shop uses the token the player already has.

Messages from the pages

A page sends messages with postMessage. Each message is JSON, as a string or an object, in the form { "type": "...", "data": ... }:

type data Meaning What your game does
close — the page's close button was pressed close the view
login a token: "<token>" or { "token": "<token>" } the player signed in to an account send the token to your server, which verifies it and links the account or switches to the player's other account; then refresh the token and close the view
login (none) the player's current account was linked in place mark the player as linked, refresh the token
token a token a silent handshake the page sends when it loads ignore it: it is not a sign-in
logout — the player signed out inside the page close the view, delete everything your game stored locally, and start again as a brand-new visitor

Warning

  • Only accept messages whose origin is the page you opened ({web}), and ignore everything else.
  • Never treat token as a sign-in. If you do, the reward shop closes as soon as it opens.
API

Baziness Pay, step by step

Selling items for real money, in order — open the payment page, check and verify the payment, give the item exactly once, and consume the purchase so it can be bought again.

Before you start

Base URL {pay} development: https://pay-dev.karizmastudios.org
production: https://pay.baziness.com
Who calls it your server. The client only opens the payment page, and otherwise calls your own server
pname your Pay package name. It may differ from the platform package name
sku the product id registered in the Baziness dashboard (for example example_pack)
uid your stable id for the player, and it must be numeric (send it as a string, e.g. "1042"). A non-numeric uid is rejected with no_required_params
sequenceDiagram
  autonumber
  participant P as Player
  participant C as Your client
  participant S as Your server
  participant Y as Baziness Pay
  P->>C: taps "Buy"
  C->>S: start a purchase
  S-->>C: payment id + payment page URL
  C->>Y: opens the payment page (new tab)
  P->>Y: pays
  P->>C: returns to the game (or taps «پرداخت کردم»)
  C->>S: verify my purchase
  S->>Y: get_payment
  Y-->>S: payment token
  S->>Y: verify_payment
  Y-->>S: success
  S->>S: give the item — exactly once
  S-->>C: done + new balances
  S->>Y: consume

Step 1 — Open the payment page

What it does: shows the player Baziness's payment page for one product.

How: when the player taps "Buy", your client asks your server to start a purchase. Your server saves an order (see step 4), builds the page URL and returns it. The client opens it in a new tab on the web, or in the system browser on mobile.

HTTP
GET {pay}/buy?sku=example_pack&uid=1042&pname=com.karizmastudio.example

→ the hosted payment page (opened in a new tab / the system browser)

Tip

Browsers block new tabs that aren't opened directly by a tap. Open an empty tab in the tap handler first, then send it to the URL once your server answers (client code).

Step 2 — Check whether the player paid

What it does: asks Baziness whether this player has a completed, not yet consumed payment for this product, and if so returns its payment token.

When to call it: when the player comes back to the game (the window or app regains focus), when they tap «پرداخت کردم» ("I've paid"), and when your shop opens, to catch payments finished while the game was closed.

HTTP
POST {pay}/get_payment
Content-Type: application/json

{ "uid": "1042", "sku": "example_pack", "pname": "com.karizmastudio.example" }

→ 200 OK   (paid, not consumed yet)
{ "uid": "1042", "sku": "example_pack", "token": "TEST-633…", "time": 1791366000000, "success": true }

→ 200 OK   (nothing to verify yet — the player hasn't paid)
{ "error": "payment_is_not_valid", "success": false }

payment_is_not_valid simply means "nothing paid yet": keep waiting quietly.

Step 3 — Verify the payment

What it does: confirms with Baziness that the payment token is genuine. This is the call that protects you: never give anything based on what the client says.

HTTP
POST {pay}/verify_payment
Content-Type: application/x-www-form-urlencoded

uid=1042&token=TEST-633…&sku=example_pack&pname=com.karizmastudio.example

→ 200 OK
{ "success": true }

Warning

verify_payment takes a form body (application/x-www-form-urlencoded). The other calls take JSON. Sending JSON here is the most common reason verification fails.

Step 4 — Give the item exactly once

Baziness tells you the payment is real. Making sure the player receives the item once is your server's job. Keep a payments table in your database:

Column Meaning
id your order id (returned to the client in step 1)
user_id, product_id, sku, price who buys what
status REDIRECTED (payment page opened) → FULFILLED (item given), or FAILED / CANCELLED
reward_snapshot exactly what the player was promised when they tapped "Buy"
provider_token the payment token from step 2, with a unique constraint

Then, after a successful step 3, in one database transaction:

  1. Lock the order row and check it isn't already FULFILLED. If it is, answer "done" without giving anything again.
  2. If the same payment token already fulfilled another order, mark this one FAILED and stop.
  3. Give the items in reward_snapshot, not the product's current definition. An admin who edits a pack while the player pays must not change the order.
  4. Save the token and set the order to FULFILLED.

Step 5 — Consume the purchase

What it does: marks the purchase as used, so the same product can be bought again. Until it is consumed, step 2 keeps returning it and a second purchase of the SKU is blocked.

When: right after step 4 is committed. Try a few times if it fails. The item is already given, so a failure here never blocks the player.

HTTP
POST {pay}/consume
Content-Type: application/json

{ "uid": "1042", "sku": "example_pack", "pname": "com.karizmastudio.example" }

→ 200 OK
{ "success": true }

What the player sees

Moment Your client shows
after tapping "Buy" a small window: "the Baziness payment page is open, come back after paying", with a «پرداخت کردم» button
back in the game, payment not found yet nothing new (quiet re-check); only a tap on «پرداخت کردم» says "payment not found yet, try again in a moment"
payment verified "purchase successful", and the updated balances
window closed before paying nothing is cancelled; the payment is checked again the next time the shop opens

Testing

  • On pay-dev, the payment page offers «خرید آزمایشی» (test purchase): the whole flow, from step 1 to step 5, runs without real money.
  • Without any gateway access, use the local fake gateway from the example game: it speaks the same protocol, with a «پرداخت موفق» button (settings).

Store builds (Cafe Bazaar / Myket)

A build published in Cafe Bazaar or Myket must use that store's own in-app billing instead of Baziness Pay. Keep the same SKU names in both, so one product list serves every build, and send buildStore on Baziness page links in those builds (parameters).

Guides

Login & connecting a Baziness account

How a player gets a Baziness identity, signs in from the splash screen or Settings, and how your game switches accounts safely.

The identity model

Your game keeps its own player accounts, for example keyed by a device id generated on first launch. Baziness is attached on top:

  1. The first time the platform is on, your server creates an anonymous Baziness user for the player (create-user) and saves its userId on the player's record.
  2. The player stays anonymous (isLinkedToAccount: false) until they sign in with their phone number on a Baziness page. The account is then linked.
  3. If the Baziness account they sign into already belongs to another of your players (the same person on another phone), your game switches to that player. It doesn't link the account twice.
State Baziness userId saved? isLinkedToAccount What the player sees
Baziness off — — no Baziness UI
Guest yes (anonymous user) false coin balance and reward button work; "connect your account" in Settings
Signed in yes true everything, including claiming Gita coin rewards

The whole flow

sequenceDiagram
  autonumber
  participant C as Game client
  participant S as Game server
  participant K as Platform API
  participant W as Baziness page (sheet)
  C->>S: GET /api/platform/config
  S-->>C: { enabled, webBase, apiBase, packageName }
  Note over C: Splash: Sign in with Baziness / Continue as guest
  C->>S: POST /api/platform/token { deviceId, linkedToken? }
  S->>K: create-user (first time) / get-user-token
  K-->>S: { userId, token, isLinkedToAccount }
  S-->>C: { token, bazinessUserId, isLinked, switchToDeviceId? }
  C->>W: open login page (sdkType=js, linkedToken, allowGuest=false)
  W-->>C: postMessage { type: "login", data: "<token>" }
  C->>S: POST /api/platform/login { deviceId, token }
  S->>K: validate-user-token
  K-->>S: { isValid, userId }
  S-->>C: { ok } — or { ok, switchToDeviceId }
  C->>C: close sheet, or switch device id + reload

What your server provides

Your client never calls the platform's server API itself. It calls three endpoints of your own server:

Your endpoint The client sends Your server does It answers
GET /api/platform/config — nothing; it only reports settings { enabled, webBase, apiBase, packageName, gameName, gameOfferUrl }, or { enabled: false }
POST /api/platform/token device id, linkedToken if any no Baziness user yet → create-user; otherwise → get-user-token. If the resulting userId belongs to another player, it does not link { token, userId, isLinkedToAccount }, or { switchToDeviceId }
POST /api/platform/login device id, the token from the sign-in page validate-user-token → whose account is it? Another player's → switch; otherwise → link to this player { ok: true }, or { ok: true, switchToDeviceId }, or { ok: false }

If the platform is unreachable, /token answers { enabled: true, error: "platform_unavailable" } without a token, and the game boots anyway.

The first launch: guest or sign in

When Baziness is on and the player has neither chosen yet nor signed in, the splash screen shows two buttons instead of the loading bar:

  • «ورود با حساب بازینس» (sign in with Baziness): opens the sign-in page and waits.
  • «ادامه به عنوان مهمان» (continue as guest): remembers the choice and continues.

The boot order:

  1. Sign the player in to your own account system.
  2. Ask your server for /api/platform/config. If Baziness is off, skip everything below.
  3. If no choice was made and the player isn't signed in, show the two buttons and wait.
  4. Call /api/platform/token. If it answers switchToDeviceId, adopt that id and restart.
  5. If the player just signed in, load their profile again, so the screen shows the signed-in account.

If the player closes the sign-in page without signing in, show the two buttons again.

Account switching, step by step

A switch happens when the Baziness account the player signs into is already linked to another of your players, for example after a reinstall or on a new phone:

  1. Your server answers switchToDeviceId: "<that player's device id>".
  2. The client saves that device id as its own and forgets its stored Baziness token.
  3. It restarts, without linkedToken in the address.
  4. The next boot loads the other player, with all their progress.

Opening the game from the Baziness app

Important

The Baziness app now also sends ?linkCode= (one-time). Forward it to your server instead of linkedToken. See Token migration.

The Baziness app opens games with ?linkedToken=<token> in the address:

  • Send that token as linkedToken to /api/platform/token, so the player lands in the right Baziness account. Remove it from the address on the next restart.
  • If the device already holds a different token, a different Baziness account is arriving: clear the local data and start again before continuing.
  • Signing in decides which account plays, never whether the player is new. A first-time player arriving this way must still see your tutorial and receive your welcome gifts.

Signing in later, from Settings

A guest can connect their account at any time: Settings → open the profile page (parameters). When the view closes, ask /api/platform/token again and refresh the coin balance.

Signing out

When the page sends logout, close the view, delete everything your game stored locally (identity, token, choices, tutorial progress) and restart as a brand-new visitor.

Testing logins on the dev platform

Tip

On the development platform (the one test games use), you can sign in with any phone number and the code 111111. No SMS is sent. Make up numbers to create as many test accounts as you need: one for the account-switch test, one for a fresh guest, and so on. Production sends real SMS codes.

Common mistakes

Mistake What happens Fix
Treating the page's token message as a sign-in the reward shop closes as soon as it opens; the first sign-in completes before the player signs in only login is a sign-in
Linking a token without validate-user-token anyone can claim any account always verify on the server
Opening the sign-in page without the player's token the first sign-in from the splash has no anonymous user to bind to make sure /api/platform/token ran first and pass its token as linkedToken
No allowGuest=false on the sign-in page the player sees a second "continue as guest" add it on the sign-in, profile and offer pages
Rewarding the link more than once signing out and in pays again pay the "account connected" gift from the server, once per player

Ready-made code: server endpoints, client controller and messages.

Guides

Gita coins

How players earn and spend Gita coins, how your game shows the balance, gives coins safely, and treats players who haven't signed in yet.

How Gita coins work

Gita coin (گیتا کوین) is the Baziness currency. Players earn it in your game, for example by winning matches, ranking in tournaments, opening gifts or buying packs. They spend it on real prizes in the reward shop. The balance lives on the platform, not in your database:

Action Who How
Show the balance your client user/get-coin with the player's user token
Give coins your server client/add-coin with the client token
Spend coins the player inside the reward shop page; your game does nothing

Warning

Never treat a locally stored number as the balance, and never let the client add coins.

Showing the balance

Refresh the balance:

  • right after the client gets its token
  • when a Baziness page closes, because the player may have spent coins in the reward shop
  • when the game comes back to the foreground
  • after your server gave coins

While the balance is unknown, or the call failed, show … or hide it. Never show 0: the player would think their coins are gone.

Giving coins safely

Every payout needs a unique transactionId built from what the payout is, for example match:17 or reward_claim:42 (the formula). Then:

  • a retry, a double tap or a repeated request can never pay twice: the platform refuses the repeat with 400
  • treat that 400 as "already paid" and finish your own bookkeeping
  • record every payout in your own ledger too (who, how much, why), for support and reports

Players who haven't signed in

A guest still has an anonymous Baziness user, so coins can be given to them. Many games still draw a line at signed in:

Kind of payout Player not signed in Why
Claims: a reward the player taps to collect (a win, a tournament prize, a gift) refuse with a "sign in first" error; the client opens the sign-in page and retries after a successful sign-in coins spent on real prizes should belong to a real account
Payouts that must not fail: a paid purchase that includes Gita coins keep the coins in a local holding balance on your server a paid order must never fail
When the player signs in move the whole holding balance to Baziness with one add-coin (its own transactionId), then set the local balance to zero the coins move exactly once

When Baziness is off, keep Gita coin rewards in that local balance as well, so your economy stays testable without credentials.

Showing the currency honestly

  • Call it «گیتا کوین» everywhere, with its own icon.
  • A build that ships without Baziness must not advertise Gita coin rewards.

Ready-made code: giving coins, showing the balance.

Guides

Reward page («انتخاب جایزه»)

Let players spend their Gita coins on real prizes — a button in your game that opens the Baziness reward shop.

What it is

«انتخاب جایزه» (choose a prize) is the Baziness reward shop: a page where players spend Gita coins on real prizes. Your game does three things:

  1. earns coins for the player (Gita coins)
  2. shows the balance on a button or banner
  3. opens the reward page in the in-game web view when the button is tapped
flowchart LR
  A["Lobby banner<br/>«انتخاب جایزه» + balance"] -->|tap| B{"platform<br/>enabled?"}
  B -- no --> T["toast: available after<br/>connecting Baziness"]
  B -- yes --> C["open sheet:<br/>{webBase}/shop?game_name=…"]
  C --> D["player picks prizes,<br/>spends Gita coins"]
  D -->|swipe down / close| E["sheet closed →<br/>refresh get-coin"]

The address

Text
{web}/shop?game_name=<your game>&packageName=<package>&sdkType=js&linkedToken=<user token>&redirect=shop

The parameters are explained on Baziness pages. Two rules matter most here:

  • Open /shop, not the site root. The root sends a player inside a game to the sign-in screen.
  • Don't add allowGuest=false: the shop is a destination, not a sign-in.

The button in your game

  • Put it where players look: the lobby or the main menu. Show the live balance on it, with the Gita coin icon.
  • When Baziness is off, either hide it or show "available after connecting to Baziness" when it is tapped.
  • During your tutorial, the first tap can open your game offer page instead.

After the page closes

The player may have spent coins, so refresh the balance as soon as the view closes, and again when the game regains focus.

Hiding the platform's bottom bar (optional)

The Baziness site has its own bottom navigation (فروشگاه / بازی‌ها / خانه / ماموریت‌ها / بازینس من). Inside your game you may want to hide it. The page belongs to another site, so your CSS and scripts can't reach into it. Instead:

  1. Make the web view taller than its container by the bar's height.
  2. Let the container clip what overflows.

The bar sits at the bottom of the page, so it falls outside the visible area while the rest of the page still scrolls. Tune the height on real phones. Code.

Builds without Baziness

A build that ships without Baziness, for example some store builds, must not show the button or advertise Gita coin rewards. Let your server decide: it answers { enabled: false } for that build, and the client never draws any of it.

Checklist

  • The button shows the live balance (… while loading or on failure).
  • It opens /shop with game_name, packageName, sdkType=js, linkedToken and redirect=shop.
  • No allowGuest=false, and not the site root.
  • The balance refreshes when the view closes and when the game regains focus.
  • With Baziness off, the button is hidden or explains why it isn't available.
Guides

Tutorial offer page (game-offer)

A showcase page made for your game, opened once during the tutorial, that shows new players what Gita coins can buy and offers them a free starter pack.

What it is

Every game can have an offer page on Baziness, made for that game and selected by its game_name:

Text
{web}/game-offer?game_name=<your game>

It shows the prizes Gita coins can buy, and a special free pack for your game. For example, the example game's page shows «بسته ویژه بازی example — رایگان» with a «خرید رایگان» button. It is a showcase: you open it once, during the tutorial, to set expectations. After the tutorial, your reward button opens the real reward shop.

Note

Ask the platform team to set up the offer page for your game, and use the same game_name as your reward page.

The address

Environment Address
Development https://platform-web-dev.karizmastudios.org/game-offer?game_name=<your game>
Production https://baziness.com/game-offer?game_name=<your game>

Like every Baziness page, it needs packageName and sdkType=js, plus linkedToken and allowGuest=false (parameters).

Warning

Without sdkType=js the offer page never sends its close message, so its button can't close your view.

The tutorial flow

flowchart TD
  A["Tutorial lesson finished"] --> B["Lobby: coach mark on «انتخاب جایزه»<br/>(everything else dimmed)"]
  B -->|tap| C{"platform enabled<br/>and sheet available?"}
  C -- yes --> D["open game-offer page in the sheet<br/>stage = 'pack'"]
  D -->|"close button · swipe down · tap outside"| E["sheet closed"]
  C -- no --> E
  E --> F["Starter pack popup («خرید موفق»)"]
  F --> G["Onboarding done →<br/>the banner now opens /shop"]

Rules

  1. Save the tutorial step before opening the page. If the player reloads while the page is open, they continue with the starter pack and don't see the offer twice.
  2. Continue however the page was closed: its own button, a swipe down, or a tap outside.
  3. Never block the tutorial on Baziness. If it is off or unreachable, skip straight to the starter pack.
  4. Give the starter pack from the server, once per player. Present it as a successful purchase («خرید موفق»), even though it is free.
  5. Players arriving from the Baziness app get the tutorial too. Signing in doesn't make a player "old".
  6. After the tutorial, open /shop, never the offer page again.

Test it

  1. Clear the site data (or use a private window) and open your game.
  2. Choose either option on the splash, and finish the tutorial's first lesson.
  3. Only the reward button is highlighted. Tap it: the offer page slides up.
  4. Close it each way in turn (its button, a swipe on the handle, a tap outside). Every way must bring the starter pack.
  5. Tap the reward button again: now the real /shop opens.

Ready-made code: client. See it live in the example game.

Guides

Migrating to link codes & short-lived tokens

The platform is replacing long-lived user tokens with 15-minute tokens and one-time link codes. What changes for your server and your client, step by step, and by when.

What is changing and why

Until now, a Baziness user token was valid for 10 years. It also travelled in addresses:

  • your game opened Baziness pages with ?linkedToken=<token>;
  • the Baziness app opened your game with ?linkedToken=<token>.

Addresses end up in server logs, analytics, browser history and Referer headers. A token found there gave access to the player's coins and account for a decade, and signing out couldn't stop it.

The new model:

Before Now
Access token (what your client sends as Bearer) valid 10 years valid 15 minutes
Refresh token — renews the access token; kept on your server, never on the device; single use, so every renewal returns a new one
Moving a player between apps ?linkedToken=<token> ?linkCode=<code>: a one-time code, valid about 2 minutes, useless after first use

Important

Both models work side by side during the migration. The old endpoints and ?linkedToken= keep working, and the Baziness app sends both ?linkCode= and ?linkedToken= to games. The platform team will announce a cut-off date: after it, old tokens and linkedToken stop working. Plan the migration before then.

The flow after the migration:

sequenceDiagram
  autonumber
  participant C as Game client
  participant S as Game server
  participant K as Platform API
  participant W as Baziness page (sheet)
  C->>S: POST /api/platform/token { deviceId, linkCode? }
  alt first time, or arrived with ?linkCode=
    S->>K: POST v2/client/create-user { deviceId, linkCode }
  else known player
    S->>K: POST auth/refresh { stored refreshToken }
  end
  K-->>S: { userId, isLinkedToAccount, accessToken (15 min), refreshToken }
  Note over S: save the NEW refreshToken on the player
  S-->>C: { token: accessToken, tokenExpiresAt, bazinessUserId, isLinked }
  C->>S: POST /api/platform/link-code (before opening a page)
  S->>K: POST v2/client/link-code/{userId}
  K-->>S: { code } (one use, ~2 min)
  S-->>C: { linkCode }
  C->>W: open page (sdkType=js, linkCode, …)
  W-->>C: postMessage { type: "login", data: "<token>" }
  C->>S: POST /api/platform/login { deviceId, token }
  S->>K: POST client/validate-user-token { token }
  S-->>C: { ok } — or { ok, switchToDeviceId }

What changes at a glance

Today After the migration Where
client/create-user with linkedToken v2/client/create-user with linkCode; save tokens.refreshToken server
client/get-user-token/{id} on every boot auth/refresh with the saved refresh token; v2/client/get-user-token/{id} only when there is no session server
open pages with linkedToken=<token> open pages with linkCode=<code>, a new code from v2/client/link-code/{id} for each page server + client
the Baziness app opens your game with ?linkedToken= ?linkCode=: forward it to your server like the token before client
GET client/validate-user-token/{token} POST client/validate-user-token { token } (the token is no longer in the address) server
the client keeps its token for years the client keeps the access token only; when it expires, it asks your server again client

Nothing else changes: the page messages (login, token, close, logout), coins, rewards and payments work as before.

Server, step by step

1. Keep a refresh token per player

Add a column to your player record, for example baziness_refresh_token. Treat it like a password:

  • never log it;
  • never send it to the client;
  • encrypt it if you encrypt other secrets.

Always overwrite it with the newest one. Every create-user, get-user-token and refresh returns a new refresh token, and the previous one stops working.

2. /api/platform/token: refresh instead of get-user-token

This is the endpoint your client calls on boot. The client now sends linkCode instead of linkedToken.

  1. The client sent a linkCode, or the player has no Baziness user yet: call v2/client/create-user with deviceId and linkCode. Then handle the returned userId as you do today:
    • The userId belongs to another of your players → answer switchToDeviceId (same person, another device).
    • The player already has a different userId → a different Baziness account arrived. Start it as its own player and answer switchToDeviceId (the old "clear local data and start again" rule).
    • Otherwise → save userId and the refresh token on this player.
    • If the code was expired or already used (400 invalid_link_code), continue as if no code was sent.
  2. A known player: call auth/refresh with the saved refresh token, and save the new one. The answer also contains isLinkedToAccount, so you still learn when the player signed in.
    • 401 refresh_token_rotated: another request of the same player refreshed a moment earlier. Reload the refresh token from your database and try once more.
    • Any other 401 (session_revoked, refresh_token_expired, invalid_refresh_token, refresh_token_reused): the session is over. Call v2/client/get-user-token/{userId} for a new one and save its refresh token.
    • 403 account_suspended: the account is blocked.
  3. Answer the client with the access token and its expiry: { token, tokenExpiresAt, bazinessUserId, isLinkedToAccount }. Never include the refresh token.

Tip

Players you already have don't hold a refresh token yet. Step 2 handles them on its own: no saved refresh token → v2/client/get-user-token once, and from then on auth/refresh.

When the client is about to open a Baziness page, it asks your server for a code. Your server calls v2/client/link-code/{userId} and returns { linkCode }.

  • A code works once and for about two minutes: get a new one for every page you open.
  • It only works for Baziness pages. Even if it leaks, nobody can use it to log in to your game.
  • Protect the endpoint like your other player endpoints: only the player themself may ask for their code.

4. /api/platform/login: verify with POST

Nothing changes in the logic. Only the call does: POST client/validate-user-token with { token } in the body.

The token in the login message is now a 15-minute token. Verify it right away, as you already do.

If the token belongs to a different Baziness user than the player's current one, clear the player's saved refresh token; the next /token call starts a new session.

5. New /api/platform/logout

When a page sends logout, the client calls this before starting over. Your server ends the session (auth/logout) and clears the saved refresh token.

Server code

Additions to your platform client (next to createUser / getUserAccessToken):

// platform/baziness.ts — ADD these next to createUser / getUserAccessToken (they reuse API_BASE and platformFetch).

/** A player's session: the access token goes to your client, the refresh token stays in your database. */
export interface PlatformSession {
  userId: number;
  isLinkedToAccount: boolean;
  accessToken: string;
  accessTokenExpiresAt: string;
  refreshToken: string;
}

const readSession = (j: Record<string, any>): PlatformSession => {
  const t = j.tokens ?? j; // v2 create-user / get-user-token nest the pair in `tokens`; auth/refresh returns it flat
  return {
    userId: Number(j.userId),
    isLinkedToAccount: Boolean(j.isLinkedToAccount),
    accessToken: String(t.accessToken),
    accessTokenExpiresAt: String(t.accessTokenExpiresAt),
    refreshToken: String(t.refreshToken),
  };
};

/** First time, or when the client brought a ?linkCode=. Throws (status 400) for an expired or used code. */
export const createUserSession = async (deviceId: string, platform: string, store: string, linkCode: string | null) =>
  readSession(await platformFetch("POST", "/v2/client/create-user", { deviceId, platform, store, linkCode }));

/** A new session for a player you know — only when you have no refresh token or the session ended. */
export const startUserSession = async (bazinessUserId: number) =>
  readSession(await platformFetch("POST", `/v2/client/get-user-token/${bazinessUserId}`));

/**
 * Renews a session. Returns the new session, "rotated" when a parallel request refreshed first
 * (reload the stored token and retry once), or null when the session is over (start a new one).
 */
export async function refreshUserSession(refreshToken: string): Promise<PlatformSession | "rotated" | null> {
  const res = await fetch(`${API_BASE}/auth/refresh`, {   // no client token: the refresh token is the credential
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ refreshToken }),
  });
  if (res.ok) return readSession(await res.json());
  const error = ((await res.json().catch(() => ({}))) as { error?: string }).error;
  if (res.status === 401 && error === "refresh_token_rotated") return "rotated";
  if (res.status === 403) throw Object.assign(new Error("account_suspended"), { status: 403 });
  if (res.status === 401) return null;
  throw new Error(`auth/refresh failed (${res.status})`); // 5xx / network: keep the old session, try later
}

/** Ends a session (player signed out). Never throws. */
export async function endUserSession(refreshToken: string): Promise<void> {
  await fetch(`${API_BASE}/auth/logout`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ refreshToken }),
  }).catch(() => {});
}

/** One-time code for opening ONE Baziness page as this player (~2 minutes). */
export const createPageLinkCode = async (bazinessUserId: number) =>
  String((await platformFetch("POST", `/v2/client/link-code/${bazinessUserId}`)).code);

/** REPLACES the old GET version: the token now goes in the body, not in the URL. */
export async function validateUserAccessToken(token: string): Promise<number> {
  const j = await platformFetch("POST", "/client/validate-user-token", { token });
  if (!(j.isValid ?? j.IsValid)) throw new Error("invalid user token");
  return Number(j.userId ?? j.UserId ?? 0);
}

The four endpoints:

import {
  createUserSession, startUserSession, refreshUserSession, endUserSession, createPageLinkCode,
  validateUserAccessToken, platformClientConfig, platformEnabled, type PlatformSession,
} from "./platform/baziness";
import { db } from "./db"; // your own data layer. New column on the player: baziness_refresh_token (a secret)

// What the client gets. The refresh token never leaves your server.
const forClient = (s: PlatformSession) => ({
  token: s.accessToken, tokenExpiresAt: s.accessTokenExpiresAt,
  bazinessUserId: s.userId, isLinkedToAccount: s.isLinkedToAccount,
});

async function keep(playerId: number, s: PlatformSession) {
  await db.setRefreshToken(playerId, s.refreshToken); // always the NEWEST one: the old one stops working
  return s;
}

/** A valid session for a player who already has a Baziness user. */
async function sessionFor(player: { id: number; bazinessUserId: number; bazinessRefreshToken: string | null }) {
  if (player.bazinessRefreshToken) {
    let r = await refreshUserSession(player.bazinessRefreshToken);
    if (r === "rotated") {                       // another request of this player refreshed a moment ago
      const latest = await db.getRefreshToken(player.id);
      r = latest ? await refreshUserSession(latest) : null;
      if (r === "rotated") r = null;
    }
    if (r) return keep(player.id, r);
  }
  // No refresh token yet (players from before the migration) or the session ended.
  return keep(player.id, await startUserSession(player.bazinessUserId));
}

app.post("/api/platform/token", async (req, res) => {
  const deviceId = String(req.body.deviceId ?? "").trim();
  const linkCode: string | null = req.body.linkCode ?? null;           // replaces linkedToken
  if (!deviceId) return res.status(400).json({ error: "deviceId required" });
  if (!platformEnabled()) return res.json({ enabled: false });

  const config = { enabled: true, ...platformClientConfig() };
  const me = await db.ensureUserByDevice(deviceId, { utm: req.body.utm, tag: req.body.tag });
  try {
    if (linkCode || !me.bazinessUserId) {
      // First time, or arrived from the Baziness app with ?linkCode=: which Baziness user is this?
      const s = await createUserSession(deviceId, "Web", "Direct", linkCode)
        .catch((e) => (linkCode && e.status === 400 ? null : Promise.reject(e))); // expired or used code
      if (s) {
        const owner = await db.findUserByBazinessId(s.userId);
        if (owner && owner.id !== me.id) {                  // the same person, another player of yours
          await keep(owner.id, s);
          return res.json({ ...config, switchToDeviceId: owner.deviceId });
        }
        if (me.bazinessUserId && me.bazinessUserId !== s.userId) {
          // A different Baziness account arrived on a device that already has a player:
          // it becomes its own player (the old "clear local data and start again" rule).
          const fresh = await db.createPlayerForBaziness(s.userId, s.isLinkedToAccount);
          await keep(fresh.id, s);
          return res.json({ ...config, switchToDeviceId: fresh.deviceId });
        }
        await db.linkBaziness(me.id, s.userId, s.isLinkedToAccount);
        await keep(me.id, s);
        return res.json({ ...config, ...forClient(s) });
      }
      if (!me.bazinessUserId) {                              // stale code and no Baziness user yet
        const s2 = await createUserSession(deviceId, "Web", "Direct", null);
        await db.linkBaziness(me.id, s2.userId, s2.isLinkedToAccount);
        await keep(me.id, s2);
        return res.json({ ...config, ...forClient(s2) });
      }
    }
    const s = await sessionFor(me);
    if (s.isLinkedToAccount !== me.isBazinessLinked) await db.setLinked(me.id, s.isLinkedToAccount);
    return res.json({ ...config, ...forClient(s) });
  } catch (err) {
    console.error("[platform] token failed:", (err as Error).message);
    const suspended = (err as { status?: number }).status === 403;
    return res.json({ ...config, error: suspended ? "account_suspended" : "platform_unavailable" }); // never block the boot
  }
});

// Protect it like your other player endpoints: only the player themself may ask for their code.
app.post("/api/platform/link-code", async (req, res) => {
  const me = await db.findUserByDevice(String(req.body.deviceId ?? ""));
  if (!me?.bazinessUserId) return res.status(409).json({ error: "call /api/platform/token first" });
  try {
    return res.json({ linkCode: await createPageLinkCode(me.bazinessUserId) });
  } catch {
    return res.status(503).json({ error: "platform_unavailable" });
  }
});

app.post("/api/platform/login", async (req, res) => {
  const deviceId = String(req.body.deviceId ?? "").trim();
  const token = String(req.body.token ?? "").trim();
  if (!deviceId || !token) return res.status(400).json({ error: "deviceId and token required" });
  if (!platformEnabled()) return res.json({ ok: false, reason: "disabled" });
  try {
    const bazinessUserId = await validateUserAccessToken(token);         // POST now; verify right away (15-min token)
    const owner = await db.findUserByBazinessId(bazinessUserId);
    const me = await db.ensureUserByDevice(deviceId);
    if (owner && owner.id !== me.id) return res.json({ ok: true, switchToDeviceId: owner.deviceId });
    if (me.bazinessUserId !== bazinessUserId) await db.setRefreshToken(me.id, null); // belonged to the old user
    await db.linkBaziness(me.id, bazinessUserId, true);
    return res.json({ ok: true });                                       // the client then calls /token again
  } catch {
    return res.json({ ok: false, reason: "invalid" });
  }
});

// The page sent `logout`: end the session before the client starts over as a new visitor.
app.post("/api/platform/logout", async (req, res) => {
  const me = await db.findUserByDevice(String(req.body.deviceId ?? ""));
  if (me?.bazinessRefreshToken) {
    await endUserSession(me.bazinessRefreshToken);
    await db.setRefreshToken(me.id, null);
  }
  return res.json({ ok: true });
});

Client, step by step

  1. On boot, send linkCode (from ?linkCode= in the address, or from your deep link) to /api/platform/token instead of linkedToken. Remove both linkCode and linkedToken from the address afterwards: a code works once.
  2. Stop storing the token for later boots (localStorage, PlayerPrefs). Your server keeps the session and gives you a fresh token on every /api/platform/token.
  3. Before using the token (user/get-coin), check tokenExpiresAt. If it expires within a minute, or a call answers 401, call /api/platform/token again.
  4. To open a Baziness page, get a code from /api/platform/link-code and put linkCode=<code> in the page address instead of linkedToken. All other parameters stay the same (pages).
  5. On the page's logout message, call /api/platform/logout, then start over as before.
// PlatformController — the parts that change.

// 1. Boot: forward the one-time ?linkCode= (it replaces ?linkedToken=). Don't keep the token in
//    localStorage any more: your server keeps the session and gives you a fresh token on demand.
async init(): Promise<boolean> {
  const params = new URLSearchParams(location.search);
  const linkCode = params.get("linkCode");
  const res = await fetch("/api/platform/token", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ deviceId: this.deviceId, linkCode }),
  });
  const data = await res.json();
  if (data.switchToDeviceId) return this.switchTo(data.switchToDeviceId); // unchanged
  this.token = data.token ?? null;
  this.tokenExpiresAt = data.tokenExpiresAt ? Date.parse(data.tokenExpiresAt) : 0;
  if (linkCode || params.has("linkedToken")) {                              // a code works once
    params.delete("linkCode"); params.delete("linkedToken");
    const rest = params.toString();
    history.replaceState(null, "", rest ? `${location.pathname}?${rest}` : location.pathname);
  }
  return !!this.token;
}

// 2. The token lives 15 minutes: ask your server again shortly before it expires, or after a 401.
async accessToken(): Promise<string | null> {
  if (!this.token || this.tokenExpiresAt - Date.now() < 60_000) await this.init();
  return this.token;
}

async getCoin(): Promise<number> {
  const call = async () => fetch(`${this.apiBase}/user/get-coin`, {
    headers: { Authorization: `Bearer ${await this.accessToken()}` },
  });
  let res = await call();
  if (res.status === 401) { this.token = null; res = await call(); }        // renew once, then give up
  return res.ok ? Number((await res.json()).amount ?? 0) : 0;
}

// 3. Pages: a fresh one-time code for every page you open, instead of the token.
async pageUrl(path: string, o: PageOptions = {}): Promise<string> {
  const res = await fetch("/api/platform/link-code", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ deviceId: this.deviceId }),
  });
  const { linkCode } = await res.json();
  const url = new URL(bazinessPageUrl(this.webBase, path, this.packageName, null, o)); // no token any more
  url.searchParams.set("linkCode", linkCode);
  return url.toString();
}

// 4. The page sent `logout`: tell your server first, then start over as before.
async onPageLogout() {
  await fetch("/api/platform/logout", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ deviceId: this.deviceId }),
  }).catch(() => {});
  this.resetAndRestart(); // unchanged: delete local data, new visitor
}

The new platform endpoints

All are under the usual {api} base. The client endpoints need your client token, as before.

Create a user, with a session

HTTP
POST {api}/v2/client/create-user
Authorization: Bearer <client token>
Content-Type: application/json

{ "deviceId": "8f1c2a…", "platform": "Android", "store": "CafeBazaar", "linkCode": null }

→ 200 OK
{
  "userId": 239236,
  "token": "eyJhbGciOiJIUzI1NiIs…",              // the access token (same as tokens.accessToken)
  "isLinkedToAccount": false,
  "tokens": {
    "accessToken": "eyJhbGciOiJIUzI1NiIs…",       // 15 minutes — the client uses this
    "accessTokenExpiresAt": "2026-10-22T10:15:00+00:00",
    "refreshToken": "rt1.dkUoqaDDG8YB15KAQNO1q77FE…", // keep on YOUR server only
    "refreshTokenExpiresAt": "2026-11-21T10:00:00+00:00"
  }
}

→ 400 { "success": false, "error": "invalid_link_code" }   // the code expired or was already used

Renew a session

No client token: the refresh token itself is the credential. Call it from your server.

HTTP
POST {api}/auth/refresh
Content-Type: application/json

{ "refreshToken": "rt1.dkUoqaDDG8YB15KAQNO1q77FE…" }

→ 200 OK
{
  "userId": 239236,
  "isLinkedToAccount": true,
  "accessToken": "eyJhbGciOiJIUzI1NiIs…",
  "accessTokenExpiresAt": "2026-10-22T10:30:00+00:00",
  "refreshToken": "rt1.Wn0QhZ…",                  // store THIS one — the old one no longer works
  "refreshTokenExpiresAt": "2026-11-21T10:15:00+00:00"
}

→ 401 { "success": false, "error": "refresh_token_rotated" }  // another request refreshed first: reload yours, retry once
→ 401 { "success": false, "error": "session_revoked" }        // also invalid_refresh_token, refresh_token_expired,
                                                               // refresh_token_reused: get a new session
→ 403 { "success": false, "error": "account_suspended" }

A new session for a known player

HTTP
POST {api}/v2/client/get-user-token/239236
Authorization: Bearer <client token>

→ 200 OK
{ "userId": 239236, "token": "eyJhbGciOiJIUzI1NiIs…", "isLinkedToAccount": true,
  "tokens": { "accessToken": "…", "accessTokenExpiresAt": "…", "refreshToken": "rt1.…", "refreshTokenExpiresAt": "…" } }

→ 403 { "success": false, "error": "access denied" }   // the user belongs to another game
HTTP
POST {api}/v2/client/link-code/239236
Authorization: Bearer <client token>

→ 200 OK
{ "code": "q3Jc9yX2tL0…", "expiresAt": "2026-10-22T10:02:00+00:00" }   // one page, once, ~2 minutes

→ 403 { "success": false, "error": "access denied" }   // the user belongs to another game

Verify a token (body instead of address)

HTTP
POST {api}/client/validate-user-token
Authorization: Bearer <client token>
Content-Type: application/json

{ "token": "eyJhbGciOiJIUzI1NiIs…" }

→ 200 OK
{ "userId": 239236, "isValid": true }

→ 200 OK   (invalid or expired: not an HTTP error)
{ "userId": 0, "isValid": false }

End a session

POST {api}/auth/logout with { "refreshToken": "rt1.…" }. It always answers 200.

Errors

Answer Where Meaning What to do
401 refresh_token_rotated auth/refresh a parallel request of this player refreshed first reload the saved refresh token, retry once
401 session_revoked, refresh_token_expired, invalid_refresh_token, refresh_token_reused auth/refresh the session is over v2/client/get-user-token/{id}, save the new refresh token
403 account_suspended auth/refresh the account is blocked show the blocked message
400 invalid_link_code v2/client/create-user the code expired, was used, or wasn't meant for your game continue without the code
403 v2/client/link-code/{id}, v2/client/get-user-token/{id} the user belongs to another game check the userId you saved
401 user/get-coin (client) the 15-minute token expired ask /api/platform/token again, retry once

Checklist

Test on the development platform (any phone number, code 111111):

  • A new player boots: the client gets a token; your database has a refresh token for them.
  • Boot again: auth/refresh is used (not get-user-token), and the stored refresh token changed.
  • Two boots of the same player at the same moment: both succeed (one through the rotated retry).
  • An existing player from before the migration boots once and gets a refresh token.
  • Every Baziness page opens with linkCode=… and no linkedToken; the sign-in, profile, reward shop and offer pages all work.
  • Signing in on a page with a phone that already belongs to another of your players still switches to that player.
  • Opening your game from the Baziness app with ?linkCode= lands in the right account; open the same link twice → the second time the code is ignored and the game still boots.
  • After 15 minutes of play, the coin balance still loads (token renewed through your server).
  • logout from a page ends the session; the next boot is a new visitor.
  • Your logs contain no refresh tokens, and no tokens in addresses.

Note

Until the cut-off date, the Baziness app also sends ?linkedToken=. Once your game reads linkCode, ignore linkedToken. After the cut-off it won't be sent any more, and old tokens stop working.

Code samples

Server code

Ready-made server code for every step — the platform client, your own endpoints, Gita coin payouts, Baziness Pay and campaign tags — in TypeScript, C# and Python.

These samples implement the API steps and guides. Each one comes in TypeScript (Node), C# (.NET) and Python; pick a tab, and the choice is remembered on every page. They use Express, ASP.NET minimal APIs and FastAPI, but the logic carries over to any framework. Where a sample calls db…, plug in your own database.

Settings

.env (server)
# --- Baziness platform: accounts, Gita coins, reward pages -------------------------------
BAZINESS_PLATFORM_ENABLED=false          # master switch: nothing shows until "true"
BAZINESS_PLATFORM_USE_PRODUCTION=false   # selects the default API + web URLs below
BAZINESS_CLIENT_ID=
BAZINESS_CLIENT_SECRET=
BAZINESS_PLATFORM_PACKAGE_NAME=com.yourstudio.yourgame
# Optional overrides (derived from USE_PRODUCTION when blank):
#   dev  api https://platform-dev.karizmastudios.org/api   web https://platform-web-dev.karizmastudios.org
#   prod api https://platform.karizmastudio.com/api        web https://baziness.com
BAZINESS_PLATFORM_API_URL=
BAZINESS_PLATFORM_WEB_URL=
BAZINESS_GAME_NAME=yourgame              # ?game_name= of your pages on the platform (rewards, game offer)
# Optional: the tutorial offer page (blank = {web}/game-offer?game_name=<BAZINESS_GAME_NAME>)
BAZINESS_GAME_OFFER_URL=

# --- Baziness Pay: real-money purchases ----------------------------------------------------
BAZINESS_PAY_ENABLED=false              # buy buttons stay disabled until "true"
#   dev  https://pay-dev.karizmastudios.org   prod  https://pay.baziness.com
BAZINESS_PAY_BASE_URL=https://pay-dev.karizmastudios.org
BAZINESS_PAY_PACKAGE_NAME=com.yourstudio.yourgame   # the Pay app's package (pname) — may differ from BAZINESS_PLATFORM_PACKAGE_NAME
BAZINESS_SKU_PREFIX=                    # optional prefix: SKU = prefix + product id
DEV_FAKE_BAZINESS_PAY=false             # local development only (see the Pay page)

The platform client

One small class that does steps 1–5: it gets the client token on first use, gets it again after a 401, and builds payout ids from a grant key.

// platform/baziness.ts — all credentials stay on the server.
import { createHash } from "node:crypto";

const DEV_API = "https://platform-dev.karizmastudios.org/api";
const PROD_API = "https://platform.karizmastudio.com/api";
const DEV_WEB = "https://platform-web-dev.karizmastudios.org";
const PROD_WEB = "https://baziness.com";

const env = process.env;
const ENABLED = env.BAZINESS_PLATFORM_ENABLED === "true";
const USE_PROD = env.BAZINESS_PLATFORM_USE_PRODUCTION === "true";
const CLIENT_ID = env.BAZINESS_CLIENT_ID ?? "";
const CLIENT_SECRET = env.BAZINESS_CLIENT_SECRET ?? "";
const PACKAGE_NAME = env.BAZINESS_PLATFORM_PACKAGE_NAME ?? "";
const trim = (u: string) => u.replace(/\/+$/, "");
export const API_BASE = trim(env.BAZINESS_PLATFORM_API_URL || (USE_PROD ? PROD_API : DEV_API));
export const WEB_BASE = trim(env.BAZINESS_PLATFORM_WEB_URL || (USE_PROD ? PROD_WEB : DEV_WEB));

export const platformEnabled = () => ENABLED && CLIENT_ID !== "" && CLIENT_SECRET !== "";

/** Public, non-secret config for the browser. */
export const platformClientConfig = () => ({ webBase: WEB_BASE, apiBase: API_BASE, packageName: PACKAGE_NAME });

export interface PlatformUser { userId: number; token: string; isLinkedToAccount: boolean }

let bearer: string | null = null;

async function authenticate(): Promise<void> {
  const res = await fetch(`${API_BASE}/client/auth`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ clientId: CLIENT_ID, clientSecret: CLIENT_SECRET }),
  });
  if (!res.ok) throw new Error(`client/auth failed (${res.status})`);
  const json = (await res.json()) as Record<string, unknown>;
  bearer = String(json.token ?? json.Token ?? "");
  if (!bearer) throw new Error("client/auth returned no token");
}

async function platformFetch(method: "GET" | "POST", path: string, body?: unknown) {
  if (!bearer) await authenticate();
  const send = () =>
    fetch(`${API_BASE}${path}`, {
      method,
      headers: { Authorization: `Bearer ${bearer}`, ...(body ? { "Content-Type": "application/json" } : {}) },
      body: body ? JSON.stringify(body) : undefined,
    });
  let res = await send();
  if (res.status === 401) { await authenticate(); res = await send(); } // bearer expired
  if (!res.ok) throw Object.assign(new Error(`${path} failed (${res.status})`), { status: res.status }); // 400 from add-coin = already paid
  return (await res.json()) as Record<string, unknown>;
}

const readUser = (j: Record<string, unknown>): PlatformUser => ({
  userId: Number(j.userId ?? j.UserId ?? 0),
  token: String(j.token ?? j.Token ?? ""),
  isLinkedToAccount: Boolean(j.isLinkedToAccount ?? j.IsLinkedToAccount ?? false),
});

export const createUser = async (deviceId: string, platform: string, store: string, linkedToken: string | null) =>
  readUser(await platformFetch("POST", "/client/create-user", { deviceId, platform, store, linkedToken }));

export const getUserAccessToken = async (bazinessUserId: number) =>
  readUser(await platformFetch("GET", `/client/get-user-token/${bazinessUserId}`));

export async function validateUserAccessToken(token: string): Promise<number> {
  const j = await platformFetch("GET", `/client/validate-user-token/${encodeURIComponent(token)}`);
  if (!(j.isValid ?? j.IsValid)) throw new Error("invalid user token");
  return Number(j.userId ?? j.UserId ?? 0);
}

/** Credit Gita coins. `grantKey` makes the transaction id deterministic: a repeat answers 400 (= already paid). */
export async function addCoin(bazinessUserId: number, amount: number, grantKey: string, metaData?: object) {
  const hash = createHash("sha1").update(grantKey).digest("hex").slice(0, 24);
  const j = await platformFetch("POST", "/client/add-coin", {
    userId: bazinessUserId, amount, transactionId: `${bazinessUserId}_${amount}_${hash}`, metaData: metaData ?? null,
  });
  return Number(j.amount ?? j.Amount ?? 0); // the player's new balance
}

Telling the client the settings

GET /api/platform/config: whether Baziness is on, and where its pages and API are. It has no side effects and never includes the secret.

import express from "express";
import { platformClientConfig, platformEnabled, WEB_BASE } from "./platform/baziness";

const GAME_NAME = process.env.BAZINESS_GAME_NAME ?? "example"; // your game's name on the platform
const GAME_OFFER_URL = process.env.BAZINESS_GAME_OFFER_URL || `${WEB_BASE}/game-offer?game_name=${encodeURIComponent(GAME_NAME)}`;

const app = express();
app.use(express.json());

// Side-effect free: tells the client whether Baziness is on and where its pages live. Never the secret.
app.get("/api/platform/config", (_req, res) => {
  res.json(platformEnabled()
    ? { enabled: true, ...platformClientConfig(), gameName: GAME_NAME, gameOfferUrl: GAME_OFFER_URL }
    : { enabled: false });
});

Login endpoints

POST /api/platform/token and POST /api/platform/login, as described in Login → what your server provides.

import { createUser, getUserAccessToken, platformClientConfig, platformEnabled, validateUserAccessToken } from "./platform/baziness";
import { db } from "./db"; // your own data layer

app.post("/api/platform/token", async (req, res) => {
  const deviceId = String(req.body.deviceId ?? "").trim();
  const linkedToken: string | null = req.body.linkedToken ?? null;
  if (!deviceId) return res.status(400).json({ error: "deviceId required" });
  if (!platformEnabled()) return res.json({ enabled: false });

  const config = { enabled: true, ...platformClientConfig() };
  // Pass first-touch attribution so a player who signs in before your normal login is still credited.
  const user = await db.ensureUserByDevice(deviceId, { utm: req.body.utm, tag: req.body.tag });
  try {
    if (!user.bazinessUserId) {
      const created = await createUser(deviceId, "Web", "Direct", linkedToken);
      const owner = await db.findUserByBazinessId(created.userId);
      if (owner && owner.id !== user.id) {
        return res.json({ ...config, token: created.token, bazinessUserId: created.userId,
          isLinkedToAccount: created.isLinkedToAccount, switchToDeviceId: owner.deviceId });
      }
      await db.linkBaziness(user.id, created.userId, created.isLinkedToAccount);
      return res.json({ ...config, token: created.token, bazinessUserId: created.userId, isLinkedToAccount: created.isLinkedToAccount });
    }
    const fresh = await getUserAccessToken(user.bazinessUserId);
    if (fresh.isLinkedToAccount !== user.isBazinessLinked) await db.setLinked(user.id, fresh.isLinkedToAccount);
    return res.json({ ...config, token: fresh.token, bazinessUserId: fresh.userId, isLinkedToAccount: fresh.isLinkedToAccount });
  } catch (err) {
    console.error("[platform] token failed:", (err as Error).message);
    return res.json({ ...config, error: "platform_unavailable" }); // never block the boot
  }
});

app.post("/api/platform/login", async (req, res) => {
  const deviceId = String(req.body.deviceId ?? "").trim();
  const token = String(req.body.token ?? "").trim();
  if (!deviceId || !token) return res.status(400).json({ error: "deviceId and token required" });
  if (!platformEnabled()) return res.json({ ok: false, reason: "disabled" });
  try {
    const bazinessUserId = await validateUserAccessToken(token); // the server-side gate
    const owner = await db.findUserByBazinessId(bazinessUserId);
    const me = await db.ensureUserByDevice(deviceId);
    if (owner && owner.id !== me.id) return res.json({ ok: true, switchToDeviceId: owner.deviceId });
    await db.linkBaziness(me.id, bazinessUserId, true);
    return res.json({ ok: true });
  } catch {
    return res.json({ ok: false, reason: "invalid" });
  }
});

Giving Gita coins

A payout that can never be paid twice: the grant key fixes the transactionId, and a 400 means it was already paid.

// grants.ts — a payout that can never be paid twice.
import { addCoin } from "./platform/baziness";

export async function payTournamentReward(claimId: number, bazinessUserId: number, amount: number) {
  try {
    // Same claim → same key → same transactionId → the platform refuses a second credit.
    await addCoin(bazinessUserId, amount, `reward_claim:${claimId}`, { source: "tournament", claimId });
  } catch (err) {
    // 400 = this transactionId was already credited (e.g. we crashed after the platform paid but
    // before we saved): the coins are there, so carry on instead of failing forever.
    if ((err as { status?: number }).status !== 400) throw err;
  }
  await db.claims.markPaid(claimId);                       // your own bookkeeping
  await db.ledger.insert({ claimId, currency: "GITA_COIN", amount, external: true });
}

Claims, held coins and moving them on sign-in

The rules from Gita coins → players who haven't signed in: a claim requires a signed-in player, a paid purchase never fails, and held coins move to Baziness when the player signs in.

// grants.ts: Gita coins go to the platform; COIN/GEM stay local.
export async function grantCurrency(tx: Tx, change: WalletChange & { idempotencyKey: string },
                                    opts: { requireLink: boolean; metaData?: object }) {
  if (change.currency !== "GITA_COIN" || !platformEnabled() || change.amount < 0) {
    return applyWalletChange(tx, change);                      // local wallet
  }
  const { baziness_user_id: bazinessUserId } = await tx.one("select baziness_user_id from users where id = $1", [change.userId]);
  if (!bazinessUserId) {
    if (opts.requireLink) throw new ApiError("PLATFORM_NOT_LINKED", "برای دریافت گیتا کوین وارد حساب بازینس شوید");
    return applyWalletChange(tx, { ...change, note: "held until a Baziness account is linked" }); // escrow
  }
  try {
    await addCoin(bazinessUserId, change.amount, change.idempotencyKey, { source: change.type, ...opts.metaData });
  } catch (err) {
    throw new ApiError("PLATFORM_UNAVAILABLE", "ارتباط با بازینس برقرار نشد، دوباره تلاش کنید");
  }
  await recordExternalCredit(tx, change);                      // ledger row, balance unchanged locally
}

/** Called right after a successful link (POST /api/platform/login or /token). */
export async function flushGitaEscrow(userId: number) {
  await withTx(async (tx) => {
    const user = await tx.one("select baziness_user_id from users where id = $1", [userId]);
    const wallet = await tx.oneOrNone(
      "select balance from user_wallets where user_id = $1 and currency = 'GITA_COIN' for update", [userId]);
    if (!user.baziness_user_id || !wallet || wallet.balance <= 0) return;
    const last = await tx.one("select max(id) as id from wallet_transactions where user_id = $1 and currency = 'GITA_COIN'", [userId]);
    const key = `escrow:${userId}:${last.id ?? 0}`;
    await addCoin(user.baziness_user_id, wallet.balance, key, { source: "escrow" });
    await applyWalletChange(tx, { userId, currency: "GITA_COIN", amount: -wallet.balance,
      type: "REWARD_SELECTION", idempotencyKey: key, note: "moved to the Baziness account" });
  });
}

Baziness Pay client

Steps 1–5 of Baziness Pay: the payment page URL, get_payment, verify_payment (a form body) and consume.

// platform/baziness-pay.ts
const BASE = (process.env.BAZINESS_PAY_BASE_URL ?? "https://pay.baziness.com").replace(/\/+$/, "");
const PNAME = process.env.BAZINESS_PAY_PACKAGE_NAME ?? "";
const PREFIX = process.env.BAZINESS_SKU_PREFIX ?? "";
export const payEnabled = () => process.env.BAZINESS_PAY_ENABLED === "true";

export const skuFor = (productSku: string, override?: string | null) => override || `${PREFIX}${productSku}`;

export const buildBuyUrl = (uid: string, sku: string) =>
  `${BASE}/buy?${new URLSearchParams({ sku, uid, pname: PNAME })}`;

/** The verification token of a completed, unconsumed payment — or null while unpaid. */
export async function getPayment(uid: string, sku: string): Promise<{ token: string; sku: string } | null> {
  try {
    const res = await fetch(`${BASE}/get_payment`, {
      method: "POST", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ uid, sku, pname: PNAME }),
    });
    if (!res.ok) return null;
    const j = (await res.json()) as { success?: boolean; token?: string; sku?: string };
    return j.success && j.token ? { token: j.token, sku: j.sku ?? sku } : null;
  } catch { return null; }
}

/** The authoritative check. Never trust a token from the client. */
export async function verifyPayment(uid: string, token: string, sku: string): Promise<boolean> {
  try {
    const res = await fetch(`${BASE}/verify_payment`, {
      method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" },   // form, not JSON
      body: new URLSearchParams({ uid, token, sku, pname: PNAME }).toString(),
    });
    if (!res.ok) return false;
    const j = (await res.json()) as { success?: boolean; error?: string };
    if (!j.success && j.error) console.warn("[pay] verify_payment rejected:", j.error);
    return j.success === true;
  } catch { return false; }
}

/** Let the SKU be bought again. Best effort, 3 attempts; never blocks an already-granted purchase. */
export async function consumePayment(uid: string, sku: string): Promise<boolean> {
  for (let attempt = 1; attempt <= 3; attempt++) {
    try {
      const res = await fetch(`${BASE}/consume`, {
        method: "POST", headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ uid, sku, pname: PNAME }),
      });
      if (res.ok && ((await res.json()) as { success?: boolean }).success) return true;
    } catch { /* retry */ }
  }
  console.error("[pay] consume failed uid=%s sku=%s", uid, sku);
  return false;
}

The payments table

Schema (PostgreSQL)
create table payments (
  id                       bigserial primary key,
  user_id                  bigint not null references users(id),
  shop_product_id          bigint not null references shop_products(id),
  provider                 text not null default 'BAZINESS',
  provider_sku             text not null,
  -- The Baziness payment token; unique so the same payment can never be fulfilled twice.
  provider_transaction_id  text,
  amount                   bigint not null,
  currency_code            text not null,
  status                   text not null default 'CREATED' check (status in
                             ('CREATED','REDIRECTED','PAID','VERIFIED','FAILED','CANCELLED','FULFILLED')),
  reward_snapshot          jsonb not null,          -- what the player was promised when they clicked
  failure_reason           text,
  created_at               timestamptz not null default now(),
  redirected_at            timestamptz,
  verified_at              timestamptz,
  fulfilled_at             timestamptz,
  updated_at               timestamptz not null default now(),
  unique (provider, provider_transaction_id)
);
create index payments_user on payments(user_id, created_at desc);

Purchase endpoints

POST /api/shop/purchase/start and POST /api/shop/purchase/verify: save the order, then verify and give the item exactly once.

// services/shop.ts
export async function startPurchase(userId: number, productId: number) {
  if (!payEnabled()) throw new ApiError("PAYMENT_DISABLED", "پرداخت به‌زودی فعال می‌شود");
  const product = await db.one("select * from shop_products where id = $1 and enabled", [productId]);
  const sku = skuFor(product.sku, product.baziness_product_id);
  const payment = await db.one(
    `insert into payments (user_id, shop_product_id, provider_sku, amount, currency_code, status, reward_snapshot, redirected_at)
     values ($1, $2, $3, $4, $5, 'REDIRECTED', $6, now()) returning id`,
    [userId, product.id, sku, product.price, product.currency_code, JSON.stringify(productGrants(product))]);
  return { paymentId: String(payment.id), url: buildBuyUrl(String(userId), sku) };
}

export async function verifyPurchase(userId: number, paymentId: number) {
  const p = await db.oneOrNone("select * from payments where id = $1", [paymentId]);
  if (!p || p.user_id !== userId) throw notFound("payment");
  if (p.status === "FULFILLED" || p.status === "FAILED" || p.status === "CANCELLED") {
    return { status: p.status, wallets: await getBalances(userId), granted: [] };
  }
  const uid = String(userId);
  const found = await getPayment(uid, p.provider_sku);
  if (!found) return { status: p.status, wallets: await getBalances(userId), granted: [] };   // not paid yet
  if (!(await verifyPayment(uid, found.token, p.provider_sku))) {
    await db.none("update payments set failure_reason = 'verify_payment rejected' where id = $1 and status <> 'FULFILLED'", [paymentId]);
    throw new ApiError("PAYMENT_FAILED", "پرداخت تأیید نشد");
  }
  const granted = await fulfilPayment(paymentId, found.token);
  void consumePayment(uid, p.provider_sku);                       // after commit, best effort
  return { status: "FULFILLED", wallets: await getBalances(userId), granted };
}

export function fulfilPayment(paymentId: number, providerToken: string) {
  return withTx(async (tx) => {
    const p = await tx.one("select * from payments where id = $1 for update", [paymentId]);
    if (p.status === "FULFILLED") return [];
    const dup = await tx.oneOrNone(
      "select id from payments where provider = 'BAZINESS' and provider_transaction_id = $1 and id <> $2", [providerToken, paymentId]);
    if (dup) {
      await tx.none("update payments set status = 'FAILED', failure_reason = 'duplicate provider token' where id = $1", [paymentId]);
      return [];
    }
    await tx.none("update payments set status = 'VERIFIED', provider_transaction_id = $2, verified_at = now() where id = $1",
      [paymentId, providerToken]);
    for (const g of p.reward_snapshot) {
      await grantCurrency(tx, { userId: p.user_id, currency: g.currency, amount: g.amount, type: "SHOP_PURCHASE",
        idempotencyKey: `payment:${paymentId}:${g.currency}` }, { requireLink: false }); // Gita coins: escrow if not linked
    }
    await tx.none("update payments set status = 'FULFILLED', fulfilled_at = now() where id = $1", [paymentId]);
    return p.reward_snapshot;
  });
}

Local fake gateway

The example game includes a stand-in for Baziness Pay that speaks the same protocol, for working without gateway access. It is switched on with these settings, and never in production:

.env (local only)
DEV_FAKE_BAZINESS_PAY=true
BAZINESS_PAY_ENABLED=true
BAZINESS_PAY_BASE_URL=http://localhost:3390/dev/baziness

First-touch attribution

The rules from Links & UTM. The first tab is the client side, reading the tags and removing them from the address. The others save them on the server when the player is created.

// api/client.ts — the FIRST module the app imports.
const UTM_KEY = "mygame_utm";
const TAG_KEY = "mygame_tag";
const UTM_FIELDS = ["source", "medium", "campaign", "content"] as const;
type Utm = Partial<Record<(typeof UTM_FIELDS)[number], string>>;

const clean = (raw: unknown): string | null => {
  if (typeof raw !== "string") return null;
  const v = raw.trim().toLowerCase().replace(/\s+/g, "_").slice(0, 64);
  return /^[a-z0-9][a-z0-9._-]*$/.test(v) ? v : null;      // ASCII only
};

export const FIRST_TOUCH_UTM: Utm = (() => {
  let stored: Utm = {};
  try { stored = JSON.parse(localStorage.getItem(UTM_KEY) ?? "{}"); } catch {}
  try {
    const query = new URLSearchParams(location.search);
    const keys = [...query.keys()].filter((k) => k.toLowerCase().startsWith("utm_"));
    const fromUrl: Utm = {};
    for (const k of keys) {
      const field = k.toLowerCase().slice(4) as keyof Utm;
      const v = clean(query.get(k));
      if (UTM_FIELDS.includes(field as never) && v) fromUrl[field] = v;
    }
    if (keys.length) {                                      // take them out of the address
      keys.forEach((k) => query.delete(k));
      const search = query.toString();
      history.replaceState(history.state, "", `${location.pathname}${search ? `?${search}` : ""}${location.hash}`);
    }
    if (Object.keys(fromUrl).length && !Object.keys(stored).length) {   // first touch wins
      stored = fromUrl;
      localStorage.setItem(UTM_KEY, JSON.stringify(stored));
    }
  } catch { /* storage blocked: the visit is simply untagged */ }
  return stored;
})();

export function firstTouchTag(): string | null {
  try {
    const fromUrl = clean(new URLSearchParams(location.search).get("tag"));
    if (fromUrl && !localStorage.getItem(TAG_KEY)) localStorage.setItem(TAG_KEY, fromUrl);
    return localStorage.getItem(TAG_KEY) ?? fromUrl;
  } catch { return null; }
}

// Sent with the device login AND with /api/platform/token:
//   { deviceId, tag: firstTouchTag(), utm: FIRST_TOUCH_UTM }
Schema (PostgreSQL)
alter table users
  add column if not exists acq_tag      text,
  add column if not exists utm_source   text,
  add column if not exists utm_medium   text,
  add column if not exists utm_campaign text,
  add column if not exists utm_content  text;

create index if not exists idx_users_utm_combo on users (utm_source, utm_medium, utm_campaign, utm_content)
  where utm_source is not null or utm_medium is not null or utm_campaign is not null or utm_content is not null;

Baziness page links (parameters):

type PageOptions = { redirect?: "shop" | "profile"; allowGuest?: boolean; gameName?: string; buildStore?: string };

export function bazinessPageUrl(webBase: string, path: string, packageName: string, token: string | null, o: PageOptions = {}) {
  const u = new URL(webBase.replace(/\/+$/, "") + (path || "/"));
  if (o.gameName) u.searchParams.set("game_name", o.gameName);
  u.searchParams.set("packageName", packageName);
  u.searchParams.set("sdkType", "js");                      // never optional
  if (token) u.searchParams.set("linkedToken", token);
  if (o.redirect) u.searchParams.set("redirect", o.redirect);
  if (o.allowGuest === false) u.searchParams.set("allowGuest", "false");
  if (o.buildStore) u.searchParams.set("buildStore", o.buildStore);
  return u.toString();
}

// bazinessPageUrl(webBase, "/shop", pkg, token, { gameName: "example", redirect: "shop" })
// bazinessPageUrl(webBase, "/", pkg, token, { allowGuest: false })

Campaign links for marketing:

export function campaignLink(gameUrl: string, utm: { source: string; medium: string; campaign: string; content?: string }) {
  const u = new URL(gameUrl);
  u.searchParams.set("utm_source", utm.source);
  u.searchParams.set("utm_medium", utm.medium);
  u.searchParams.set("utm_campaign", utm.campaign);
  if (utm.content) u.searchParams.set("utm_content", utm.content);
  return u.toString();
}
// campaignLink("https://yourgame.example.com/", { source: "baziness", medium: "inapp", campaign: "shahrivar_1405", content: "banner_home" })
Code samples

Client code

Ready-made client code — the platform controller, page messages, the coin balance, the reward button, the tutorial offer page and the purchase window — for the web, React, Cocos Creator and Unity.

These samples are the client side of the guides. Most come as TypeScript (web), React and C# (Unity) tabs, and there's a complete Cocos Creator version at the end. The in-game web view (the bottom sheet) has its own page.

Platform controller

The heart of the client: it asks your server for the player's token, builds every page link, opens pages, and runs the first-launch choice on the splash (Login).

// baziness.ts — the client controller.
const TOKEN_KEY = "mygame_baziness_token";
const CHOICE_KEY = "mygame_platform_choice";

export class BazinessPlatform {
  enabled = false;
  isLinked = false;
  private token: string | null = null;
  private webBase = "";
  private apiBase = "";
  private packageName = "";
  private loginGate: ((ok: boolean) => void) | null = null;
  private loginSucceeded = false;
  onChange: (() => void) | null = null;

  constructor(private sheet: BazinessSheet, private deviceId: string) {
    sheet.onMessage = (m) => this.handleMessage(m);
    sheet.onClosed = () => this.handleClosed();
  }

  get choiceMade() { return localStorage.getItem(CHOICE_KEY) === "1"; }
  markChoiceMade() { localStorage.setItem(CHOICE_KEY, "1"); }

  async loadConfig(): Promise<boolean> {
    try {
      const cfg = await fetch("/api/platform/config").then((r) => r.json());
      this.enabled = cfg.enabled;
      if (cfg.enabled) this.apply(cfg);
    } catch { this.enabled = false; }
    return this.enabled;
  }

  /** Resolve the token. Returns false when the page is about to reload into another account. */
  async init(): Promise<boolean> {
    // The Baziness app opens games with ?linkedToken=… — forward it once, then it is stripped on reload.
    const linkedToken = new URLSearchParams(location.search).get("linkedToken") || localStorage.getItem(TOKEN_KEY);
    const resp = await fetch("/api/platform/token", {
      method: "POST", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ deviceId: this.deviceId, linkedToken }),
    }).then((r) => r.json()).catch(() => null);
    if (!resp?.enabled) { this.enabled = false; return true; }
    this.enabled = true;
    this.apply(resp);
    if (resp.switchToDeviceId && resp.switchToDeviceId !== this.deviceId) {
      this.switchAccount(resp.switchToDeviceId);
      return false;
    }
    if (resp.token) {
      this.token = resp.token;
      this.isLinked = !!resp.isLinkedToAccount;
      localStorage.setItem(TOKEN_KEY, resp.token);
    }
    this.onChange?.();
    return true;
  }

  /** Splash "Sign in with Baziness": resolves true once a login/link succeeded, false if closed. */
  async openLoginAndWait(): Promise<boolean> {
    if (!this.token) await this.init(); // the anonymous user's token becomes linkedToken
    if (!this.enabled || !this.webBase) return false;
    return new Promise((resolve) => {
      this.loginGate = resolve;
      this.loginSucceeded = false;
      this.sheet.open(this.pageUrl("/", { allowGuest: false }));
    });
  }

  /** Settings → "Connect your account": the profile page. */
  openAccount() { this.sheet.open(this.pageUrl("/profile", { redirect: "profile", allowGuest: false })); }

  pageUrl(path: string, o: { redirect?: string; allowGuest?: boolean; extra?: Record<string, string> } = {}) {
    const u = new URL(this.webBase + path);
    for (const [k, v] of Object.entries(o.extra ?? {})) u.searchParams.set(k, v);
    u.searchParams.set("packageName", this.packageName);
    u.searchParams.set("sdkType", "js");
    if (this.token) u.searchParams.set("linkedToken", this.token);
    if (o.redirect) u.searchParams.set("redirect", o.redirect);
    if (o.allowGuest === false) u.searchParams.set("allowGuest", "false");
    return u.toString();
  }

  private apply(c: { webBase?: string; apiBase?: string; packageName?: string }) {
    this.webBase = c.webBase ?? ""; this.apiBase = c.apiBase ?? ""; this.packageName = c.packageName ?? "";
  }
  // handleMessage / handleClosed / switchAccount: see "Messages from the Baziness page" below.
}

Handling page messages

close, login, token and logout, as listed in Baziness pages → messages.

// Inside BazinessPlatform (continued).
const extractToken = (data: unknown): string | undefined =>
  typeof data === "string" ? data
  : data && typeof (data as { token?: unknown }).token === "string" ? (data as { token: string }).token
  : undefined;

handleMessage(msg: { type: string; data?: unknown }) {
  const token = extractToken(msg.data);
  switch (msg.type) {
    case "close":
      this.sheet.close();
      break;
    case "login":
      if (token) void this.applyNewLogin(token);   // signed into an account → switch or link
      else void this.linkedInPlace();              // linked onto the current player
      break;
    case "token":
      break;                                       // silent handshake — NOT a login
    case "logout":
      this.sheet.close();
      localStorage.clear();                        // identity, token, choice, tutorial flags…
      this.reloadClean();                          // next boot = brand-new visitor
      break;
  }
}

private async applyNewLogin(token: string) {
  const result = await fetch("/api/platform/login", {
    method: "POST", headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ deviceId: this.deviceId, token }),
  }).then((r) => r.json());
  if (result.switchToDeviceId && result.switchToDeviceId !== this.deviceId) {
    this.markChoiceMade();
    this.switchAccount(result.switchToDeviceId);   // reloads
    return;
  }
  if (result.ok) {
    localStorage.setItem(TOKEN_KEY, token);
    await this.init();                             // fresh token + linked flag
    this.loginSucceeded = true;
    this.markChoiceMade();
    this.sheet.close();                            // handleClosed resolves the login gate
  }
  // on failure keep the page open so the player can retry
}

private async linkedInPlace() {
  this.isLinked = true;
  this.loginSucceeded = true;
  this.markChoiceMade();
  if (this.loginGate) { await this.init(); this.sheet.close(); }
  this.onChange?.();
}

private handleClosed() {
  const gate = this.loginGate;
  this.loginGate = null;
  gate?.(this.loginSucceeded);                     // true only if a login actually happened
  this.onChange?.();                               // coin balance / link state may have changed
}

private switchAccount(deviceId: string) {
  setDeviceId(deviceId);                           // your own identity store
  localStorage.removeItem(TOKEN_KEY);
  this.reloadClean();
}

private reloadClean() {
  const url = new URL(location.href);
  url.searchParams.delete("linkedToken");          // never re-apply a stale token
  url.searchParams.delete("device");               // a ?device= test override would undo the switch
  location.replace(url.toString());
}

Showing the balance

// Inside BazinessPlatform: the player's Gita coin balance, straight from the platform. null = unknown.
async getCoin(): Promise<number | null> {
  if (!this.enabled || !this.token || !this.apiBase) return null;
  try {
    const res = await fetch(`${this.apiBase}/user/get-coin`, {
      headers: { Authorization: `Bearer ${this.token}`, "Content-Type": "application/json" },
    });
    if (!res.ok) return null;
    const json = (await res.json()) as { amount?: number };
    return typeof json.amount === "number" ? json.amount : null;
  } catch {
    return null;
  }
}

// Wiring: refresh on every relevant moment.
baziness.onChange = () => void baziness.getCoin().then(renderCoinChip);   // sheet closed, login, link
window.addEventListener("focus", () => void baziness.getCoin().then(renderCoinChip));

The reward button

// Inside BazinessPlatform. GAME_NAME comes from /api/platform/config (or a constant).
openRewardSelection(): void {
  if (!this.enabled || !this.webBase) return;
  this.sheet.open(
    this.pageUrl("/shop", { redirect: "shop", extra: { game_name: this.gameName } }),
    BAZINESS_BOTTOM_BAR_CROP_PX, // optional: hide the platform's own tab bar (see below)
  );
}

// The lobby banner
rewardBanner.onclick = () => {
  if (baziness.enabled) baziness.openRewardSelection();
  else toast("انتخاب جایزه بعد از اتصال به بازینس فعال می‌شود");
};

Hiding the bottom bar

The technique from Reward page: make the frame taller than the view, and let the view clip it.

TypeScript (web)
/** Pixels cropped off the bottom of the embedded page. 0 = show the platform's tab bar. */
export const BAZINESS_BOTTOM_BAR_CROP_PX = 0;

// in BazinessSheet.open(url, cropBottomPx):
this.frame.style.marginBottom = cropBottomPx > 0 ? `${-cropBottomPx}px` : "";

Tutorial offer page

Open the offer page once during the tutorial, and give the starter pack when it closes (Tutorial offer page).

// Inside BazinessPlatform.
gameOfferUrl = "";
private closeListeners = new Set<() => void>();

/** Run fn whenever the sheet closes; returns the unsubscribe function. */
onSheetClosed(fn: () => void) { this.closeListeners.add(fn); return () => this.closeListeners.delete(fn); }
// …and in handleClosed(): for (const fn of [...this.closeListeners]) fn();

get canOpenSheet() { return this.enabled && !!this.webBase; }

/** Onboarding only. Adds the SDK parameters to the server-provided URL. */
openGameOffer() {
  if (!this.gameOfferUrl) return;
  const u = new URL(this.gameOfferUrl);                 // already has ?game_name=
  u.searchParams.set("packageName", this.packageName);
  u.searchParams.set("sdkType", "js");                  // or its close button can't close the sheet
  if (this.token) u.searchParams.set("linkedToken", this.token);
  u.searchParams.set("allowGuest", "false");
  this.sheet.open(u.toString());
}

// Lobby: the coach-marked banner
function onRewardBannerTap() {
  if (onboarding.stage === "rewards") {
    onboarding.setStage("pack");                        // persist first: a reload resumes at the pack
    if (baziness.canOpenSheet && baziness.gameOfferUrl) {
      const off = baziness.onSheetClosed(() => { off(); showStarterPack(); });
      baziness.openGameOffer();
    } else {
      showStarterPack();                                // platform off: never block onboarding
    }
    return;
  }
  baziness.openRewardSelection();                        // after onboarding: the real shop
}

Purchase window

Open the tab inside the tap, start the purchase on your server, send the tab to the payment page, and verify when the player returns (Baziness Pay).

// purchase.ts — the purchase popup.
export function buy(productId: string, ui: PurchaseUi) {
  const tab = window.open("about:blank", "_blank");           // 1. synchronously, no "noopener"
  let paymentId: string | null = null;
  let started = false;
  let verifying = false;

  const verify = async (manual: boolean) => {
    if (!started || verifying || !paymentId) return;
    verifying = true;
    ui.showChecking();
    try {
      const r = await api("POST", "/api/shop/purchase/verify", { paymentId });
      if (r.status === "FULFILLED") { cleanup(); ui.success(r.granted); void refreshWallets(); return; }
      if (manual) ui.toast("پرداختی پیدا نشد. اگر پرداخت کرده‌ای چند لحظه بعد دوباره بررسی کن.");
      ui.showWaiting();
    } catch (err) {
      if (manual) ui.error(err);
      ui.showWaiting();
    } finally { verifying = false; }
  };

  const onFocus = () => void verify(false);
  const onVisible = () => document.visibilityState === "visible" && void verify(false);
  const cleanup = () => { removeEventListener("focus", onFocus); document.removeEventListener("visibilitychange", onVisible); };
  addEventListener("focus", onFocus);
  document.addEventListener("visibilitychange", onVisible);

  ui.showOpening(() => verify(true) /* «پرداخت کردم» */, () => { void verify(false); cleanup(); } /* close */);
  (async () => {
    try {
      const r = await api("POST", "/api/shop/purchase/start", { productId });   // 2.
      paymentId = r.paymentId;
      if (tab && !tab.closed) tab.location.href = r.url;
      else window.open(r.url, "_blank", "noopener");
      started = true;
      ui.showWaiting();                                                     // 3.
    } catch (err) {
      tab?.close(); cleanup(); ui.error(err);
    }
  })();
}

Cocos Creator

A complete set for Cocos Creator 3.x, in three files:

File Role
BazinessSDK.ts a persistent component: opens pages, routes their messages, reads the balance
BazinessWebView.ts the component on your WebView prefab: loading overlay, close button, slide animations, messages
BazinessController.ts boot wiring: linkedToken from the address, /api/platform/token, sign-in, sign-out, account switch

The prefab needs a WebView, a loading overlay node, a close button, and an Animation with Open and Close clips (slide up / slide down). On web builds the WebView is an iframe, so page messages arrive as window message events.

// BazinessSDK.ts — a persistent component (put it on a node in the first scene).
// Opens Baziness pages in a WebView prefab, routes their messages and reads the Gita coin balance.
import { _decorator, Component, director, find, instantiate, Prefab, EventTarget } from "cc";
import { BazinessWebView } from "./BazinessWebView";
const { ccclass, property } = _decorator;

export const bazinessEvents = new EventTarget(); // "login" | "logout" | "closed"

const WEB = { dev: "https://platform-web-dev.karizmastudios.org", prod: "https://baziness.com" };
const API = { dev: "https://platform-dev.karizmastudios.org/api", prod: "https://platform.karizmastudio.com/api" };

@ccclass("BazinessSDK")
export class BazinessSDK extends Component {
  @property(Prefab) webViewPrefab: Prefab = null!;

  static instance: BazinessSDK;
  static packageName = "com.yourstudio.yourgame";
  static devMode = true;
  static token: string | null = null; // the player's user token, from YOUR server
  static isLinked = false;

  private view: BazinessWebView | null = null;

  onLoad() {
    BazinessSDK.instance = this;
    director.addPersistRootNode(this.node);
  }

  /** Call after your server answered /api/platform/token. */
  static initialize(token: string, isLinked: boolean) {
    this.token = token;
    this.isLinked = isLinked;
  }

  static openLogin() {
    this.instance.open(`${this.web()}/`, { allowGuest: false });
  }
  static openProfile() {
    this.instance.open(`${this.web()}/profile`, { redirect: "profile", allowGuest: false });
  }
  static openRewards(gameName: string) {
    this.instance.open(`${this.web()}/shop`, { redirect: "shop", extra: { game_name: gameName } });
  }

  /** The Gita coin balance, straight from the platform with the player's own token. */
  static async getCoin(): Promise<number | null> {
    if (!this.token) return null;
    try {
      const res = await fetch(`${this.devMode ? API.dev : API.prod}/user/get-coin`, {
        headers: { Authorization: `Bearer ${this.token}`, "Content-Type": "application/json" },
      });
      return res.ok ? ((await res.json()).amount ?? null) : null;
    } catch {
      return null;
    }
  }

  private static web() {
    return this.devMode ? WEB.dev : WEB.prod;
  }

  private open(base: string, o: { redirect?: string; allowGuest?: boolean; extra?: Record<string, string> } = {}) {
    if (this.view) return; // one page at a time
    const q = new URLSearchParams(o.extra);
    q.set("packageName", BazinessSDK.packageName);
    q.set("sdkType", "js"); // the page posts close / login / logout / token
    if (BazinessSDK.token) q.set("linkedToken", BazinessSDK.token);
    if (o.redirect) q.set("redirect", o.redirect);
    if (o.allowGuest === false) q.set("allowGuest", "false");

    const node = instantiate(this.webViewPrefab);
    node.setParent(find("Canvas"));
    this.view = node.getComponent(BazinessWebView)!;
    this.view.onMessage = (m) => this.onMessage(m);
    this.view.onClosed = () => {
      this.view = null;
      bazinessEvents.emit("closed");
    };
    this.view.open(`${base}?${q}`);
  }

  private onMessage(msg: { type: string; data?: unknown }) {
    const token = typeof msg.data === "string" ? msg.data : (msg.data as { token?: string } | undefined)?.token;
    switch (msg.type) {
      case "close":
        this.view?.close();
        break;
      case "login":
        if (token) {
          this.view?.close(true);
          bazinessEvents.emit("login", token); // → POST /api/platform/login on your server
        } else {
          BazinessSDK.isLinked = true; // linked in place
        }
        break;
      case "logout":
        this.view?.close(true);
        bazinessEvents.emit("logout");
        break;
      case "token":
        break; // the page's silent handshake — not a login
    }
  }
}
Code samples

Bottom sheet (in-game web view)

The swipe-down sheet that shows every Baziness page inside your game. It slides up, closes with a swipe, a tap outside or the page's own button, and passes the page's messages to your game.

Why a sheet

Every Baziness page (sign-in, profile, reward shop, game offer) opens inside your game, not in a new tab. The recommended container is a bottom sheet. It slides up over the dimmed game, stops a little short of the top, and has a rounded top edge with a drag handle. The player can close it in three ways, and the page can close it too.

Try it:

Drag the handle down, tap the dimmed area, or press the page's close button.

Anatomy

Text
┌──────────────────────────────────────┐
│░░░░░░░░ dimmed game (tap = close) ░░░│  ← overlay  rgba(8,6,4,.5), z-index 1100
│░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░│
│ ╭──────────────────────────────────╮ │  ← sheet: 92% height, max 560px wide,
│ │              ━━━━━               │ │     radius 22px 22px 0 0
│ │   (grabber zone, 26px — drag ↓)  │ │  ← the ONLY draggable area
│ ├──────────────────────────────────┤ │
│ │                                  │ │
│ │      <iframe src="baziness…">     │ │  ← no sandbox; spinner until "load"
│ │                                  │ │
│ │                                  │ │
╰─┴──────────────────────────────────┴─╯

How it must behave

  1. Opening: draw the sheet below the screen, then animate it up on the next frame (about 320 ms, ease-out).
  2. Three ways to close:
    • drag the handle down further than min(150px, 28% of the sheet height); a shorter drag springs back
    • tap the dimmed area above the sheet
    • the page sends { "type": "close" }
  3. Only the handle is draggable. The page swallows its own touches, so a drag that starts inside the page can't move the sheet.
  4. No sandbox on the iframe. The sign-in page needs its own cookies, pop-ups (for the code) and redirects. Allow clipboard-read; clipboard-write; payment.
  5. Show a spinner until the page has loaded.
  6. Accept messages only from the page's own origin. Read both string JSON and plain objects, and require a string type.
  7. Close cleanly: animate down, then tear down once. Point the frame at about:blank, stop listening for messages, and only then report "closed". If the sheet was opened again in the meantime, do nothing.
  8. Stay above everything, the splash screen included: the sign-in page can open from the splash.
  9. Size it like your game's column: full width on phones, at most about 560 px wide and centred on wide screens.

Web: TypeScript, React and CSS

// bazinesswebview.ts — the sheet, framework-free.
export interface BazinessMessage { type: string; data?: unknown }

export class BazinessSheet {
  readonly el: HTMLElement;
  private sheet: HTMLElement;
  private frame: HTMLIFrameElement;
  private spinner: HTMLElement;
  private allowedOrigin: string | null = null;
  private isOpen = false;
  private dragging = false;
  private dragStartY = 0;
  private dragDelta = 0;
  private sheetHeight = 1;

  onMessage: ((m: BazinessMessage) => void) | null = null;
  onClosed: (() => void) | null = null;

  constructor() {
    this.frame = Object.assign(document.createElement("iframe"), { className: "baziness-frame" });
    this.frame.setAttribute("allow", "clipboard-read; clipboard-write; payment"); // no sandbox!
    this.spinner = Object.assign(document.createElement("div"), { className: "baziness-spinner" });
    const grabberZone = document.createElement("div");
    grabberZone.className = "baziness-grabber-zone";
    grabberZone.innerHTML = '<div class="baziness-grabber"></div>';
    this.sheet = document.createElement("div");
    this.sheet.className = "baziness-sheet";
    this.sheet.append(grabberZone, this.spinner, this.frame);
    this.el = document.createElement("div");
    this.el.className = "overlay baziness-webview hidden";
    this.el.append(this.sheet);
    document.body.append(this.el);

    this.frame.addEventListener("load", () => this.spinner.classList.add("hidden"));
    grabberZone.addEventListener("pointerdown", (e) => this.dragStart(e));
    grabberZone.addEventListener("pointermove", (e) => this.dragMove(e));
    grabberZone.addEventListener("pointerup", () => this.dragEnd());
    grabberZone.addEventListener("pointercancel", () => this.dragEnd());
    this.el.addEventListener("pointerdown", (e) => { if (e.target === this.el) this.close(); }); // tap outside
  }

  open(url: string, cropBottomPx = 0): void {
    if (this.isOpen) return;
    this.isOpen = true;
    this.frame.style.marginBottom = cropBottomPx > 0 ? `${-cropBottomPx}px` : "";
    this.allowedOrigin = new URL(url).origin;
    this.spinner.classList.remove("hidden");
    this.el.classList.remove("hidden");
    this.sheet.style.transition = "none";
    this.sheet.style.transform = "translateY(100%)";
    this.frame.src = url;
    window.addEventListener("message", this.onWindowMessage);
    requestAnimationFrame(() => {
      this.sheet.style.transition = "";
      this.sheet.style.transform = "translateY(0)";
    });
  }

  close(): void {
    if (!this.isOpen) return;
    this.isOpen = false;
    this.dragging = false;
    window.removeEventListener("message", this.onWindowMessage);
    this.sheet.style.transition = "";
    this.sheet.style.transform = "translateY(100%)";
    let torn = false;
    const teardown = () => {
      if (torn || this.isOpen) return;          // already torn down, or reopened meanwhile
      torn = true;
      this.sheet.removeEventListener("transitionend", teardown);
      this.el.classList.add("hidden");
      this.frame.src = "about:blank";
      this.frame.style.marginBottom = "";
      this.sheet.style.transform = "";
      this.allowedOrigin = null;
      this.onClosed?.();
    };
    this.sheet.addEventListener("transitionend", teardown);
    window.setTimeout(teardown, 400);
  }

  private dragStart(e: PointerEvent) {
    this.dragging = true;
    this.dragStartY = e.clientY;
    this.dragDelta = 0;
    this.sheetHeight = this.sheet.getBoundingClientRect().height || 1;
    this.sheet.style.transition = "none";
    (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
  }
  private dragMove(e: PointerEvent) {
    if (!this.dragging) return;
    this.dragDelta = Math.max(0, e.clientY - this.dragStartY);   // downward only
    this.sheet.style.transform = `translateY(${this.dragDelta}px)`;
  }
  private dragEnd() {
    if (!this.dragging) return;
    this.dragging = false;
    this.sheet.style.transition = "";
    if (this.dragDelta > Math.min(150, this.sheetHeight * 0.28)) this.close();
    else this.sheet.style.transform = "translateY(0)";           // spring back
  }

  private onWindowMessage = (event: MessageEvent) => {
    if (this.allowedOrigin && event.origin !== this.allowedOrigin) return;
    let value: unknown = event.data;
    if (typeof value === "string") { try { value = JSON.parse(value); } catch { return; } }
    if (value && typeof value === "object" && typeof (value as BazinessMessage).type === "string") {
      this.onMessage?.(value as BazinessMessage);
    }
  };
}

Cocos Creator

The BazinessWebView component in Client code → Cocos Creator does the same job with a WebView prefab: a loading overlay until the page loads, a close button that appears after a few seconds, and Open / Close slide animations.

Unity

Unity has no built-in web view, so you need a plugin. This sketch uses gree/unity-webview. The page starts 8% below the top of the screen, the drag handle is a Unity UI element above it, and the page's messages reach C# through a small JavaScript bridge.

C# (Unity)
// BazinessSheet.cs — a bottom sheet around a WebView plugin (gree/unity-webview).
using System;
using UnityEngine;
using UnityEngine.EventSystems;

public class BazinessSheet : MonoBehaviour, IDragHandler, IEndDragHandler   // put this on the grabber UI
{
    [SerializeField] RectTransform grabber;    // a 26px bar drawn by Unity above the web view
    [SerializeField] GameObject dimmer;        // full-screen button behind; OnClick → Close()
    WebViewObject web;
    Action<string> onMessage;
    Action onClosed;
    float topMargin, drag;

    public void Open(string url, Action<string> onMessage, Action onClosed = null)
    {
        this.onMessage = onMessage;
        this.onClosed = onClosed;
        topMargin = Screen.height * 0.08f;     // the sheet stops 8% short of the top
        drag = 0;
        web = new GameObject("BazinessWebView").AddComponent<WebViewObject>();
        web.Init(
            cb: msg => this.onMessage?.Invoke(msg),   // messages forwarded by the bridge below
            ld: _ => web.EvaluateJS(
                "window.addEventListener('message',function(e){" +
                "Unity.call(typeof e.data==='string'?e.data:JSON.stringify(e.data));});"));
        Layout();
        web.LoadURL(url);
        web.SetVisibility(true);
        dimmer.SetActive(true);
        grabber.gameObject.SetActive(true);
    }

    public void OnDrag(PointerEventData e) { drag = Mathf.Max(0, drag - e.delta.y); Layout(); }

    public void OnEndDrag(PointerEventData e)
    {
        if (drag > Mathf.Min(150, (Screen.height - topMargin) * 0.28f)) Close();
        else { drag = 0; Layout(); }           // spring back
    }

    public void Close()
    {
        if (web == null) return;
        Destroy(web.gameObject);
        web = null;
        dimmer.SetActive(false);
        grabber.gameObject.SetActive(false);
        onClosed?.Invoke();
    }

    void Layout()
    {
        int top = (int)(topMargin + drag + grabber.rect.height);
        web.SetMargins(0, top, 0, 0);
        grabber.anchoredPosition = new Vector2(0, -(topMargin + drag));
    }
}

Common mistakes

Mistake What happens
sandbox on the iframe sign-in gets stuck: pop-ups and cookies are blocked
The whole sheet is draggable drags inside the page scroll it and move the sheet, or nothing moves
No origin check on messages a message from somewhere else is taken for a page message
"Closed" reported twice (animation end + timeout) a waiting sign-in finishes twice; the starter pack opens twice
The sheet sits below the splash screen the sign-in page opens invisibly behind it
The frame isn't reset to about:blank the old page keeps running in the background
Example game

Example game — Rock, Paper, Scissors

A complete, small game that uses every integration in this wiki. Play it on the dev server, download the project, and run it yourself in two minutes.

Play it

Open the example game (development servers: platform-dev, platform-web-dev, pay-dev).

Download the project (.zip): plain Node.js and browser JavaScript, no dependencies, no build step.

What it shows

A best-of-five rock-paper-scissors match against a bot: first to 3 points wins, and draws don't count. Each match costs 1 ticket, and winning pays 50 Gita coins. Every platform feature is in it:

Feature Where in the game Wiki page Code
Sign in with Baziness (splash choice), account switch, logout first launch; Settings → «اتصال حساب» Login public/js/platform.js, server/services.js (platformToken, platformLogin)
Currencies: tickets (local) and Gita coins (get-coin) the top bar chips, the reward banner Gita coins public/js/platform.js (getCoin), public/js/app.js (renderTopbar)
Winner gets 50 Gita coins (add-coin, idempotent), sign-in required to claim the result popup, the "reward waiting" strip Gita coins server/services.js (claimMatchReward), public/js/app.js (claim)
«انتخاب جایزه» reward page the purple banner Reward page public/js/platform.js (openRewardSelection)
Tutorial: rules → coach mark → game-offer page → «خرید موفق» starter pack (5 tickets) first launch Tutorial offer page public/js/app.js (tutorial section), server/services.js (claimStarterPack)
Swipe-down sheet every Baziness page Bottom sheet public/js/sheet.js, public/styles.css
Baziness Pay: «بسته‌ی بلیت», 10 tickets for 4,900 Toman (SKU example_pack) the shop card Baziness Pay server/pay.js, server/services.js (startPurchase, verifyPurchase), public/js/purchase.js
UTM / ?tag= first-touch capture any link, e.g. …/example/?utm_source=wiki&utm_medium=docs Links & UTM public/js/api.js (FIRST_TOUCH), server/services.js (ensureUser)

Run it locally

Shell
unzip baziness-example-rps.zip && cd baziness-example-rps
cp .env.example .env        # fill in BAZINESS_CLIENT_ID / BAZINESS_CLIENT_SECRET
npm start                   # → http://127.0.0.1:4380/

Requirements: Node.js 20+. Data lives in data/db.json, a tiny JSON store standing in for your database.

.env Development value
BAZINESS_CLIENT_ID / BAZINESS_CLIENT_SECRET the example's dev client (ask the platform team, or use your own game's)
BAZINESS_PLATFORM_PACKAGE_NAME com.karizmastudio.example
BAZINESS_PAY_BASE_URL https://pay-dev.karizmastudios.org
BAZINESS_PAY_PACKAGE_NAME com.karizmastudio.example
PACK_SKU / PACK_PRICE example_pack / 4900

Tip

No credentials? It still runs. With BAZINESS_PLATFORM_ENABLED=false the Baziness UI disappears and winnings are kept locally. With DEV_FAKE_BAZINESS_PAY=true and BAZINESS_PAY_BASE_URL=http://localhost:4380/dev/baziness, purchases go through a built-in fake gateway with a «پرداخت موفق» button.

Testing tips

  • Signing in: on this dev deployment, «ورود با حساب بازینس» accepts any phone number with the code 111111. No SMS is sent.
  • Replay the tutorial: use «بازنشانی این دستگاه» in the lobby footer. It clears local storage, so the next boot is a brand-new player.
  • A second player in the same browser: add ?device=someone-else to the URL.
  • Claiming as a guest: win a match without signing in. The claim answers PLATFORM_NOT_LINKED and the login sheet opens. After you sign in, the claim retries by itself.
  • A purchase you finish later: start a purchase, pay, close the game, and open it again. The lobby reconciles pending payments.
  • Campaign links: open …/example/?utm_source=wiki&utm_campaign=test. The address is clean after load, and the tags are stored on the account when it is created.

Project layout

Text
baziness-example-rps/
├── .env.example          every setting, documented
├── package.json          "npm start" — no dependencies
├── server/
│   ├── index.js          HTTP routes + static files (relative paths: works at / or /example/)
│   ├── env.js            configuration (.env loader)
│   ├── platform.js       platform client: auth, create-user, tokens, add-coin   ← secret stays here
│   ├── pay.js            Baziness Pay: buy URL, get_payment, verify_payment (form), consume
│   ├── services.js       accounts & linking, match rules, rewards, starter pack, purchases
│   ├── store.js          JSON-file store (swap for your database)
│   └── fake-pay.js       local fake gateway (never in production)
└── public/
    ├── index.html, styles.css
    ├── assets/           art (hands, bot, pack, prize, Gita coin, ticket, Baziness logo)
    └── js/
        ├── api.js        first module: UTM capture, device id, fetch wrapper
        ├── platform.js   BazinessPlatform: page URLs, messages, login, get-coin
        ├── sheet.js      the swipe-down sheet
        ├── purchase.js   Pay popup: tab opened in the click, verify on focus
        ├── ui.js         DOM helpers, Persian numbers, toasts, modals
        └── app.js        splash, lobby, match, tutorial

Hosting it under a path

The client only uses relative URLs (api/…, assets/…), so the same files work at / or under a prefix. On the wiki's dev server, nginx strips /example/ and proxies to the Node process:

NGINX
location = /example { return 301 /example/; }
location /example/ {
    proxy_pass http://127.0.0.1:4380/;          # trailing slash: /example/api/me → /api/me
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
Reference

Go-live checklist & troubleshooting

Everything to check before you switch Baziness on in production, and the fixes for the problems integrations run into most often.

Before you go live

Settings

  • A production client id and secret (different from development), on the server only.
  • BAZINESS_PLATFORM_ENABLED=true, BAZINESS_PLATFORM_USE_PRODUCTION=true, and the platform package name and game name set.
  • BAZINESS_PAY_ENABLED=true, BAZINESS_PAY_BASE_URL=https://pay.baziness.com, and the Pay package name set; the local fake gateway off.
  • Search your client build for the secret: it must not be there.
  • /api/platform/config answers enabled: true with the production addresses.

Login

On the dev platform you can test every case with made-up phone numbers and the code 111111, because no SMS is sent (details).

  • The splash asks «ورود با حساب بازینس» / «ادامه به عنوان مهمان» once per device.
  • Closing the sign-in page without signing in brings the two buttons back.
  • Signing in to an account used on another device switches to it, with its progress.
  • Settings → connect account opens the profile page, and shows "connected" afterwards.
  • Signing out inside a Baziness page resets the game to a brand-new visitor.
  • Opening the game from the Baziness app (?linkedToken=) still runs the tutorial for new players.

Coins & pages

  • The Gita coin balance comes from the platform: … while unknown, never a made-up 0.
  • The balance refreshes when a page closes and when the game regains focus.
  • Every one-off payout uses a transactionId built from what the payout is, and a 400 counts as paid.
  • «انتخاب جایزه» opens /shop?game_name=… without a sign-in wall.
  • (If you use it) the tutorial's first tap opens /game-offer, and closing it continues to the starter pack.
  • The view closes by its handle, by a tap outside, and by the page's own button.
  • Every page link has packageName and sdkType=js, and linkedToken when available.
  • Campaign links carry all four utm_* in ASCII, and the address bar is clean after loading.
  • The campaign survives a Baziness sign-in and an account switch.

Baziness Pay

  • Every SKU is registered under your Pay package name.
  • A real production purchase is given once, then consumed.
  • Buying the same pack twice in a row works, which proves consume ran.
  • A payment finished after closing the game is given the next time the shop opens.

Troubleshooting

Symptom Likely cause Fix
The page's close button does nothing sdkType=js missing, or the message origin check uses the wrong origin add sdkType=js to every page link; compare the message origin with the address you opened
The reward shop closes as soon as it opens the token handshake is treated as a sign-in ignore token; only login is a sign-in
The first sign-in hangs, or your server answers 400 the sign-in started before the player had a Baziness user and token call /api/platform/token first, and open the page with that token
Signed in, but the game still shows a guest /api/platform/login wasn't called, or the profile wasn't reloaded call it on login, then reload the player's profile
The sign-in page offers "continue as guest" allowGuest=false missing add it on the sign-in, profile and offer pages (not /shop)
The reward page asks the player to sign in the site root was opened instead of /shop open {web}/shop?game_name=… with linkedToken
Platform calls start failing after a while the client token expired (401) get a new one (step 1) and retry once
A valid-looking token is rejected validate-user-token answered isValid: false (HTTP 200) check the isValid field, not only the HTTP status; the client signs in again
A player got the same coins twice the transactionId was built from the time build it from what the payout is (step 5)
A claim fails forever after a crash add-coin answered 400 and the claim treated it as an error treat 400 as "already paid"
The payment tab stays blank the tab was opened after waiting for the server, or with noopener open an empty tab inside the tap, then set its address
The player paid but got nothing they never came back, so the check never ran check on focus and on «پرداخت کردم», and check pending payments when the shop opens
verify_payment always fails it was sent as JSON send a form body
Pay answers no_required_params uid isn't numeric send your numeric player id
A second purchase of the same pack is blocked the purchase wasn't consumed call consume after giving the item, with retries
Players from the Baziness app skip the tutorial ?linkedToken= was taken to mean "returning player" decide "new player" from your own data
The campaign is lost after sign-in the tags were read from the address only, and the game restarted store them on the device before the sign-in
Persian campaign names are missing UTM values are kept as ASCII only use ASCII names (shahrivar_1405)
The sign-in view is invisible on the splash the view sits below the splash screen put it above everything

Getting help

  • Credentials, your game's offer page and SKUs: ask the Baziness platform team.
  • To see everything working together, read the example game's code.
Reference

API reference

Every endpoint, address and message on one page — the platform API, Baziness Pay, the Baziness pages, and the endpoints your own server should offer.

Platform API

  • Production: https://platform.karizmastudio.com/api
  • Development: https://platform-dev.karizmastudios.org/api

Step-by-step explanations: Platform API.

Method & path Auth Body Response Notes
POST /client/auth — { clientId, clientSecret } { clientId, token } the client token
POST /client/create-user client token { deviceId, platform, store, linkedToken } { userId, token, isLinkedToAccount } platform: Web / Android / Ios / Editor; store: Direct / GooglePlay / AppStore / CafeBazaar / Myket
GET /client/get-user-token/{userId} client token — { userId, token, isLinkedToAccount } a fresh user token
GET /client/validate-user-token/{token} client token — { userId, isValid } invalid → 200 with isValid: false, userId: 0
POST /client/add-coin client token { userId, amount, transactionId, metaData } { resourceId, amount } (new balance) a repeated transactionId → 400 { success: false, error: "add resource failed" }
GET /user/get-coin user token — { resourceId, amount } called by the client; any origin allowed

Any call with a missing or expired client token answers 401.

Baziness pages

  • Production: https://baziness.com
  • Development: https://platform-web-dev.karizmastudios.org

Details: Baziness pages.

Page Path Required Optional
Sign-in / packageName, sdkType=js linkedToken, allowGuest=false, buildStore
Profile /profile packageName, sdkType=js linkedToken, redirect=profile, allowGuest=false
Reward shop «انتخاب جایزه» /shop packageName, sdkType=js, linkedToken game_name, redirect=shop
Game offer /game-offer game_name, packageName, sdkType=js linkedToken, allowGuest=false

Messages from the pages

{ "type": string, "data"?: string | { "token": string } }, as JSON text or an object.

type What to do
login with a token send it to your server → verify → link or switch → close
login without data linked in place → refresh
token ignore (a silent handshake)
logout close, clear local data, restart as a new visitor
close close the view

Baziness Pay

  • Production: https://pay.baziness.com
  • Development: https://pay-dev.karizmastudios.org

uid must be numeric. Details: Baziness Pay.

Method & path Body format Body Response
GET /buy query sku, uid, pname the payment page
POST /get_payment JSON { uid, sku, pname } { uid, sku, token, time, success }, or { error: "payment_is_not_valid", success: false }
POST /verify_payment form uid, token, sku, pname { success, error }
POST /consume JSON { uid, sku, pname } { success }

Your server's endpoints (recommended)

Endpoint Body Response
GET /api/platform/config — { enabled, webBase, apiBase, packageName, gameName, gameOfferUrl }
POST /api/platform/token { deviceId, linkedToken?, tag?, utm? } { enabled, …settings, token?, userId?, isLinkedToAccount?, switchToDeviceId?, error? }
POST /api/platform/login { deviceId, token } { ok, switchToDeviceId?, reason? }
GET /api/shop — the products this player can buy
POST /api/shop/purchase/start { productId } { paymentId, url }
POST /api/shop/purchase/verify { paymentId } { status, granted, balances }
POST /api/shop/purchase/cancel { paymentId } —
GET /api/shop/purchase/pending — [{ paymentId, productId }]

Settings

Setting Default Meaning
BAZINESS_PLATFORM_ENABLED false master switch for accounts, coins and pages
BAZINESS_PLATFORM_USE_PRODUCTION false production or development addresses
BAZINESS_CLIENT_ID / BAZINESS_CLIENT_SECRET — credentials (secret)
BAZINESS_PLATFORM_PACKAGE_NAME — packageName on page links
BAZINESS_GAME_NAME — game_name on the reward and offer pages
BAZINESS_PLATFORM_API_URL / BAZINESS_PLATFORM_WEB_URL derived address overrides
BAZINESS_GAME_OFFER_URL {web}/game-offer?game_name=… offer page override
BAZINESS_PAY_ENABLED false master switch for purchases
BAZINESS_PAY_BASE_URL — https://pay-dev.karizmastudios.org or https://pay.baziness.com
BAZINESS_PAY_PACKAGE_NAME — pname
BAZINESS_SKU_PREFIX empty SKU = prefix + product id
DEV_FAKE_BAZINESS_PAY false the local fake gateway (never in production)