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>

// 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.

// 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 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();

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
}
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.

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);

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, sendToWall)
rate_limitedtoo many requests; sendToWall adds retry_after — seconds until you are let in
no_pagethe game has no promo page (sendToWall only)
image_and_videoboth an image and a clip were sent: a post has one place for media — pick one (sendToWall)
video_too_largethe clip is over 200 MB
video_too_longthe clip is longer than 180 seconds
bad_videowhat arrived as a clip is not video — the format is determined by parsing, not by the file name
video_unsupportedclip uploads are currently unavailable on the platform side; screenshots keep working — a sensible fallback
bannedthe 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);

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.
  • 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.
  • 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 →