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 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.
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=trueand 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.
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.
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
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.
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.
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.
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 }
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.
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.
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 }
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.
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.
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 /.
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.
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.
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
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).
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.
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.
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 }
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.
Your game keeps its own player accounts, for example keyed by a device id generated on first launch. Baziness is attached on top:
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.
The player stays anonymous (isLinkedToAccount: false) until they sign in with their phone number on a Baziness page. The account is then linked.
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
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
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:
Your server answers switchToDeviceId: "<that player's device id>".
The client saves that device id as its own and forgets its stored Baziness token.
It restarts, without linkedToken in the address.
The next boot loads the other player, with all their progress.
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.
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.
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.
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.
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:
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 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:
Make the web view taller than its container by the bar's height.
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.
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.
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.
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.
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"]
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.
Continue however the page was closed: its own button, a swipe down, or a tap outside.
Never block the tutorial on Baziness. If it is off or unreachable, skip straight to the starter pack.
Give the starter pack from the server, once per player. Present it as a successful purchase («خرید موفق»), even though it is free.
Players arriving from the Baziness app get the tutorial too. Signing in doesn't make a player "old".
After the tutorial, open /shop, never the offer page again.
The three kinds of link in a Baziness integration, and how to tag the links that bring players to your game so every player is credited to the right campaign.
UTM values must be plain ASCII: lowercase letters, digits, ., _, -, at most 64 characters. A Persian campaign name is lost, so write shahrivar_1405, not شهریور.
Read them first, before anything renders. Match the names case-insensitively.
Remove them from the address bar. The player doesn't see or edit them, and a player sharing their address doesn't credit your campaign with their friends.
Store them on the device the first time ("first touch"), and only if nothing is stored yet. A Baziness sign-in or an account switch restarts the game, and without local storage the tags would be lost.
Send them with the first request that creates the player, both your normal sign-in and /api/platform/token, whichever runs first.
On the server, save them only when the player is created. Never overwrite them later.
Keep four separate fields (utm_source, utm_medium, utm_campaign, utm_content), not one joined string. Campaign names contain underscores, so a joined value can't be split back.
Leave linkedToken alone. It isn't a campaign tag: the sign-in logic uses it, and removes it on the next restart.
Give marketing a fixed recipe, or a small link generator, so every link carries all four parameters spelled the same way: your game's address, plus utm_source, utm_medium, utm_campaign and (optionally) utm_content, all in lowercase ASCII.
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.
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 }
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.
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 differentuserId → 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.
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.
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.
#3. New /api/platform/link-code: a code for each page
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.
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.
Additions to your platform client (next to createUser / getUserAccessToken):
TypeScript (Node)
// 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. */exportinterfacePlatformSession {
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 flatreturn {
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. */exportconstcreateUserSession = async (deviceId: string, platform: string, store: string, linkCode: string | null) =>
readSession(awaitplatformFetch("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. */exportconststartUserSession = async (bazinessUserId: number) =>
readSession(awaitplatformFetch("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).
*/exportasyncfunctionrefreshUserSession(refreshToken: string): Promise<PlatformSession | "rotated" | null> {
const res = awaitfetch(`${API_BASE}/auth/refresh`, { // no client token: the refresh token is the credentialmethod: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken }),
});
if (res.ok) returnreadSession(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) throwObject.assign(newError("account_suspended"), { status: 403 });
if (res.status === 401) returnnull;
thrownewError(`auth/refresh failed (${res.status})`); // 5xx / network: keep the old session, try later
}
/** Ends a session (player signed out). Never throws. */exportasyncfunctionendUserSession(refreshToken: string): Promise<void> {
awaitfetch(`${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). */exportconstcreatePageLinkCode = async (bazinessUserId: number) =>
String((awaitplatformFetch("POST", `/v2/client/link-code/${bazinessUserId}`)).code);
/** REPLACES the old GET version: the token now goes in the body, not in the URL. */exportasyncfunctionvalidateUserAccessToken(token: string): Promise<number> {
const j = awaitplatformFetch("POST", "/client/validate-user-token", { token });
if (!(j.isValid ?? j.IsValid)) thrownewError("invalid user token");
returnNumber(j.userId ?? j.UserId ?? 0);
}
C# (.NET)
// BazinessPlatformClient.cs — ADD these members (they reuse settings, http, SendAsync and Json).publicrecordPlatformSession(long UserId, bool IsLinkedToAccount, string AccessToken,
DateTimeOffset AccessTokenExpiresAt, string RefreshToken);
publicenum RefreshOutcome { Ok, Rotated, Ended }
static PlatformSession ReadSession(JsonElement j)
{
// v2 create-user / get-user-token nest the pair in "tokens"; auth/refresh returns it flat.var t = j.TryGetProperty("tokens", outvar nested) ? nested : j;
returnnew PlatformSession(
j.GetProperty("userId").GetInt64(),
j.GetProperty("isLinkedToAccount").GetBoolean(),
t.GetProperty("accessToken").GetString()!,
t.GetProperty("accessTokenExpiresAt").GetDateTimeOffset(),
t.GetProperty("refreshToken").GetString()!);
}
///<summary>First time, or when the client brought a ?linkCode=. 400 = expired or used code.</summary>publicasync Task<PlatformSession> CreateUserSessionAsync(string deviceId, string platform, string store, string? linkCode) =>
ReadSession(await SendAsync(HttpMethod.Post, "/v2/client/create-user", new { deviceId, platform, store, linkCode }));
///<summary>A new session for a known player — only when there is no refresh token or the session ended.</summary>publicasync Task<PlatformSession> StartUserSessionAsync(long bazinessUserId) =>
ReadSession(await SendAsync(HttpMethod.Post, $"/v2/client/get-user-token/{bazinessUserId}"));
///<summary>Renews a session. Rotated = a parallel request refreshed first (reload, retry once); Ended = start a new one.</summary>publicasync Task<(RefreshOutcome Outcome, PlatformSession? Session)> RefreshUserSessionAsync(string refreshToken)
{
// No client token here: the refresh token itself is the credential.var res = await http.PostAsJsonAsync($"{settings.ApiBase}/auth/refresh", new { refreshToken }, Json);
if (res.IsSuccessStatusCode)
return (RefreshOutcome.Ok, ReadSession(await res.Content.ReadFromJsonAsync<JsonElement>(Json)));
string? error = null;
var text = await res.Content.ReadAsStringAsync();
if (!string.IsNullOrEmpty(text))
{
try { error = JsonDocument.Parse(text).RootElement.TryGetProperty("error", outvar e) ? e.GetString() : null; }
catch (JsonException) { /* not JSON */ }
}
return res.StatusCode switch
{
HttpStatusCode.Unauthorized when error == "refresh_token_rotated" => (RefreshOutcome.Rotated, null),
HttpStatusCode.Unauthorized => (RefreshOutcome.Ended, null),
HttpStatusCode.Forbidden => thrownew InvalidOperationException("account_suspended"),
_ => thrownew HttpRequestException($"auth/refresh failed ({(int)res.StatusCode})") // keep the old session
};
}
///<summary>Ends a session (player signed out). Never throws.</summary>publicasync Task EndUserSessionAsync(string refreshToken)
{
try { await http.PostAsJsonAsync($"{settings.ApiBase}/auth/logout", new { refreshToken }, Json); }
catch { /* best effort */ }
}
///<summary>One-time code for opening ONE Baziness page as this player (~2 minutes).</summary>publicasync Task<string> CreatePageLinkCodeAsync(long bazinessUserId) =>
(await SendAsync(HttpMethod.Post, $"/v2/client/link-code/{bazinessUserId}")).GetProperty("code").GetString()!;
///<summary>REPLACES the old GET version: the token now goes in the body, not in the URL.</summary>publicasync Task<long> ValidateUserAccessTokenAsync(string token)
{
var j = await SendAsync(HttpMethod.Post, "/client/validate-user-token", new { token });
if (!j.GetProperty("isValid").GetBoolean()) thrownew InvalidOperationException("invalid user token");
return j.GetProperty("userId").GetInt64();
}
Python
# baziness/platform.py — ADD these methods to BazinessPlatform (they reuse API_BASE, self._http and self._call). @staticmethoddef_session(j: dict) -> dict:
t = j.get("tokens") or j # v2 create-user / get-user-token nest the pair; auth/refresh returns it flatreturn {"userId": int(j["userId"]), "isLinkedToAccount": bool(j["isLinkedToAccount"]),
"accessToken": t["accessToken"], "accessTokenExpiresAt": t["accessTokenExpiresAt"],
"refreshToken": t["refreshToken"]}
defcreate_user_session(self, device_id: str, platform: str, store: str, link_code: str | None) -> dict:
"""First time, or when the client brought a ?linkCode=. HTTPError 400 = expired or used code."""returnself._session(self._call("POST", "/v2/client/create-user", {
"deviceId": device_id, "platform": platform, "store": store, "linkCode": link_code}))
defstart_user_session(self, baziness_user_id: int) -> dict:
"""A new session for a known player — only when there is no refresh token or the session ended."""returnself._session(self._call("POST", f"/v2/client/get-user-token/{baziness_user_id}"))
defrefresh_user_session(self, refresh_token: str):
"""Returns the new session, "rotated" (reload the stored token, retry once) or None (session over)."""
r = self._http.post(f"{API_BASE}/auth/refresh", json={"refreshToken": refresh_token}, timeout=10)
if r.ok:
returnself._session(r.json())
error = (r.json() if r.content else {}).get("error")
if r.status_code == 401:
return"rotated"if error == "refresh_token_rotated"elseNoneif r.status_code == 403:
raise PermissionError("account_suspended")
r.raise_for_status() # 5xx: keep the old session, try laterdefend_user_session(self, refresh_token: str) -> None:
"""Ends a session (player signed out). Never raises."""try:
self._http.post(f"{API_BASE}/auth/logout", json={"refreshToken": refresh_token}, timeout=10)
except requests.RequestException:
passdefcreate_page_link_code(self, baziness_user_id: int) -> str:
"""One-time code for opening ONE Baziness page as this player (~2 minutes)."""returnstr(self._call("POST", f"/v2/client/link-code/{baziness_user_id}")["code"])
defvalidate_user_access_token(self, token: str) -> int:
"""REPLACES the old GET version: the token now goes in the body, not in the URL."""
j = self._call("POST", "/client/validate-user-token", {"token": token})
ifnot _pick(j, "isValid", False):
raise ValueError("invalid user token")
returnint(_pick(j, "userId", 0))
The four endpoints:
TypeScript (Node)
import {
createUserSession, startUserSession, refreshUserSession, endUserSession, createPageLinkCode,
validateUserAccessToken, platformClientConfig, platformEnabled, typePlatformSession,
} 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.constforClient = (s: PlatformSession) => ({
token: s.accessToken, tokenExpiresAt: s.accessTokenExpiresAt,
bazinessUserId: s.userId, isLinkedToAccount: s.isLinkedToAccount,
});
asyncfunctionkeep(playerId: number, s: PlatformSession) {
await db.setRefreshToken(playerId, s.refreshToken); // always the NEWEST one: the old one stops workingreturn s;
}
/** A valid session for a player who already has a Baziness user. */asyncfunctionsessionFor(player: { id: number; bazinessUserId: number; bazinessRefreshToken: string | null }) {
if (player.bazinessRefreshToken) {
let r = awaitrefreshUserSession(player.bazinessRefreshToken);
if (r === "rotated") { // another request of this player refreshed a moment agoconst latest = await db.getRefreshToken(player.id);
r = latest ? awaitrefreshUserSession(latest) : null;
if (r === "rotated") r = null;
}
if (r) returnkeep(player.id, r);
}
// No refresh token yet (players from before the migration) or the session ended.returnkeep(player.id, awaitstartUserSession(player.bazinessUserId));
}
app.post("/api/platform/token", async (req, res) => {
const deviceId = String(req.body.deviceId ?? "").trim();
constlinkCode: string | null = req.body.linkCode ?? null; // replaces linkedTokenif (!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 = awaitcreateUserSession(deviceId, "Web", "Direct", linkCode)
.catch((e) => (linkCode && e.status === 400 ? null : Promise.reject(e))); // expired or used codeif (s) {
const owner = await db.findUserByBazinessId(s.userId);
if (owner && owner.id !== me.id) { // the same person, another player of yoursawaitkeep(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);
awaitkeep(fresh.id, s);
return res.json({ ...config, switchToDeviceId: fresh.deviceId });
}
await db.linkBaziness(me.id, s.userId, s.isLinkedToAccount);
awaitkeep(me.id, s);
return res.json({ ...config, ...forClient(s) });
}
if (!me.bazinessUserId) { // stale code and no Baziness user yetconst s2 = awaitcreateUserSession(deviceId, "Web", "Direct", null);
await db.linkBaziness(me.id, s2.userId, s2.isLinkedToAccount);
awaitkeep(me.id, s2);
return res.json({ ...config, ...forClient(s2) });
}
}
const s = awaitsessionFor(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 asError).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: awaitcreatePageLinkCode(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 = awaitvalidateUserAccessToken(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 userawait 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) {
awaitendUserSession(me.bazinessRefreshToken);
await db.setRefreshToken(me.id, null);
}
return res.json({ ok: true });
});
C# (.NET)
// IUserStore gains: SetRefreshTokenAsync(id, token?), GetRefreshTokenAsync(id), FindByDeviceAsync(deviceId),// CreatePlayerForBazinessAsync(bazinessUserId, isLinked). Store the refresh token like a password.// What the client gets. The refresh token never leaves your server.staticobjectForClient(PlatformSettings pub, PlatformSession s) => new
{
enabled = true, pub.WebBase, pub.ApiBase, pub.PackageName,
token = s.AccessToken, tokenExpiresAt = s.AccessTokenExpiresAt,
bazinessUserId = s.UserId, isLinkedToAccount = s.IsLinkedToAccount
};
staticasync Task<PlatformSession> SessionForAsync(Player player, BazinessPlatformClient platform, IUserStore users)
{
var refreshToken = player.BazinessRefreshToken;
for (var attempt = 0; refreshToken isnotnull && attempt < 2; attempt++)
{
var (outcome, session) = await platform.RefreshUserSessionAsync(refreshToken);
if (outcome == RefreshOutcome.Ok)
{
await users.SetRefreshTokenAsync(player.Id, session!.RefreshToken); // always the newest onereturn session;
}
if (outcome == RefreshOutcome.Ended) break;
refreshToken = await users.GetRefreshTokenAsync(player.Id); // Rotated: a parallel request won
}
// No refresh token yet (players from before the migration) or the session ended.var fresh = await platform.StartUserSessionAsync(player.BazinessUserId!.Value);
await users.SetRefreshTokenAsync(player.Id, fresh.RefreshToken);
return fresh;
}
app.MapPost("/api/platform/token", async (TokenRequest body, BazinessPlatformClient platform,
IUserStore users, PlatformSettings pub, ILogger<Program> log) =>
{
if (string.IsNullOrWhiteSpace(body.DeviceId)) return Results.BadRequest(new { error = "deviceId required" });
if (!pub.IsOn) return Results.Ok(new { enabled = false });
var me = await users.EnsureByDeviceAsync(body.DeviceId);
try
{
if (body.LinkCode isnotnull || me.BazinessUserId isnull)
{
PlatformSession? s = null;
try { s = await platform.CreateUserSessionAsync(body.DeviceId, "Web", "Direct", body.LinkCode); }
catch (HttpRequestException e) when (body.LinkCode isnotnull && e.StatusCode == HttpStatusCode.BadRequest) { }
if (s isnotnull)
{
var owner = await users.FindByBazinessUserIdAsync(s.UserId);
if (owner isnotnull && owner.Id != me.Id)
{
await users.SetRefreshTokenAsync(owner.Id, s.RefreshToken);
return Results.Ok(new { enabled = true, pub.WebBase, pub.ApiBase, pub.PackageName, switchToDeviceId = owner.DeviceId });
}
if (me.BazinessUserId isnotnull && me.BazinessUserId != s.UserId)
{
var created = await users.CreatePlayerForBazinessAsync(s.UserId, s.IsLinkedToAccount);
await users.SetRefreshTokenAsync(created.Id, s.RefreshToken);
return Results.Ok(new { enabled = true, pub.WebBase, pub.ApiBase, pub.PackageName, switchToDeviceId = created.DeviceId });
}
await users.LinkBazinessAsync(me.Id, s.UserId, s.IsLinkedToAccount);
await users.SetRefreshTokenAsync(me.Id, s.RefreshToken);
return Results.Ok(ForClient(pub, s));
}
if (me.BazinessUserId isnull) // stale code and no Baziness user yet
{
var s2 = await platform.CreateUserSessionAsync(body.DeviceId, "Web", "Direct", null);
await users.LinkBazinessAsync(me.Id, s2.UserId, s2.IsLinkedToAccount);
await users.SetRefreshTokenAsync(me.Id, s2.RefreshToken);
return Results.Ok(ForClient(pub, s2));
}
}
var session = await SessionForAsync(me, platform, users);
if (session.IsLinkedToAccount != me.IsBazinessLinked) await users.SetLinkedAsync(me.Id, session.IsLinkedToAccount);
return Results.Ok(ForClient(pub, session));
}
catch (Exception ex)
{
log.LogError(ex, "platform token failed");
var error = ex.Message == "account_suspended" ? "account_suspended" : "platform_unavailable";
return Results.Ok(new { enabled = true, pub.WebBase, pub.ApiBase, pub.PackageName, error }); // never block the boot
}
});
// Protect it like your other player endpoints: only the player themself may ask for their code.
app.MapPost("/api/platform/link-code", async (DeviceRequest body, BazinessPlatformClient platform, IUserStore users) =>
{
var me = await users.FindByDeviceAsync(body.DeviceId);
if (me?.BazinessUserId isnull) return Results.Conflict(new { error = "call /api/platform/token first" });
try { return Results.Ok(new { linkCode = await platform.CreatePageLinkCodeAsync(me.BazinessUserId.Value) }); }
catch { return Results.StatusCode(503); }
});
app.MapPost("/api/platform/login", async (LoginRequest body, BazinessPlatformClient platform, IUserStore users, PlatformSettings pub) =>
{
if (string.IsNullOrWhiteSpace(body.DeviceId) || string.IsNullOrWhiteSpace(body.Token))
return Results.BadRequest(new { error = "deviceId and token required" });
if (!pub.IsOn) return Results.Ok(new { ok = false, reason = "disabled" });
try
{
long bazinessUserId = await platform.ValidateUserAccessTokenAsync(body.Token); // POST now; verify right awayvar owner = await users.FindByBazinessUserIdAsync(bazinessUserId);
var me = await users.EnsureByDeviceAsync(body.DeviceId);
if (owner isnotnull && owner.Id != me.Id) return Results.Ok(new { ok = true, switchToDeviceId = owner.DeviceId });
if (me.BazinessUserId != bazinessUserId) await users.SetRefreshTokenAsync(me.Id, null); // belonged to the old userawait users.LinkBazinessAsync(me.Id, bazinessUserId, isLinked: true);
return Results.Ok(new { ok = true }); // the client then calls /token again
}
catch { return Results.Ok(new { ok = false, reason = "invalid" }); }
});
// The page sent `logout`: end the session before the client starts over as a new visitor.
app.MapPost("/api/platform/logout", async (DeviceRequest body, BazinessPlatformClient platform, IUserStore users) =>
{
var me = await users.FindByDeviceAsync(body.DeviceId);
if (me?.BazinessRefreshToken is { } refreshToken)
{
await platform.EndUserSessionAsync(refreshToken);
await users.SetRefreshTokenAsync(me.Id, null);
}
return Results.Ok(new { ok = true });
});
publicrecordTokenRequest(string DeviceId, string? LinkCode);
publicrecordDeviceRequest(string DeviceId);
publicrecordLoginRequest(string DeviceId, string Token);
Python
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from requests import HTTPError
from baziness.platform import platform, platform_client_config, platform_enabled
from app import db # your own data layer. New column on the player: baziness_refresh_token (a secret)
app = FastAPI()
classTokenRequest(BaseModel):
deviceId: str
linkCode: str | None = None# replaces linkedTokenclassDeviceRequest(BaseModel):
deviceId: strclassLoginRequest(BaseModel):
deviceId: str
token: strdeffor_client(s: dict) -> dict: # the refresh token never leaves your serverreturn {"token": s["accessToken"], "tokenExpiresAt": s["accessTokenExpiresAt"],
"bazinessUserId": s["userId"], "isLinkedToAccount": s["isLinkedToAccount"]}
defkeep(player_id: int, s: dict) -> dict:
db.set_refresh_token(player_id, s["refreshToken"]) # always the newest onereturn s
defsession_for(player) -> dict:
token = player.baziness_refresh_token
for _ inrange(2):
ifnot token:
break
r = platform.refresh_user_session(token)
ifisinstance(r, dict):
return keep(player.id, r)
if r isNone: # the session endedbreak
token = db.get_refresh_token(player.id) # "rotated": a parallel request won# No refresh token yet (players from before the migration) or the session ended.return keep(player.id, platform.start_user_session(player.baziness_user_id))
@app.post("/api/platform/token")defplatform_token(body: TokenRequest):
ifnot platform_enabled():
return {"enabled": False}
config = {"enabled": True, **platform_client_config()}
me = db.ensure_user_by_device(body.deviceId)
try:
if body.linkCode ornot me.baziness_user_id:
try:
s = platform.create_user_session(body.deviceId, "Web", "Direct", body.linkCode)
except HTTPError as e:
ifnot (body.linkCode and e.response.status_code == 400):
raise
s = None# expired or used codeif s:
owner = db.find_user_by_baziness_user_id(s["userId"])
if owner and owner.id != me.id: # the same person, another player of yours
keep(owner.id, s)
return {**config, "switchToDeviceId": owner.device_id}
if me.baziness_user_id and me.baziness_user_id != s["userId"]:
fresh = db.create_player_for_baziness(s["userId"], s["isLinkedToAccount"])
keep(fresh.id, s)
return {**config, "switchToDeviceId": fresh.device_id}
db.link_baziness(me.id, s["userId"], s["isLinkedToAccount"])
return {**config, **for_client(keep(me.id, s))}
ifnot me.baziness_user_id: # stale code and no Baziness user yet
s2 = platform.create_user_session(body.deviceId, "Web", "Direct", None)
db.link_baziness(me.id, s2["userId"], s2["isLinkedToAccount"])
return {**config, **for_client(keep(me.id, s2))}
s = session_for(me)
if s["isLinkedToAccount"] != me.is_baziness_linked:
db.set_linked(me.id, s["isLinkedToAccount"])
return {**config, **for_client(s)}
except PermissionError:
return {**config, "error": "account_suspended"}
except Exception as err: # never block the bootprint("[platform] token failed:", err)
return {**config, "error": "platform_unavailable"}
@app.post("/api/platform/link-code") # protect it like your other player endpointsdefplatform_link_code(body: DeviceRequest):
me = db.find_user_by_device(body.deviceId)
ifnot me ornot me.baziness_user_id:
raise HTTPException(409, "call /api/platform/token first")
return {"linkCode": platform.create_page_link_code(me.baziness_user_id)}
@app.post("/api/platform/login")defplatform_login(body: LoginRequest):
ifnot platform_enabled():
return {"ok": False, "reason": "disabled"}
try:
baziness_user_id = platform.validate_user_access_token(body.token) # POST now; verify right awayexcept Exception:
return {"ok": False, "reason": "invalid"}
owner = db.find_user_by_baziness_user_id(baziness_user_id)
me = db.ensure_user_by_device(body.deviceId)
if owner and owner.id != me.id:
return {"ok": True, "switchToDeviceId": owner.device_id}
if me.baziness_user_id != baziness_user_id:
db.set_refresh_token(me.id, None) # belonged to the old user
db.link_baziness(me.id, baziness_user_id, True)
return {"ok": True} # the client then calls /token again@app.post("/api/platform/logout") # the page sent `logout`defplatform_logout(body: DeviceRequest):
me = db.find_user_by_device(body.deviceId)
if me and me.baziness_refresh_token:
platform.end_user_session(me.baziness_refresh_token)
db.set_refresh_token(me.id, None)
return {"ok": True}
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.
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.
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.
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).
On the page's logout message, call /api/platform/logout, then start over as before.
TypeScript
// 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.asyncinit(): Promise<boolean> {
const params = newURLSearchParams(location.search);
const linkCode = params.get("linkCode");
const res = awaitfetch("/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) returnthis.switchTo(data.switchToDeviceId); // unchangedthis.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.asyncaccessToken(): Promise<string | null> {
if (!this.token || this.tokenExpiresAt - Date.now() < 60_000) awaitthis.init();
returnthis.token;
}
asyncgetCoin(): Promise<number> {
constcall = async () => fetch(`${this.apiBase}/user/get-coin`, {
headers: { Authorization: `Bearer ${awaitthis.accessToken()}` },
});
let res = awaitcall();
if (res.status === 401) { this.token = null; res = awaitcall(); } // renew once, then give upreturn 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.asyncpageUrl(path: string, o: PageOptions = {}): Promise<string> {
const res = awaitfetch("/api/platform/link-code", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deviceId: this.deviceId }),
});
const { linkCode } = await res.json();
const url = newURL(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.asynconPageLogout() {
awaitfetch("/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
}
C# (Unity)
// BazinessLogin — the parts that change.public DateTimeOffset TokenExpiresAt { get; privateset; }
[Serializable] classLinkCodeResponse { publicstring linkCode; }
// 1. Boot: forward the one-time ?linkCode= / deep-link code (it replaces linkedToken).// Stop saving the token in PlayerPrefs: your server keeps the session.public IEnumerator Init(string deviceId, string linkCode = null)
{
var body = $"{{\"deviceId\":\"{deviceId}\",\"linkCode\":{Quote(linkCode)}}}";
usingvar req = Post("/api/platform/token", body);
yieldreturn req.SendWebRequest();
var r = JsonUtility.FromJson<TokenResponse>(req.downloadHandler.text); // TokenResponse gains: string tokenExpiresAtif (!string.IsNullOrEmpty(r.switchToDeviceId)) { SwitchTo(r.switchToDeviceId); yieldbreak; } // unchanged
Token = r.token;
TokenExpiresAt = DateTimeOffset.TryParse(r.tokenExpiresAt, outvar t) ? t : DateTimeOffset.MinValue;
}
// 2. The token lives 15 minutes: renew it through your server before using it.public IEnumerator EnsureFreshToken(string deviceId)
{
if (string.IsNullOrEmpty(Token) || TokenExpiresAt - DateTimeOffset.UtcNow < TimeSpan.FromMinutes(1))
yieldreturnInit(deviceId);
}
// 3. Pages: a fresh one-time code for every page you open, instead of the token.public IEnumerator OpenPage(string deviceId, string path, Action<string> open,
string redirect = null, bool allowGuest = true, string extra = null)
{
usingvar req = Post("/api/platform/link-code", $"{{\"deviceId\":\"{deviceId}\"}}");
yieldreturn req.SendWebRequest();
var linkCode = JsonUtility.FromJson<LinkCodeResponse>(req.downloadHandler.text).linkCode;
open(PageUrl(path, linkCode, redirect, allowGuest, extra));
}
publicstringPageUrl(string path, string linkCode, string redirect = null, bool allowGuest = true, string extra = null)
{
var url = $"{webBase.TrimEnd('/')}{path}?packageName={Uri.EscapeDataString(packageName)}&sdkType=js";
if (!string.IsNullOrEmpty(linkCode)) url += $"&linkCode={Uri.EscapeDataString(linkCode)}"; // was linkedTokenif (!string.IsNullOrEmpty(redirect)) url += $"&redirect={redirect}";
if (!allowGuest) url += "&allowGuest=false";
if (!string.IsNullOrEmpty(extra)) url += $"&{extra}";
return url;
}
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" }
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
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 nolinkedToken; 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.
# --- 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 belowBAZINESS_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.comBAZINESS_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.comBAZINESS_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 idDEV_FAKE_BAZINESS_PAY=false# local development only (see the Pay page)
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.
TypeScript (Node)
import express from"express";
import { platformClientConfig, platformEnabled, WEB_BASE } from"./platform/baziness";
constGAME_NAME = process.env.BAZINESS_GAME_NAME ?? "example"; // your game's name on the platformconstGAME_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 });
});
C# (.NET)
// Side-effect free: tells the client whether Baziness is on and where its pages live. Never the secret.
app.MapGet("/api/platform/config", (PlatformSettings s, IConfiguration cfg) =>
{
if (!s.IsOn) return Results.Ok(new { enabled = false });
var gameName = cfg["Baziness:GameName"] ?? "example";
var gameOfferUrl = cfg["Baziness:GameOfferUrl"] is { Length: > 0 } custom
? custom
: $"{s.WebBase}/game-offer?game_name={Uri.EscapeDataString(gameName)}";
return Results.Ok(new { enabled = true, webBase = s.WebBase, apiBase = s.ApiBase, packageName = s.PackageName, gameName, gameOfferUrl });
});
Python
import os
from urllib.parse import quote
from fastapi import FastAPI
from baziness.platform import WEB_BASE, platform_client_config, platform_enabled
GAME_NAME = os.getenv("BAZINESS_GAME_NAME", "example") # your game's name on the platform
GAME_OFFER_URL = os.getenv("BAZINESS_GAME_OFFER_URL") orf"{WEB_BASE}/game-offer?game_name={quote(GAME_NAME)}"
app = FastAPI()
@app.get("/api/platform/config")defplatform_config():
"""Side-effect free: tells the client whether Baziness is on and where its pages live. Never the secret."""ifnot platform_enabled():
return {"enabled": False}
return {"enabled": True, **platform_client_config(), "gameName": GAME_NAME, "gameOfferUrl": GAME_OFFER_URL}
A payout that can never be paid twice: the grant key fixes the transactionId, and a 400 means it was already paid.
TypeScript (Node)
// grants.ts — a payout that can never be paid twice.import { addCoin } from"./platform/baziness";
exportasyncfunctionpayTournamentReward(claimId: number, bazinessUserId: number, amount: number) {
try {
// Same claim → same key → same transactionId → the platform refuses a second credit.awaitaddCoin(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 bookkeepingawait db.ledger.insert({ claimId, currency: "GITA_COIN", amount, external: true });
}
C# (.NET)
// Grants.cs — a payout that can never be paid twice.publicsealedclassGrants(BazinessPlatformClient platform, AppDb db)
{
publicasync Task PayTournamentRewardAsync(long claimId, long bazinessUserId, long amount)
{
try
{
// Same claim → same key → same transactionId → the platform refuses a second credit.await platform.AddCoinAsync(bazinessUserId, amount, $"reward_claim:{claimId}", new { source = "tournament", claimId });
}
catch (HttpRequestException e) when (e.StatusCode == System.Net.HttpStatusCode.BadRequest)
{
// 400 = already credited by an earlier attempt: the coins are there, carry on.
}
await db.Claims.MarkPaidAsync(claimId); // your own bookkeepingawait db.Ledger.InsertAsync(new LedgerRow(claimId, "GITA_COIN", amount, External: true));
}
}
Python
# grants.py — a payout that can never be paid twice.import requests
from baziness.platform import platform
defpay_tournament_reward(claim_id: int, baziness_user_id: int, amount: int) -> None:
try:
# Same claim → same key → same transactionId → the platform refuses a second credit.
platform.add_coin(baziness_user_id, amount, f"reward_claim:{claim_id}", {"source": "tournament", "claimId": claim_id})
except requests.HTTPError as err:
if err.response isNoneor err.response.status_code != 400:
raise# 400 = already credited by an earlier attempt: the coins are there, carry on.
db.claims.mark_paid(claim_id) # your own bookkeeping
db.ledger.insert(claim_id=claim_id, currency="GITA_COIN", amount=amount, external=True)
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.
TypeScript (Node)
// grants.ts: Gita coins go to the platform; COIN/GEM stay local.exportasyncfunctiongrantCurrency(tx: Tx, change: WalletChange & { idempotencyKey: string },
opts: { requireLink: boolean; metaData?: object }) {
if (change.currency !== "GITA_COIN" || !platformEnabled() || change.amount < 0) {
returnapplyWalletChange(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) thrownewApiError("PLATFORM_NOT_LINKED", "برای دریافت گیتا کوین وارد حساب بازینس شوید");
returnapplyWalletChange(tx, { ...change, note: "held until a Baziness account is linked" }); // escrow
}
try {
awaitaddCoin(bazinessUserId, change.amount, change.idempotencyKey, { source: change.type, ...opts.metaData });
} catch (err) {
thrownewApiError("PLATFORM_UNAVAILABLE", "ارتباط با بازینس برقرار نشد، دوباره تلاش کنید");
}
awaitrecordExternalCredit(tx, change); // ledger row, balance unchanged locally
}
/** Called right after a successful link (POST /api/platform/login or /token). */exportasyncfunctionflushGitaEscrow(userId: number) {
awaitwithTx(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}`;
awaitaddCoin(user.baziness_user_id, wallet.balance, key, { source: "escrow" });
awaitapplyWalletChange(tx, { userId, currency: "GITA_COIN", amount: -wallet.balance,
type: "REWARD_SELECTION", idempotencyKey: key, note: "moved to the Baziness account" });
});
}
C# (.NET)
// Inside a service with: BazinessPlatformClient platform, PlatformSettings settings, IUserStore users, IWalletStore wallets.publicasync Task GrantAsync(DbTransaction tx, WalletChange change, bool requireLink)
{
if (change.Currency != "GITA_COIN" || !settings.IsOn || change.Amount < 0)
{ await wallets.ApplyAsync(tx, change); return; } // local walletlong? bazinessUserId = await users.GetBazinessUserIdAsync(tx, change.UserId);
if (bazinessUserId isnull)
{
if (requireLink) thrownew ApiException("PLATFORM_NOT_LINKED", "برای دریافت گیتا کوین وارد حساب بازینس شوید");
await wallets.ApplyAsync(tx, change with { Note = "held until a Baziness account is linked" }); // escrowreturn;
}
await platform.AddCoinAsync(bazinessUserId.Value, change.Amount, change.IdempotencyKey, new { source = change.Type });
await wallets.RecordExternalCreditAsync(tx, change);
}
publicasync Task FlushEscrowAsync(long userId)
{
awaitusingvar tx = await db.BeginTransactionAsync();
var bazinessUserId = await users.GetBazinessUserIdAsync(tx, userId);
var balance = await wallets.LockBalanceAsync(tx, userId, "GITA_COIN"); // SELECT … FOR UPDATEif (bazinessUserId isnull || balance <= 0) return;
var key = $"escrow:{userId}:{await wallets.LastTransactionIdAsync(tx, userId, "GITA_COIN")}";
await platform.AddCoinAsync(bazinessUserId.Value, balance, key, new { source = "escrow" });
await wallets.ApplyAsync(tx, new WalletChange(userId, "GITA_COIN", -balance, "REWARD_SELECTION", key));
await tx.CommitAsync();
}
Python
defgrant_currency(tx, change: dict, require_link: bool, meta: dict | None = None) -> None:
if change["currency"] != "GITA_COIN"ornot platform_enabled() or change["amount"] < 0:
return apply_wallet_change(tx, change) # local wallet
baziness_user_id = tx.scalar("select baziness_user_id from users where id = %s", [change["userId"]])
ifnot baziness_user_id:
if require_link:
raise ApiError("PLATFORM_NOT_LINKED", "برای دریافت گیتا کوین وارد حساب بازینس شوید")
return apply_wallet_change(tx, {**change, "note": "held until a Baziness account is linked"}) # escrow
platform.add_coin(baziness_user_id, change["amount"], change["idempotencyKey"], {"source": change["type"], **(meta or {})})
record_external_credit(tx, change)
defflush_gita_escrow(user_id: int) -> None:
with db.transaction() as tx:
baziness_user_id = tx.scalar("select baziness_user_id from users where id = %s", [user_id])
balance = tx.scalar("select balance from user_wallets where user_id = %s and currency = 'GITA_COIN' ""for update", [user_id]) or0ifnot baziness_user_id or balance <= 0:
return
last = tx.scalar("select max(id) from wallet_transactions where user_id = %s and currency = 'GITA_COIN'",
[user_id]) or0
key = f"escrow:{user_id}:{last}"
platform.add_coin(baziness_user_id, balance, key, {"source": "escrow"})
apply_wallet_change(tx, {"userId": user_id, "currency": "GITA_COIN", "amount": -balance,
"type": "REWARD_SELECTION", "idempotencyKey": key})
create table payments (
id bigserial primary key,
user_id bigintnot nullreferences users(id),
shop_product_id bigintnot nullreferences shop_products(id),
provider text not nulldefault'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 bigintnot null,
currency_code text not null,
status text not nulldefault'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 nulldefault now(),
redirected_at timestamptz,
verified_at timestamptz,
fulfilled_at timestamptz,
updated_at timestamptz not nulldefault now(),
unique (provider, provider_transaction_id)
);
create index payments_user on payments(user_id, created_at desc);
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:
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.
TypeScript (client)
// api/client.ts — the FIRST module the app imports.constUTM_KEY = "mygame_utm";
constTAG_KEY = "mygame_tag";
constUTM_FIELDS = ["source", "medium", "campaign", "content"] asconst;
typeUtm = Partial<Record<(typeofUTM_FIELDS)[number], string>>;
const clean = (raw: unknown): string | null => {
if (typeof raw !== "string") returnnull;
const v = raw.trim().toLowerCase().replace(/\s+/g, "_").slice(0, 64);
return/^[a-z0-9][a-z0-9._-]*$/.test(v) ? v : null; // ASCII only
};
exportconstFIRST_TOUCH_UTM: Utm = (() => {
letstored: Utm = {};
try { stored = JSON.parse(localStorage.getItem(UTM_KEY) ?? "{}"); } catch {}
try {
const query = newURLSearchParams(location.search);
const keys = [...query.keys()].filter((k) => k.toLowerCase().startsWith("utm_"));
constfromUrl: 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 asnever) && 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;
})();
exportfunctionfirstTouchTag(): string | null {
try {
const fromUrl = clean(newURLSearchParams(location.search).get("tag"));
if (fromUrl && !localStorage.getItem(TAG_KEY)) localStorage.setItem(TAG_KEY, fromUrl);
returnlocalStorage.getItem(TAG_KEY) ?? fromUrl;
} catch { returnnull; }
}
// Sent with the device login AND with /api/platform/token:// { deviceId, tag: firstTouchTag(), utm: FIRST_TOUCH_UTM }
C# (server)
using System.Text.RegularExpressions;
// Save attribution ONLY when the account is created (first touch). Four separate columns.publicasync Task<User> EnsureUserByDeviceAsync(string deviceId, string? tag, UtmParams? utm)
{
conststring sql = """
insert into users (device_id, acq_tag, utm_source, utm_medium, utm_campaign, utm_content)
values (@deviceId, @tag, @source, @medium, @campaign, @content)
on conflict (device_id) do update set last_seen = now() -- existing account: attribution untouched
returning *;
""";
returnawait db.QuerySingleAsync<User>(sql, new {
deviceId, tag = Utm.Clean(tag),
source = Utm.Clean(utm?.Source), medium = Utm.Clean(utm?.Medium),
campaign = Utm.Clean(utm?.Campaign), content = Utm.Clean(utm?.Content),
});
}
publicstaticclassUtm
{
staticreadonly Regex Valid = new("^[a-z0-9][a-z0-9._-]*$", RegexOptions.Compiled);
publicstaticstring? Clean(string? raw)
{
if (raw isnull) returnnull;
var v = Regex.Replace(raw.Trim().ToLowerInvariant(), @"\s+", "_");
if (v.Length > 64) v = v[..64];
return Valid.IsMatch(v) ? v : null;
}
}
publicrecordUtmParams(string? Source, string? Medium, string? Campaign, string? Content);
Python (server)
import re
_VALID = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
defclean_utm(raw: str | None) -> str | None:
ifnotisinstance(raw, str):
returnNone
v = re.sub(r"\s+", "_", raw.strip().lower())[:64]
return v if _VALID.match(v) elseNonedefensure_user_by_device(device_id: str, tag: str | None, utm: dict | None):
"""Attribution is saved ONLY when the account is created (first touch)."""
utm = utm or {}
return db.fetch_one(
"""
insert into users (device_id, acq_tag, utm_source, utm_medium, utm_campaign, utm_content)
values (%s, %s, %s, %s, %s, %s)
on conflict (device_id) do update set last_seen = now()
returning *
""",
[device_id, clean_utm(tag), *(clean_utm(utm.get(k) or utm.get(f"utm_{k}"))
for k in ("source", "medium", "campaign", "content"))],
)
Schema (PostgreSQL)
alter table users
addcolumn if notexists acq_tag text,
addcolumn if notexists utm_source text,
addcolumn if notexists utm_medium text,
addcolumn if notexists utm_campaign text,
addcolumn if notexists utm_content text;
create index if notexists idx_users_utm_combo on users (utm_source, utm_medium, utm_campaign, utm_content)
where utm_source isnot nullor utm_medium isnot nullor utm_campaign isnot nullor utm_content isnot null;
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.
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).
TypeScript (web)
// baziness.ts — the client controller.constTOKEN_KEY = "mygame_baziness_token";
constCHOICE_KEY = "mygame_platform_choice";
exportclassBazinessPlatform {
enabled = false;
isLinked = false;
privatetoken: string | null = null;
private webBase = "";
private apiBase = "";
private packageName = "";
privateloginGate: ((ok: boolean) =>void) | null = null;
private loginSucceeded = false;
onChange: (() =>void) | null = null;
constructor(privatesheet: BazinessSheet, privatedeviceId: string) {
sheet.onMessage = (m) =>this.handleMessage(m);
sheet.onClosed = () =>this.handleClosed();
}
getchoiceMade() { returnlocalStorage.getItem(CHOICE_KEY) === "1"; }
markChoiceMade() { localStorage.setItem(CHOICE_KEY, "1"); }
asyncloadConfig(): Promise<boolean> {
try {
const cfg = awaitfetch("/api/platform/config").then((r) => r.json());
this.enabled = cfg.enabled;
if (cfg.enabled) this.apply(cfg);
} catch { this.enabled = false; }
returnthis.enabled;
}
/** Resolve the token. Returns false when the page is about to reload into another account. */asyncinit(): Promise<boolean> {
// The Baziness app opens games with ?linkedToken=… — forward it once, then it is stripped on reload.const linkedToken = newURLSearchParams(location.search).get("linkedToken") || localStorage.getItem(TOKEN_KEY);
const resp = awaitfetch("/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; returntrue; }
this.enabled = true;
this.apply(resp);
if (resp.switchToDeviceId && resp.switchToDeviceId !== this.deviceId) {
this.switchAccount(resp.switchToDeviceId);
returnfalse;
}
if (resp.token) {
this.token = resp.token;
this.isLinked = !!resp.isLinkedToAccount;
localStorage.setItem(TOKEN_KEY, resp.token);
}
this.onChange?.();
returntrue;
}
/** Splash "Sign in with Baziness": resolves true once a login/link succeeded, false if closed. */asyncopenLoginAndWait(): Promise<boolean> {
if (!this.token) awaitthis.init(); // the anonymous user's token becomes linkedTokenif (!this.enabled || !this.webBase) returnfalse;
returnnewPromise((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 = newURL(this.webBase + path);
for (const [k, v] ofObject.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();
}
privateapply(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.
}
React
// Splash.tsx — the first-time choice.exportfunctionSplash({ onReady }: { onReady: (me: Me) => void }) {
const [needsChoice, setNeedsChoice] = useState(false);
const choice = useRef<(() =>void) | null>(null);
const loggedIn = useRef(false);
useEffect(() => {
(async () => {
let me = (awaitauthenticate()).me; // 1. your own account firstif (await baziness.loadConfig()) { // 2. platform on?if (!baziness.choiceMade && !me.user.isBazinessLinked) {
setNeedsChoice(true); // 3. ask onceawaitnewPromise<void>((r) => (choice.current = r));
setNeedsChoice(false);
}
if (!(await baziness.init())) return; // 4. reloading into the linked accountif (loggedIn.current || baziness.isLinked !== me.user.isBazinessLinked) {
me = await api<Me>("GET", "/api/me"); // 5. show the linked account
}
}
onReady(me);
})();
}, [onReady]);
constchooseGuest = () => { baziness.markChoiceMade(); choice.current?.(); };
constchooseLogin = async () => {
await baziness.init();
if (!(await baziness.openLoginAndWait())) return; // closed without logging in → keep the buttons
loggedIn.current = true;
choice.current?.();
};
return needsChoice ? (
<divclassName="splash-choice"><buttonclassName="btn btn-green"onClick={chooseLogin}>ورود با حساب بازینس</button><buttonclassName="btn btn-ghost"onClick={chooseGuest}>ادامه به عنوان مهمان</button></div>
) : <ProgressBar />;
}
C# (Unity)
// BazinessLogin.cs — the same protocol from a Unity client. Your SERVER still does every// platform call; Unity only talks to your server and shows the page in a WebView.using System;
using System.Collections;
using UnityEngine;
using UnityEngine.Networking;
publicclassBazinessLogin : MonoBehaviour
{
conststring TokenKey = "baziness_token", ChoiceKey = "baziness_choice";
[SerializeField] string serverUrl = "https://api.yourgame.com";
[SerializeField] BazinessSheet sheet; // see the Bottom sheet pagepublicbool Enabled { get; privateset; }
publicbool IsLinked { get; privateset; }
publicstring Token { get; privateset; }
publicstring PackageName => packageName;
publicstring GameOfferUrl { get; privateset; } // from /api/platform/tokenstring webBase, apiBase, packageName;
Action<bool> loginGate;
[Serializable] classTokenResponse {
publicbool enabled; publicstring webBase, apiBase, packageName, token, switchToDeviceId;
publiclong bazinessUserId; publicbool isLinkedToAccount;
}
public IEnumerator Init(string deviceId)
{
var body = $"{{\"deviceId\":\"{deviceId}\",\"linkedToken\":{Quote(PlayerPrefs.GetString(TokenKey, null))}}}";
usingvar req = new UnityWebRequest($"{serverUrl}/api/platform/token", "POST");
req.uploadHandler = new UploadHandlerRaw(System.Text.Encoding.UTF8.GetBytes(body));
req.downloadHandler = new DownloadHandlerBuffer();
req.SetRequestHeader("Content-Type", "application/json");
yieldreturn req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success) { Enabled = false; yieldbreak; }
var r = JsonUtility.FromJson<TokenResponse>(req.downloadHandler.text);
Enabled = r.enabled;
if (!r.enabled) yieldbreak;
(webBase, apiBase, packageName) = (r.webBase, r.apiBase, r.packageName);
if (!string.IsNullOrEmpty(r.switchToDeviceId) && r.switchToDeviceId != deviceId)
{
DeviceIdentity.Set(r.switchToDeviceId); // adopt the other account …
PlayerPrefs.DeleteKey(TokenKey);
GameBoot.Restart(); // … and boot again as that playeryieldbreak;
}
if (!string.IsNullOrEmpty(r.token)) { Token = r.token; IsLinked = r.isLinkedToAccount; PlayerPrefs.SetString(TokenKey, r.token); }
}
publicvoidOpenLogin(Action<bool> done)
{
loginGate = done;
sheet.Open(PageUrl("/", allowGuest: false), OnRawPageMessage, onClosed: () => { loginGate?.Invoke(IsLinked); loginGate = null; });
}
publicstringPageUrl(string path, string redirect = null, bool allowGuest = true, string extra = null)
{
var url = $"{webBase}{path}?{extra}{(extra != null ? "&" : "")}packageName={Uri.EscapeDataString(packageName)}&sdkType=js";
if (!string.IsNullOrEmpty(Token)) url += $"&linkedToken={Uri.EscapeDataString(Token)}";
if (redirect != null) url += $"&redirect={redirect}";
if (!allowGuest) url += "&allowGuest=false";
return url;
}
publicvoidOnRawPageMessage(string json) { /* see "Messages from the Baziness page" below */ }
staticstringQuote(string s) => string.IsNullOrEmpty(s) ? "null" : $"\"{s}\"";
}
// 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) voidthis.applyNewLogin(token); // signed into an account → switch or linkelsevoidthis.linkedInPlace(); // linked onto the current playerbreak;
case"token":
break; // silent handshake — NOT a logincase"logout":
this.sheet.close();
localStorage.clear(); // identity, token, choice, tutorial flags…this.reloadClean(); // next boot = brand-new visitorbreak;
}
}
privateasyncapplyNewLogin(token: string) {
const result = awaitfetch("/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); // reloadsreturn;
}
if (result.ok) {
localStorage.setItem(TOKEN_KEY, token);
awaitthis.init(); // fresh token + linked flagthis.loginSucceeded = true;
this.markChoiceMade();
this.sheet.close(); // handleClosed resolves the login gate
}
// on failure keep the page open so the player can retry
}
privateasynclinkedInPlace() {
this.isLinked = true;
this.loginSucceeded = true;
this.markChoiceMade();
if (this.loginGate) { awaitthis.init(); this.sheet.close(); }
this.onChange?.();
}
privatehandleClosed() {
const gate = this.loginGate;
this.loginGate = null;
gate?.(this.loginSucceeded); // true only if a login actually happenedthis.onChange?.(); // coin balance / link state may have changed
}
privateswitchAccount(deviceId: string) {
setDeviceId(deviceId); // your own identity storelocalStorage.removeItem(TOKEN_KEY);
this.reloadClean();
}
privatereloadClean() {
const url = newURL(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());
}
React
// BazinessSheet.tsx — listening for page messages (only from the page's own origin).functionparse(data: unknown): { type: string; data?: unknown } | null {
let value = data;
if (typeof data === "string") {
try { value = JSON.parse(data); } catch { returnnull; }
}
return value && typeof value === "object" && typeof (value as { type?: unknown }).type === "string"
? (value as { type: string; data?: unknown })
: null;
}
useEffect(() => {
if (!url) return;
const origin = newURL(url).origin;
constonMessage = (event: MessageEvent) => {
if (event.origin !== origin) return; // ignore HMR, extensions, other framesconst msg = parse(event.data);
if (msg) baziness.handleMessage(msg);
};
window.addEventListener("message", onMessage);
return() =>window.removeEventListener("message", onMessage);
}, [url]);
C# (Unity)
// Called by BazinessSheet with the raw JSON the page posted (see the Bottom sheet page).// Uses Newtonsoft.Json (com.unity.nuget.newtonsoft-json) because `data` can be a string OR an object.using Newtonsoft.Json.Linq;
publicvoidOnRawPageMessage(string json)
{
JObject msg;
try { msg = JObject.Parse(json); } catch { return; }
var type = (string)msg["type"];
var data = msg["data"];
string token = data?.Type == JTokenType.String ? (string)data : (string)data?["token"];
switch (type)
{
case"close": sheet.Close(); break;
case"login":
if (!string.IsNullOrEmpty(token)) StartCoroutine(ApplyNewLogin(token)); // POST /api/platform/loginelse { IsLinked = true; StartCoroutine(Init(DeviceIdentity.Get())); sheet.Close(); }
break;
case"token": break; // silent handshake — NOT a logincase"logout":
sheet.Close();
PlayerPrefs.DeleteAll(); // forget identity, token and choice
GameBoot.Restart(); // boot again as a new visitorbreak;
}
}
// Inside BazinessPlatform: the player's Gita coin balance, straight from the platform. null = unknown.asyncgetCoin(): Promise<number | null> {
if (!this.enabled || !this.token || !this.apiBase) returnnull;
try {
const res = awaitfetch(`${this.apiBase}/user/get-coin`, {
headers: { Authorization: `Bearer ${this.token}`, "Content-Type": "application/json" },
});
if (!res.ok) returnnull;
const json = (await res.json()) as { amount?: number };
returntypeof json.amount === "number" ? json.amount : null;
} catch {
returnnull;
}
}
// Wiring: refresh on every relevant moment.
baziness.onChange = () =>void baziness.getCoin().then(renderCoinChip); // sheet closed, login, linkwindow.addEventListener("focus", () =>void baziness.getCoin().then(renderCoinChip));
React
// session.tsx — keep the external balance in context and refresh it on the right events.const [gita, setGita] = useState<number | null>(null);
const refreshGita = useCallback(async () => {
if (baziness.enabled) setGita(await baziness.getCoin());
}, []);
useEffect(() => {
baziness.onChange = () =>voidrefreshGita(); // fires when the sheet closes / login changesvoidrefreshGita();
constonFocus = () => voidrefreshGita();
window.addEventListener("focus", onFocus);
return() =>window.removeEventListener("focus", onFocus);
}, [refreshGita]);
// Anywhere in the UI:<spanclassName="gita-chip"><CurrencyIconcurrency="GITA_COIN" /><bdi>{gita === null ? "…" : faNum(gita)}</bdi></span>
C# (Unity)
using System;
using System.Collections;
using UnityEngine;
using UnityEngine.Networking;
[Serializable] classCoinResponse { publiclong amount; }
// apiBase and the user token come from /api/platform/token (see the Login page).public IEnumerator GetCoin(Action<long?> done)
{
if (!Enabled || string.IsNullOrEmpty(Token)) { done(null); yieldbreak; }
usingvar req = UnityWebRequest.Get($"{apiBase}/user/get-coin");
req.SetRequestHeader("Authorization", $"Bearer {Token}");
req.SetRequestHeader("Content-Type", "application/json");
yieldreturn req.SendWebRequest();
if (req.result != UnityWebRequest.Result.Success) { done(null); yieldbreak; }
done(JsonUtility.FromJson<CoinResponse>(req.downloadHandler.text).amount);
}
// Refresh when the app regains focus and when the Baziness sheet closes.voidOnApplicationFocus(bool focus) { if (focus) StartCoroutine(GetCoin(UpdateCoinChip)); }
// 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();
elsetoast("انتخاب جایزه بعد از اتصال به بازینس فعال میشود");
};
React
// Home.tsx — the «انتخاب جایزه» banner with the live balance.const { gita } = useSession(); // from get-coin, see the Gita coins pageconstopenRewards = () => {
if (baziness.enabled) baziness.openRewardSelection();
elsetoast("انتخاب جایزه بعد از اتصال به بازینس فعال میشود", "info");
};
return (
<buttonclassName="hero-banner reward-banner"onClick={openRewards}><spanclassName="hero-text"><h2>انتخاب جایزه</h2><spanclassName="hero-sub">گیتا کوینهایت را با جایزهی واقعی عوض کن</span><spanclassName="gita-chip"><CurrencyIconcurrency="GITA_COIN"size={30} /><bdiclassName="num">{gita === null ? "…" : faNum(gita)}</bdi></span></span><imgclassName="hero-art"src={art("ui/banner-rewards")} alt="" /></button>
);
C# (Unity)
// RewardBanner.cs — attach to the lobby button.publicclassRewardBanner : MonoBehaviour
{
[SerializeField] BazinessLogin platform;
[SerializeField] BazinessSheet sheet;
[SerializeField] TMPro.TMP_Text balanceLabel;
[SerializeField] string gameName = "example";
voidOnEnable() => StartCoroutine(platform.GetCoin(ShowBalance));
publicvoidOnClick()
{
if (!platform.Enabled) { Toast.Show("انتخاب جایزه بعد از اتصال به بازینس فعال میشود"); return; }
var url = platform.PageUrl("/shop", redirect: "shop", extra: $"game_name={Uri.EscapeDataString(gameName)}");
sheet.Open(url, platform.OnRawPageMessage, onClosed: () => StartCoroutine(platform.GetCoin(ShowBalance)));
}
voidShowBalance(long? amount) => balanceLabel.text = amount?.ToString("N0") ?? "…";
}
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. */exportconstBAZINESS_BOTTOM_BAR_CROP_PX = 0;
// in BazinessSheet.open(url, cropBottomPx):this.frame.style.marginBottom = cropBottomPx > 0 ? `${-cropBottomPx}px` : "";
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).
TypeScript (web)
// purchase.ts — the purchase popup.exportfunctionbuy(productId: string, ui: PurchaseUi) {
const tab = window.open("about:blank", "_blank"); // 1. synchronously, no "noopener"letpaymentId: string | null = null;
let started = false;
let verifying = false;
constverify = async (manual: boolean) => {
if (!started || verifying || !paymentId) return;
verifying = true;
ui.showChecking();
try {
const r = awaitapi("POST", "/api/shop/purchase/verify", { paymentId });
if (r.status === "FULFILLED") { cleanup(); ui.success(r.granted); voidrefreshWallets(); return; }
if (manual) ui.toast("پرداختی پیدا نشد. اگر پرداخت کردهای چند لحظه بعد دوباره بررسی کن.");
ui.showWaiting();
} catch (err) {
if (manual) ui.error(err);
ui.showWaiting();
} finally { verifying = false; }
};
constonFocus = () => voidverify(false);
constonVisible = () => document.visibilityState === "visible" && voidverify(false);
constcleanup = () => { removeEventListener("focus", onFocus); document.removeEventListener("visibilitychange", onVisible); };
addEventListener("focus", onFocus);
document.addEventListener("visibilitychange", onVisible);
ui.showOpening(() =>verify(true) /* «پرداخت کردم» */, () => { voidverify(false); cleanup(); } /* close */);
(async () => {
try {
const r = awaitapi("POST", "/api/shop/purchase/start", { productId }); // 2.
paymentId = r.paymentId;
if (tab && !tab.closed) tab.location.href = r.url;
elsewindow.open(r.url, "_blank", "noopener");
started = true;
ui.showWaiting(); // 3.
} catch (err) {
tab?.close(); cleanup(); ui.error(err);
}
})();
}
React
// Shop.tsx + PurchaseModal.tsx: open the tab in the click, then mount the modal.exportfunctionopenPayTab(): Window | null {
returnwindow.open("about:blank", "_blank"); // must run inside the click handler
}
// <button onClick={() => setBuying({ product, payTab: openPayTab() })}>…</button>exportfunctionPurchaseModal({ product, payTab, onClose }: Props) {
const { setWallets, toast, refresh } = useSession();
const [phase, setPhase] = useState<"opening" | "waiting" | "checking" | "done">("opening");
const paymentId = useRef<string | null>(null);
const started = useRef(false);
const verifying = useRef(false);
const verify = useCallback(async (manual: boolean) => {
if (!paymentId.current || verifying.current || !started.current) return;
verifying.current = true;
setPhase("checking");
try {
const res = await api<VerifyPurchaseResponse>("POST", "/api/shop/purchase/verify", { paymentId: paymentId.current });
setWallets(res.wallets);
if (res.status === "FULFILLED") { setPhase("done"); toast("خرید با موفقیت انجام شد", "success"); voidrefresh(); return; }
if (manual) toast("پرداختی پیدا نشد. اگر پرداخت کردهای چند لحظه بعد دوباره بررسی کن.", "info");
setPhase("waiting");
} catch (err) {
if (manual) toast(errorMessage(err), "error");
setPhase("waiting");
} finally { verifying.current = false; }
}, [refresh, setWallets, toast]);
useEffect(() => { // start once
(async () => {
try {
const res = await api<StartPurchaseResponse>("POST", "/api/shop/purchase/start", { productId: product.id });
paymentId.current = res.paymentId;
if (payTab && !payTab.closed) payTab.location.href = res.url;
elsewindow.open(res.url, "_blank", "noopener");
started.current = true;
setPhase("waiting");
} catch (err) { payTab?.close(); toast(errorMessage(err), "error"); onClose(); }
})();
}, []);
useEffect(() => { // verify when the player comes backconstonFocus = () => voidverify(false);
constonVisible = () => document.visibilityState === "visible" && voidverify(false);
window.addEventListener("focus", onFocus);
document.addEventListener("visibilitychange", onVisible);
return() => { window.removeEventListener("focus", onFocus); document.removeEventListener("visibilitychange", onVisible); };
}, [verify]);
constclose = () => { if (phase !== "done" && paymentId.current) voidverify(false); onClose(); };
return (
<ModalopenonClose={close}><h3>{product.title}</h3>
{phase === "done" ? <p>به حسابت اضافه شد ✓</p>
: phase === "waiting" ? (<><p>صفحهی پرداخت بازینس باز شد. بعد از پرداخت به بازی برگرد.</p><buttononClick={() => void verify(true)}>پرداخت کردم</button></>)
: <Spinner />}
</Modal>
);
}
C# (Unity)
// PurchaseFlow.cs — on mobile the payment page opens in the system browser, so there is no// popup blocker. Verify when the app regains focus, plus a manual «پرداخت کردم» button.publicclassPurchaseFlow : MonoBehaviour
{
[SerializeField] GameApi api; // your HTTP wrapper around /api/shop/purchase/*string paymentId;
bool started, verifying;
public IEnumerator Buy(string productId)
{
StartResponse r = null;
yieldreturn api.Post<StartResponse>("/api/shop/purchase/start", new { productId }, x => r = x);
if (r == null) { Toast.Show("خطا در اتصال به درگاه"); yieldbreak; }
paymentId = r.paymentId;
Application.OpenURL(r.url); // {base}/buy?sku&uid&pname, built by YOUR server
started = true;
PurchasePanel.ShowWaiting(onPaid: () => StartCoroutine(Verify(manual: true)));
}
voidOnApplicationFocus(bool focus)
{
if (focus && started) StartCoroutine(Verify(manual: false)); // back from the browser
}
IEnumerator Verify(bool manual)
{
if (verifying || string.IsNullOrEmpty(paymentId)) yieldbreak;
verifying = true;
VerifyResponse v = null;
yieldreturn api.Post<VerifyResponse>("/api/shop/purchase/verify", new { paymentId }, x => v = x);
verifying = false;
if (v?.status == "FULFILLED") { started = false; PurchasePanel.Success(v.granted); Wallet.Set(v.wallets); }
elseif (manual) Toast.Show("پرداختی پیدا نشد. اگر پرداخت کردهای چند لحظه بعد دوباره بررسی کن.");
}
[System.Serializable] classStartResponse { publicstring paymentId, url; }
[System.Serializable] classVerifyResponse { publicstring status; public Grant[] granted; public Wallets wallets; }
}
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 windowmessage events.
BazinessSDK.ts
// 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;
exportconst bazinessEvents = newEventTarget(); // "login" | "logout" | "closed"constWEB = { dev: "https://platform-web-dev.karizmastudios.org", prod: "https://baziness.com" };
constAPI = { dev: "https://platform-dev.karizmastudios.org/api", prod: "https://platform.karizmastudio.com/api" };
@ccclass("BazinessSDK")
exportclassBazinessSDKextendsComponent {
@property(Prefab) webViewPrefab: Prefab = null!;
staticinstance: BazinessSDK;
static packageName = "com.yourstudio.yourgame";
static devMode = true;
statictoken: string | null = null; // the player's user token, from YOUR serverstatic isLinked = false;
privateview: BazinessWebView | null = null;
onLoad() {
BazinessSDK.instance = this;
director.addPersistRootNode(this.node);
}
/** Call after your server answered /api/platform/token. */staticinitialize(token: string, isLinked: boolean) {
this.token = token;
this.isLinked = isLinked;
}
staticopenLogin() {
this.instance.open(`${this.web()}/`, { allowGuest: false });
}
staticopenProfile() {
this.instance.open(`${this.web()}/profile`, { redirect: "profile", allowGuest: false });
}
staticopenRewards(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. */staticasyncgetCoin(): Promise<number | null> {
if (!this.token) returnnull;
try {
const res = awaitfetch(`${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 {
returnnull;
}
}
privatestaticweb() {
returnthis.devMode ? WEB.dev : WEB.prod;
}
privateopen(base: string, o: { redirect?: string; allowGuest?: boolean; extra?: Record<string, string> } = {}) {
if (this.view) return; // one page at a timeconst q = newURLSearchParams(o.extra);
q.set("packageName", BazinessSDK.packageName);
q.set("sdkType", "js"); // the page posts close / login / logout / tokenif (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}`);
}
privateonMessage(msg: { type: string; data?: unknown }) {
const token = typeof msg.data === "string" ? msg.data : (msg.dataas { 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
}
}
}
BazinessWebView.ts
// BazinessWebView.ts — the component on the WebView prefab (WebView + loading overlay + close button).import { _decorator, Animation, Button, Component, Node, WebView } from"cc";
const { ccclass, property } = _decorator;
@ccclass("BazinessWebView")
exportclassBazinessWebViewextendsComponent {
@property(WebView) webView: WebView = null!;
@property(Node) loadingOverlay: Node = null!;
@property(Button) closeButton: Button = null!;
@property(Animation) animation: Animation = null!; // "Open" slides up, "Close" slides downonMessage: ((m: { type: string; data?: unknown }) =>void) | null = null;
onClosed: (() =>void) | null = null;
private origin = "";
privatecloseTimer: ReturnType<typeofsetTimeout> | null = null;
private listener = (e: MessageEvent) =>this.onWindowMessage(e);
open(url: string) {
this.origin = newURL(url).origin;
this.webView.node.active = false;
this.closeButton.node.active = false;
// Our own close button appears after a few seconds: a way out even if the page never loads.this.closeTimer = setTimeout(() => {
if (this.closeButton?.isValid) this.closeButton.node.active = true;
}, 3000);
this.closeButton.node.on(Button.EventType.CLICK, () =>this.close(), this);
this.webView.node.on(WebView.EventType.LOADED, () => {
this.loadingOverlay.active = false;
this.webView.node.active = true;
}, this);
this.webView.node.on(WebView.EventType.ERROR, () =>this.close(), this);
window.addEventListener("message", this.listener); // on web builds the WebView is an iframethis.webView.url = url;
this.animation.play("Open");
}
close(immediately = false) {
window.removeEventListener("message", this.listener);
if (this.closeTimer) clearTimeout(this.closeTimer);
if (!immediately) this.animation.play("Close");
setTimeout(() => {
this.onClosed?.();
if (this.node?.isValid) this.node.destroy();
}, immediately ? 0 : 500);
}
privateonWindowMessage(e: MessageEvent) {
if (e.origin !== this.origin) return; // only the page we openedtry {
const msg = typeof e.data === "string" ? JSON.parse(e.data) : e.data;
if (msg && typeof msg.type === "string") this.onMessage?.(msg);
} catch {
/* not a Baziness message */
}
}
}
BazinessController.ts
// BazinessController.ts — boot-time wiring between your server, the SDK and the page messages.import { BazinessSDK, bazinessEvents } from"./BazinessSDK";
import { GameApi } from"./GameApi"; // your HTTP client for /api/*import { LocalData } from"./LocalData"; // your localStorage wrapperexportclassBazinessController {
/** Returns true when it started an account switch or a reset — stop your own boot then. */staticasyncinitialize(): Promise<boolean> {
// The Baziness app opens games with ?linkedToken=… . If it doesn't match the token this device// already holds, a different Baziness account is arriving: start clean before going on.const fromUrl = newURLSearchParams(location.search).get("linkedToken");
const saved = LocalData.get("baziness_token");
if (fromUrl && saved && fromUrl !== saved) {
this.resetAndReload();
returntrue;
}
const resp = awaitGameApi.post("/api/platform/token", { linkedToken: fromUrl ?? saved });
if (!resp.enabled) returnfalse; // platform off: no Baziness UIif (resp.switchToDeviceId) {
this.switchTo(resp.switchToDeviceId);
returntrue;
}
if (resp.token) {
LocalData.set("baziness_token", resp.token);
BazinessSDK.initialize(resp.token, resp.isLinkedToAccount);
}
bazinessEvents.on("login", async (token: string) => {
const r = awaitGameApi.post("/api/platform/login", { token }); // the server validates itif (r.switchToDeviceId) this.switchTo(r.switchToDeviceId);
elseif (r.ok) {
LocalData.set("baziness_token", token);
awaitthis.initialize();
}
});
bazinessEvents.on("logout", () =>this.resetAndReload());
returnfalse;
}
privatestaticswitchTo(deviceId: string) {
LocalData.clear();
LocalData.set("device_id", deviceId);
this.reload();
}
privatestaticresetAndReload() {
LocalData.clear(); // the next boot is a brand-new visitorthis.reload();
}
privatestaticreload() {
const url = newURL(location.href);
url.searchParams.delete("linkedToken");
location.replace(url.toString()); // or reload your loading scene
}
}
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.
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.
Opening: draw the sheet below the screen, then animate it up on the next frame (about 320 ms, ease-out).
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" }
Only the handle is draggable. The page swallows its own touches, so a drag that starts inside the page can't move the sheet.
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.
Show a spinner until the page has loaded.
Accept messages only from the page's own origin. Read both string JSON and plain objects, and require a string type.
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.
Stay above everything, the splash screen included: the sign-in page can open from the splash.
Size it like your game's column: full width on phones, at most about 560 px wide and centred on wide screens.
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 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;
publicclassBazinessSheet : 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;
publicvoidOpen(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);
}
publicvoidOnDrag(PointerEventData e) { drag = Mathf.Max(0, drag - e.delta.y); Layout(); }
publicvoidOnEndDrag(PointerEventData e)
{
if (drag > Mathf.Min(150, (Screen.height - topMargin) * 0.28f)) Close();
else { drag = 0; Layout(); } // spring back
}
publicvoidClose()
{
if (web == null) return;
Destroy(web.gameObject);
web = null;
dimmer.SetActive(false);
grabber.gameObject.SetActive(false);
onClosed?.Invoke();
}
voidLayout()
{
int top = (int)(topMargin + drag + grabber.rect.height);
web.SetMargins(0, top, 0, 0);
grabber.anchoredPosition = new Vector2(0, -(topMargin + drag));
}
}
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
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.
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.
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: