Apps API

Your game opens inside the platform with the player's identity already signed — no sign-up, no login of your own. From here you get the profile, friends, result posting and payments.

Need multiplayer, chat, cloud saves, leaderboards or an in-game economy? They are ready too: server cubes — no server of your own required (reference in Russian).

How it works#

The platform signs the player and passes them to the game in the page URL.

Your game runs at zloy.net/<game-address> inside an iframe. The platform appends four parameters — the signed identity of the player:

app_idyour game's identifier
user_idthe player who opened the game
tsunix time the signature was issued (valid for 48 hours)
signHMAC-SHA256(app_key, "app_id:user_id:ts") , hex

Pass these parameters into every request unchanged. The signature cannot be forged without app_key; the key is visible only to you on the game's edit page, where it can also be revoked — old signatures stop working immediately.

# The URL your game was opened with:
# https://your-address/?app_id=1&user_id=42&ts=1760000000&sign=<hex>

# Godot Web: take the query string from the page URL
var query := JavaScriptBridge.eval("location.search.slice(1)", true)

func api_url(method: String) -> String:
    return "https://api.zloy.net/v1/%s?%s" % [method, query]
// The URL your game was opened with:
// https://your-address/?app_id=1&user_id=42&ts=1760000000&sign=<hex>

// The SDK picks the parameters up on its own
const p = new URLSearchParams(location.search);
console.log('player', p.get('user_id'));
interface Identity {
  app_id: string; user_id: string; ts: string; sign: string;
}

const p = new URLSearchParams(location.search);
const identity: Identity = {
  app_id: p.get('app_id') ?? '',
  user_id: p.get('user_id') ?? '',
  ts: p.get('ts') ?? '',
  sign: p.get('sign') ?? '',
};

Sign-in outside the browser: Windows, Linux, Android stores#

A standalone build signs the player in itself — through the system browser, with no password inside the game.

When the game ships as a standalone app — an .exe, a Linux build, an APK for RuStore, Huawei, Xiaomi or Samsung — there is no page URL to carry a signature, and nobody to sign the player's identity. The game's app_key must never be embedded in the build: it can be extracted from any package.

So sign-in follows OAuth 2.0 Authorization Code + PKCE (RFC 7636 and 8252): the game opens zloy.net in the system browser, the player confirms on our domain, the browser hands the game a one-time code, and only then is the code exchanged for tokens. The game never sees the password, and an intercepted code is useless without the secret that stayed in the game's memory.

The SDK does all of it — you write one call: await Zloy.login(). It also detects when the game was launched by the platform itself and then shows the player nothing at all.

Return addresshttp://127.0.0.1:<any port>/… — works on every platform and asks nothing of the build: no intent filter, no Windows registry scheme. The zloy.<game-address>://… scheme is accepted too
If the browser can't returnawait Zloy.login_by_code(): the game shows six characters, the player types them at zloy.net/link
Later launchesno sign-in needed: the SDK keeps a long-lived token in user:// and refreshes access itself
Revoking accessthe player signs the game out any time under Sign-in & security
One account, many gamesthe session lives in the system browser, so the second Zloy game is one tap away

The platform puts app_id and the game address into the package itself (zloy_client.json, appears after the first publish) — you don't add it to the project.

# zloy_sdk.gd is an autoload named "Zloy"

func _ready() -> void:
    if await Zloy.login():
        print("hello, ", Zloy.user.nickname)
        # cubes are authorized by the same sign-in:
        var ws := WebSocketPeer.new()
        ws.connect_to_url(await Zloy.cubes_url())
    else:
        # the player declined — play without an account
        pass

# fallback when the browser could not return to the game
func sign_in_by_code() -> void:
    Zloy.device_code_ready.connect(
        func(code, uri): $Label.text = "Enter %s at %s" % [code, uri])
    await Zloy.login_by_code()

# sign out on this device
func sign_out() -> void:
    await Zloy.logout()
// 1. open in the system browser
GET https://zloy.net/oauth/authorize
      ?response_type=code
      &client_id=<app_id>
      &redirect_uri=http://127.0.0.1:<port>/zloy
      &code_challenge=<base64url(sha256(verifier))>
      &code_challenge_method=S256
      &state=<random>

// 2. the browser returns ?code=…&state=… to the loopback address

// 3. exchange the code for tokens
POST https://zloy.net/oauth/token
  grant_type=authorization_code&code=…&code_verifier=…
  &redirect_uri=…&client_id=…
→ {ok, access_token, expires_in, refresh_token,
   user, app_id, slug, api_base, ws_url}

// 4. from now on: API methods with Authorization: Bearer <access_token>,
//    cubes at wss://api.zloy.net/rt/ws?access_token=…

// refreshing access (the old refresh token is revoked)
POST /oauth/token  grant_type=refresh_token&refresh_token=…
// signing out
POST /oauth/revoke token=<refresh_token>

// sign-in by on-screen code
POST /oauth/device  client_id=<app_id>
→ {device_code, user_code, verification_uri, interval}
POST /oauth/token
  grant_type=urn:ietf:params:oauth:grant-type:device_code
  &device_code=…

SDK setup#

One line for web games; plain HTTP requests for Godot.

The JavaScript SDK attaches the signed parameters to every call for you. Methods return a promise resolving to parsed JSON: {ok: true, …} or {ok: false, error: "…"}.

From Godot it is easier to call REST directly with HTTPRequest — the query string is the same.

Base URL: https://api.zloy.net/v1 (mirror: https://zloy.net/api/v1). All methods are GET, responses are JSON, CORS is open.

extends Node

var query := ""

func _ready() -> void:
    query = JavaScriptBridge.eval("location.search.slice(1)", true)
    call_api("getUserInfo", _on_user_info)

func call_api(method: String, cb: Callable) -> void:
    var req := HTTPRequest.new()
    add_child(req)
    req.request_completed.connect(
        func(_result, _code, _headers, body):
            cb.call(JSON.parse_string(body.get_string_from_utf8()))
            req.queue_free())
    req.request("https://api.zloy.net/v1/%s?%s" % [method, query])

func _on_user_info(res: Dictionary) -> void:
    if res.get("ok"):
        print("hello, ", res.user.nickname)
<script src="https://zloy.net/assets/zloy-sdk.js"></script>
<script>
  const me = await zloy.getUserInfo();
  console.log('hello,', me.user.nickname);

  const friends = await zloy.getUserFriends(0, 100);
  console.log(friends.total, friends.friends);
</script>
import 'https://zloy.net/assets/zloy-sdk.js';

interface User {
  user_id: number; username: string; nickname: string; avatar: string;
}
interface Self extends User {
  lang: 'ru' | 'en';   // account language — the player themselves only
}
interface UserInfo { ok: boolean; user: Self }
interface FriendsPage {
  ok: boolean; total: number; offset: number; limit: number; friends: User[];
}

declare const zloy: {
  getUserInfo(): Promise<UserInfo>;
  getUserFriends(offset?: number, limit?: number): Promise<FriendsPage>;
  getUserFriendsInApp(offset?: number, limit?: number): Promise<FriendsPage>;
  getToken(): Promise<{ok: boolean; token: string; expires_in: number}>;
  postResult(text: string): Promise<{ok: boolean; url: string; seq: number}>;
  pay(description: string, amount: number): Promise<{ok: boolean; error?: string}>;
  rt: RealtimeApi;   // server cubes, see /docs/backend
};

const me: UserInfo = await zloy.getUserInfo();

Player model#

The same shape in every method. nickname is the display name, username is the player's page address (zloy.net/<username>), avatar is an absolute URL or an empty string.

The player themselves (getUserInfo) also gets lang — their account language on the platform, "ru" or "en". Open the game in that language rather than the browser's: the person already chose it on zloy.net and expects the same inside the game. Friend lists carry no such field — somebody else's language is of no use to the game.

{
  "user_id": 42,
  "username": "zloysega",
  "nickname": "Sergey",
  "avatar": "https://zloy.net/uploads/avatars/u42-abc123.png",
  "lang": "ru"        # getUserInfo only
}
{
  "user_id": 42,
  "username": "zloysega",
  "nickname": "Sergey",
  "avatar": "https://zloy.net/uploads/avatars/u42-abc123.png",
  "lang": "ru"        // getUserInfo only
}
interface User {
  user_id: number;
  username: string;
  nickname: string;
  avatar: string;   // '' when not set
}

interface Self extends User {
  lang: 'ru' | 'en';   // account language; the player themselves only
}

API methods#

Every request requires app_id, user_id, ts and sign.

getUserInfoGET#

The profile of the player who opened the game. Calling it on startup is common practice — you usually need the name, avatar and language right away.

lang is the player's account language ("ru" or "en"). It is their own choice on the platform, so it beats the browser and system locale — drive your localisation with it.

call_api("getUserInfo", func(res):
    if res.get("ok"):
        $Name.text = res.user.nickname
        _load_avatar(res.user.avatar)
        TranslationServer.set_locale(res.user.get("lang", "ru")))
const {user} = await zloy.getUserInfo();
nameLabel.textContent = user.nickname;
if (user.avatar) avatarImg.src = user.avatar;
setLocale(user.lang);            // 'ru' | 'en' — the player's choice

// Response:
// { "ok": true, "user": { "user_id": 42, "username": "zloysega",
//   "nickname": "Sergey", "avatar": "…", "lang": "ru" } }
const {user}: UserInfo = await zloy.getUserInfo();
render(user);
i18n.locale = user.lang;         // 'ru' | 'en'

getUserFriendsGET#

The player's friends, paginated: offset (default 0) and limit (default and maximum 100). The response also carries total.

call_api("getUserFriends&offset=0&limit=100", func(res):
    for f in res.friends:
        _add_friend_row(f.nickname, f.avatar))
const {total, friends} = await zloy.getUserFriends(0, 100);
console.log(`friends: ${total}`);

// Response:
// { "ok": true, "total": 152, "offset": 0, "limit": 100,
//   "friends": [ { "user_id": 7, "nickname": "Alice", … } ] }
const page: FriendsPage = await zloy.getUserFriends(0, 100);
page.friends.forEach(renderFriend);

getUserFriendsInAppGET#

Same shape, but only friends who already installed your game. Good for “invite your friends” and for showing who you can play with right now.

call_api("getUserFriendsInApp", func(res):
    $InviteList.set_friends(res.friends))
const {friends} = await zloy.getUserFriendsInApp();
showInviteList(friends);
const {friends}: FriendsPage = await zloy.getUserFriendsInApp();

getUserInstalledAppsGET#

Games the player has installed, paginated like the friend list. Useful for cross-promotion between your own games.

call_api("getUserInstalledApps", func(res):
    for a in res.apps:
        _add_promo(a.name, a.icon, a.url))
const {apps} = await zloy.call('getUserInstalledApps');

// Response:
// { "ok": true, "total": 3, "offset": 0, "limit": 100,
//   "apps": [ { "app_id": 1, "slug": "darkrunner", "name": "Dark Runner",
//     "icon": "https://zloy.net/uploads/appicons/a1-abc.png",
//     "summary": "Run in the dark.",
//     "url": "https://zloy.net/darkrunner" } ] }
interface App {
  app_id: number; slug: string; name: string;
  icon: string; summary: string; url: string;
}
interface AppsPage { ok: boolean; total: number; apps: App[] }

postResultGET#

Publishes a result on the player's own wall. The post appears at zloy.net/<player>/<seq>, where seq is the post number within that player's page (so zloy.net/alice/7 and zloy.net/bob/7 are different posts). The player is the author; the request IP is stored alongside, as required by Russian social-network law.

text is required, up to 280 characters. Posting is allowed one week after the player registered, otherwise the response is posting_locked. An hourly per-player limit guards against spam — rate_limited.

var text := "Scored %d points!" % score
call_api("postResult&text=" + text.uri_encode(), func(res):
    if res.get("ok"):
        print("post: ", res.url)
    elif res.get("error") == "posting_locked":
        pass  # account younger than a week — skip silently
)
const r = await zloy.postResult(`Scored ${score} points!`);
if (r.ok) window.open(r.url, '_blank');

// Response:
// { "ok": true, "post_id": 812, "seq": 34,
//   "url": "https://zloy.net/alice/34" }
const r: {ok: boolean; post_id: number; seq: number; url: string} =
  await zloy.postResult(`Scored ${score} points!`);

payallowlisted#

The SDK shows a “Top up” overlay with a description and an amount in roubles; once the player confirms, a payment is created (GET /v1/createPayment?amount=<₽>&description=…) and the browser follows the returned url to the payment provider.

Payments are available to an allowlisted set of games. If your game is not on the list the call quietly resolves to {ok: false, error: 'payments_not_allowed'} and shows nothing. Check with GET /v1/paymentsAllowed{"ok":true,"allowed":true|false}.

Where the purchase is credited (which bank cube and currency) is configured in the game's settings — see the bank cube.

# The payment overlay lives in the JS SDK; call it from Godot Web:
JavaScriptBridge.eval("zloy.pay('100 crystals', 199)", true)
const r = await zloy.pay('100 crystals', 199);
// r.ok === true       → confirmed, redirecting to the payment provider
// r.error === 'payments_not_allowed' | 'cancelled'
const r: {ok: boolean; error?: 'payments_not_allowed' | 'cancelled'} =
  await zloy.pay('100 crystals', 199);

Verifying the signature on your server#

Only needed if your game has its own backend.

Recompute the signature with your app_key. If it matches, the user_id that came from the client can be trusted.

If you would rather not keep app_key on a third-party server, use the identity token instead: it is verified with the platform's public key, so no shared secret is involved.

import hmac, hashlib

def valid(app_id, user_id, ts, sign, app_key):
    want = hmac.new(app_key.encode(),
                    f"{app_id}:{user_id}:{ts}".encode(),
                    hashlib.sha256).hexdigest()
    return hmac.compare_digest(want, sign)
import {createHmac, timingSafeEqual} from 'node:crypto';

function valid({app_id, user_id, ts, sign}, appKey) {
  const want = createHmac('sha256', appKey)
    .update(`${app_id}:${user_id}:${ts}`).digest('hex');
  return want.length === sign.length &&
    timingSafeEqual(Buffer.from(want), Buffer.from(sign));
}
func valid(appID, userID, ts int64, sign, appKey string) bool {
    mac := hmac.New(sha256.New, []byte(appKey))
    fmt.Fprintf(mac, "%d:%d:%d", appID, userID, ts)
    want := hex.EncodeToString(mac.Sum(nil))
    return hmac.Equal([]byte(want), []byte(sign))
}

Identity token (JWT)#

For your own backend that you do not want to trust with app_key.

zloy.getToken() performs POST https://zloy.net/studio/v1/token with the same signed parameters and returns {"ok":true,"token":"<jwt>","expires_in":300} — JWT — an EdDSA/Ed25519 JWT valid for 5 minutes. The SDK caches and refreshes it automatically.

Claims: iss="https://zloy.net", sub — the player id as a string, aud — your game's address (slug), iat/exp, nickname.

Your server verifies the signature with the platform's public key: GET https://zloy.net/.well-known/jwks.json (JWKS, OKP/Ed25519, alg="EdDSA", stable kid). Validate the signature, exp, iss и aud.

For multiplayer on the platform you do not need this token at all — server cubes verify identity themselves.

// client: fetch the token and hand it to your own server
const {token} = await zloy.getToken();
socket.send(JSON.stringify({auth: token}));
# pip install pyjwt[crypto] requests
import jwt, requests

jwks = requests.get("https://zloy.net/.well-known/jwks.json").json()
key = jwt.PyJWK.from_dict(jwks["keys"][0]).key
claims = jwt.decode(token, key=key, algorithms=["EdDSA"],
                    issuer="https://zloy.net",
                    audience="your-game-slug")
user_id = int(claims["sub"])
import {createRemoteJWKSet, jwtVerify} from 'jose';

const jwks = createRemoteJWKSet(
  new URL('https://zloy.net/.well-known/jwks.json'));

const {payload} = await jwtVerify(token, jwks, {
  issuer: 'https://zloy.net',
  audience: 'your-game-slug',
});
const userId = Number(payload.sub);

Errors#

Responses are always JSON; an error looks like {"ok": false, "error": "code"}:

bad_requestmissing parameters
expiredsignature older than 48 hours — reload the game page
unknown_appunknown app_id
bad_signsignature mismatch (for example, the key was revoked)
unknown_userplayer not found
posting_lockedaccount younger than a week (postResult only)
rate_limitedtoo many requests

Players keep the game tab open for days while a signature lives 48 hours — handle expired with a “reload the page” prompt rather than a generic failure.

call_api("getUserInfo", func(res):
    if not res.get("ok"):
        match res.get("error"):
            "expired": _ask_reload()
            _:         push_warning("API: %s" % res.error))
const r = await zloy.getUserInfo();
if (!r.ok) {
  if (r.error === 'expired') askReload();
  else console.warn('API:', r.error);
}
type ApiError = 'bad_request' | 'expired' | 'unknown_app'
  | 'bad_sign' | 'unknown_user' | 'posting_locked' | 'rate_limited';

const r = await zloy.getUserInfo();
if (!r.ok) handle((r as {error: ApiError}).error);

Limits and rules#

  • A signature is valid for 48 hours; after that the game page must be reloaded.
  • limit in list endpoints is capped at 100 per request.
  • postResult: up to 280 characters, not earlier than a week after the player registered, with an hourly limit.
  • app_key must never ship inside the game: the platform issues signatures, the key stays on the server.
  • Games run on a separate origin, s.zloy.net, so third-party code never shares an origin with the site session.
  • Every version is moderated: files, the allowlist of external domains and the backend configuration.

← Back to games  ·  Server cubes →