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).
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_id | your game's identifier |
user_id | the player who opened the game |
ts | unix time the signature was issued (valid for 48 hours) |
sign | HMAC-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') ?? '',
};
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 address | http://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 return | await Zloy.login_by_code(): the game shows six characters, the player types them at zloy.net/link |
| Later launches | no sign-in needed: the SDK keeps a long-lived token in user:// and refreshes access itself |
| Revoking access | the player signs the game out any time under Sign-in & security |
| One account, many games | the 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=…
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();
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
}
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);
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))
}
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);
Responses are always JSON; an error looks like {"ok": false, "error": "code"}:
bad_request | missing parameters |
expired | signature older than 48 hours — reload the game page |
unknown_app | unknown app_id |
bad_sign | signature mismatch (for example, the key was revoked) |
unknown_user | player not found |
posting_locked | account younger than a week (postResult only) |
rate_limited | too 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);
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.s.zloy.net, so
third-party code never shares an origin with the site session.