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>
// 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.
// 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 in your game and every call is signed.
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: "…"}.
If the SDK does not fit, the same methods work over plain
fetch — the query string is identical.
Base URL: https://api.zloy.net/v1
(mirror: https://zloy.net/api/v1). All methods are GET,
responses are JSON, CORS is open.
<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}>;
sendToWall(opts: {text?: string; image?: HTMLCanvasElement | Blob | File;
video?: Blob | File}):
Promise<{ok: boolean; url: string; seq: number; expires_at: string;
media_status?: string; error?: string; retry_after?: 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
}
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.
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.
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.
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.
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.
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!`);
sendToWallPOST#Puts an achievement on the game's own page
(zloy.net/<game_slug>game): a markdown results
table and a screenshot or a short clip. The post is signed
by the player the call came from, so it also lands in their
friends' feeds. The media joins the page gallery.
text — markdown, up to 4000 characters;
image — a canvas, Blob or
File: we accept png, jpeg, gif, bmp, webp
(up to 4 MB, side up to 1920). video — a
Blob or File in any format the
server can read (webm from MediaRecorder, mp4, mov,
mkv): up to 200 MB and up to 180 seconds. No need to
compress before sending — we will.
A screenshot or a clip, but not both (otherwise
image_and_video): a post card has one place for
media, and the platform will not choose for you which one to
show. At least one of the three — text, image or video — is
required.
The screenshot is always stored as JPEG, whatever you send: a game frame is a photograph by nature, and PNG would keep it five times more expensively with no visible gain. Transparency is lost in the process — whatever was transparent turns black. A clip is always MP4 720p (H.264 + AAC): a game records with whatever is at hand, while the post is watched from someone else's phone via a link in a feed, and it gets no second try. 1080p and 4K are scaled down to 720p — invisible in a feed, three times lighter.
Clips are transcoded in a queue, and the post appears before
the clip does. The reply comes back at once carrying
media_status: "processing"; the clip itself reaches
the post tens of seconds later, and until then the page says it is
being prepared. The platform will not hold your call for all that
time: a minute of waiting is a frozen screen for the player and a
request timed out on your side. Rejections that are visible
immediately (not a video, too long, too large) come back as normal
errors — no waiting needed.
Only games upload media to zloy.net. People cannot — a
stream of arbitrary pictures and clips is something we have no way
to moderate; a game answers for what it posts with its key. Files
are served from a separate domain, s.zloy.net.
The platform stamps the watermark itself — on screenshots and on clips alike: a black plate with the zloy.net icon and your game's address in the bottom-left corner (3% of the frame height). The post travels into friends' feeds and onwards through reposts, and the mark is the only thing that leads back to the game. You do not need to draw your own — but keep the bottom-left corner of the frame free: a score or a button there ends up under the plate.
The reply carries the address of the POST, not of the file.
What gets published is the record: it has an address, a deadline,
reactions and comments, and it is what you show the player. The
image_url and video_url fields are gone —
for a clip the file does not even exist yet when we reply.
A post lives 30 days, after which the platform deletes both
the record and the file (the deadline comes back in
expires_at). A game page is not an archive: last
season's table crowds out today's.
Rate — one post per hour and at most 12 per day per player in
this game. The limit belongs to the game: how much the player
posted in other games does not touch yours, and the other way
round. Over the limit you get rate_limited together
with retry_after in seconds; retrying sooner is
pointless. It is not a failure: say “you can share this later”
rather than showing an error. If the game has no promo page yet the
answer is no_page — there is nowhere to post.
const r = await zloy.sendToWall({
text: `**Season closed!**\n\n| place | player | score |\n|---|---|---|\n| 1 | Alice | 9120 |`,
image: document.querySelector('canvas'), // canvas | Blob | File
});
if (r.ok) console.log(r.url, 'expires', r.expires_at);
else if (r.error === 'rate_limited') laterMsg(r.retry_after);
// Response — about the POST, not the file:
// { "ok": true, "seq": 41,
// "url": "https://zloy.net/darkrunnergame/41",
// "expires_at": "2026-09-16T21:03:11Z" }
// A CLIP — instead of a screenshot. Record it straight off the game canvas:
const stream = document.querySelector('canvas').captureStream(30);
const rec = new MediaRecorder(stream, { mimeType: 'video/webm' });
const parts = [];
rec.ondataavailable = (e) => parts.push(e.data);
rec.start();
setTimeout(() => rec.stop(), 8000); // 8 seconds of the trick
rec.onstop = async () => {
const clip = new Blob(parts, { type: 'video/webm' });
const r = await zloy.sendToWall({ text: 'What a run!', video: clip });
// { "ok": true, "seq": 42, "url": "https://zloy.net/darkrunnergame/42",
// "expires_at": "…", "media_status": "processing" }
// You can show the link right away: the post exists, the clip catches up.
};
interface WallPost {
ok: boolean; seq: number; url: string;
expires_at: string;
media_status?: 'processing'; // the clip is still being transcoded
error?: 'no_page' | 'empty' | 'text_too_long' | 'image_too_large'
| 'image_and_video' | 'video_too_large' | 'video_too_long'
| 'bad_video' | 'video_unsupported'
| 'rate_limited' | 'posting_locked' | 'banned';
retry_after?: number; // seconds, only with rate_limited
}
const r: WallPost = await zloy.sendToWall({ text: 'New record!' });
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.
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, sendToWall) |
rate_limited | too many requests; sendToWall adds retry_after — seconds until you are let in |
no_page | the game has no promo page (sendToWall only) |
image_and_video | both an image and a clip were sent: a post has one place for media — pick one (sendToWall) |
video_too_large | the clip is over 200 MB |
video_too_long | the clip is longer than 180 seconds |
bad_video | what arrived as a clip is not video — the format is determined by parsing, not by the file name |
video_unsupported | clip uploads are currently unavailable on the platform side; screenshots keep working — a sensible fallback |
banned | the game's developer blocked this player — retrying will not help, show a stub |
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.
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'
| 'banned';
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.sendToWall: markdown up to 4000 characters, an
image up to 4 MB or a clip up to 200 MB and 180 seconds (not
both); one post per hour and 12 per day per player in this
game (every game has its own limit); a post lives
30 days.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.