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 |
The platform also adds lang — the player's language
(ru or en); it is not part of the
signature, see “Game language”.
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
premium: boolean; // premium account: show no ads
}
interface UserInfo { ok: boolean; user: Self }
interface FriendsPage {
ok: boolean; total: number; offset: number; limit: number; friends: User[];
}
declare const zloy: {
readonly lang: 'ru' | 'en'; // game language, see #lang
readonly isGuest: boolean; // opened without signing in
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; text_en?: string;
image?: HTMLCanvasElement | Blob | File;
video?: Blob | File;
onProgress?: (p: {loaded: number; total: number;
fraction: number}) => void}):
Promise<{ok: boolean; url: string; seq: number; expires_at: string;
media_status?: string; error?: string; retry_after?: number}>;
wallErrorText(res: {ok: boolean; error?: string; retry_after?: number},
lang?: 'ru' | 'en'): string;
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.
And premium — the zloy.net account's premium status, one
for all platforms (website, Telegram, Android). For a premium player
the game shows no ads. A guest (not signed in) is never premium.
{
"user_id": 42,
"username": "zloysega",
"nickname": "Sergey",
"avatar": "https://zloy.net/uploads/avatars/u42-abc123.png",
"lang": "ru", // getUserInfo only
"premium": false // 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
premium: boolean; // premium account; the player themselves only
}
zloy.lang — 'ru' or 'en', right away and for guests too.
The platform puts the player's language into the frame URL as the
lang parameter: for a signed-in player it is their
account language, for a guest (the game is opened without signing
in, zloy.isGuest === true) it is their browser's
language. You do not need to wait for getUserInfo —
and a guest has no profile for it to return anyway.
In the Android store package the language is always the device
language, for a signed-in player too: the package's own windows
speak the phone's language, and the game must match them. There
getUserInfo also returns the device language in
user.lang.
The SDK exposes it as zloy.lang and takes it from:
the lang URL parameter → the language in the
getUserInfo response → the browser language →
'en'. The value is read-only.
The guest response of getUserInfo is
{ok: false, error: "guest", lang}: a game that takes
the language from the profile still gets a language for a guest,
not nothing.
lang is not part of the signature: it is still
verified over the four parameters.
// A guest's frame URL:
// https://your-address/?guest=1&v=7&slug=mygame&lang=en
setLocale(zloy.lang); // 'ru' | 'en' — right away, before any request
if (zloy.isGuest) {
startWithBots(); // guests have no online features
}
declare const zloy: {
readonly lang: 'ru' | 'en';
readonly isGuest: boolean;
};
i18n.locale = zloy.lang;
Only on the game's request: a switch in Zloy Studio, plus moderation.
By default the game frame gets neither microphone nor camera:
getUserMedia fails at once, and the browser does not
even ask the player. For a game that needs them (pronunciation,
voice chat, photos) the developer turns them on in Zloy Studio:
the «Управление» (Manage) tab → «Микрофон и камера» (Microphone
and camera).
The switches work like the game's domain list: publishing carries
them into the draft version, the moderator sees them in the queue,
and players get them together with the approved version. In the
draft (?dev) they work at once — for the developer and
testers.
A switch only lets the game ask: the browser, Telegram or the phone still show the player their own prompt, and the player may refuse. The game must survive that — carry on without voice instead of hanging.
The Android build from Zloy Studio adds the permission to the manifest by the same switches and asks the player for it when the game first requests it.
async function listen() {
try {
const stream = await navigator.mediaDevices
.getUserMedia({ audio: true });
startRecognition(stream);
} catch (e) {
// NotAllowedError — the player refused or
// the microphone is off in the game settings.
showTypingInstead();
}
}
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. It is simpler to drive
localisation with zloy.lang: the
same language, but right away, without a request, and for guests too.
const {user} = await zloy.getUserInfo();
nameLabel.textContent = user.nickname;
if (user.avatar) avatarImg.src = user.avatar;
setLocale(zloy.lang); // 'ru' | 'en' — the same language as user.lang
// Response:
// { "ok": true, "user": { "user_id": 42, "username": "zloysega",
// "nickname": "Sergey", "avatar": "…", "lang": "ru",
// "premium": false } }
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.
The list includes friends with a public profile and friends who installed your game. A friend with a private profile who never played is left out: the game gets no data about them without their consent.
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.
text_en — the same post in English
(optional, also up to 4000 characters). Send both and the post is
bilingual: readers see the text in their language with a
ru | en switch, and text is taken as
Russian. Only text_en — the post is English.
No links and no code in the text. You may only link to
zloy.net and *.zloy.net (the page, the
game site); a URL with a scheme to another domain,
www., a bare domain like something.com,
t.me/… and an @name mention of five or
more characters are refused with bad_text and
reason: "link". HTML tags, ``` blocks,
backticks and javascript: — bad_text
with reason: "code". The post is signed by the player
and goes to their friends' feeds, and with the page owner's
permission to the owner's channels too: someone else's address
under a record in a real person's name is exactly what must not
happen here. Tables, bold, italics, versions like
"v1.2" and scores like "3.5" pass. postResult follows
the same rule.
Cross-posting. If the page owner has linked Telegram or MAX channels and allowed game posts there in Zloy Studio, the post goes there too — with its media (a clip already transcoded and watermarked) and a link to the game. You don't need to do anything for it, and by default game posts don't go to channels.
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.
Until the first approved release the page is closed and the
answer is not_released: nothing from the draft is
posted there, including by the game. Nothing is posted from the
sandbox (dev=1) either, even for a released game —
the answer is sandbox: otherwise testing the button
would land on the live page and in friends' feeds. Test
everything up to the send in the sandbox, and the send itself in
the published version.
Upload progress — onProgress. A clip is tens
of megabytes; on a poor connection that is minutes, and without a
bar the player decides the game has frozen. The handler gets
{loaded, total, fraction}, fraction from 0
to 1. Once it reaches 1 the file is on the server, but the reply
comes a few seconds later: the server is still checking the clip.
Show “processing” meanwhile, not a frozen 100%.
A failure comes back as a reply, not an exception: a
dropped connection is network, a reply from something
on the way rather than our server is http_error.
The reason in words comes from
zloy.wallErrorText(r, lang) — in Russian or English,
for rate_limited already with “how long to wait”.
Show the player that, not the code.
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,
onProgress: (p) => ring.set(p.fraction), // upload ring by the button
});
if (!r.ok) reason.textContent = zloy.wallErrorText(r, me.lang);
// { "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?: 'sandbox' | 'no_page' | 'not_released' | 'empty' | 'text_too_long' | 'image_too_large'
| 'image_and_video' | 'video_too_large' | 'video_too_long'
| 'bad_video' | 'video_unsupported' | 'bad_text'
| 'rate_limited' | 'posting_locked' | 'banned'
| 'network' | 'aborted' | 'http_error'; // failure on the way, not a server reply
reason?: 'link' | 'code'; // only with bad_text
retry_after?: number; // seconds, only with rate_limited
}
const r: WallPost = await zloy.sendToWall({ text: 'New record!' });
buyallowlisted#An in-game purchase. The game names the item and its price in Zloy
Coin — and that is all it does: the platform draws the window with
the amount and consent, charges the player and puts the item into
the game bank as a currency
named after the item. The result comes as a
zloy-purchase event on the game's
window.
In Telegram (the game runs in a bot,
zloy.platform === 'tg') the same call is paid in
Stars: the Stars price comes from the developer's rate and
prices in the Studio (“External platforms”). The game changes
nothing.
In MAX (zloy.platform === 'max') there is no
in-messenger payment: the platform opens the zloy.net payment page
in a browser, and the game immediately gets
zloy-purchase with paid: false, status:
'external' — the decision happens elsewhere. An item paid
on the website lands in the game bank.
The truth about a purchase lives in the game bank: the window can be closed and the connection can drop. Check the item balance, not just the event.
window.addEventListener('zloy-purchase', e => {
if (e.detail.paid) giveItem(e.detail.item_id, e.detail.count);
});
// item "gem", "Gem", 10 Zloy Coin each, 3 pieces
await zloy.buy('gem', 'Gem', 10, 3);
type Purchase = {type: 'zloy-purchase'; paid: boolean;
item_id?: string; count?: number; error?: string};
window.addEventListener('zloy-purchase', (e: CustomEvent<Purchase>) => {
if (e.detail.paid) giveItem(e.detail.item_id!, e.detail.count!);
});
await zloy.buy('gem', 'Gem', 10, 3);
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}.
In Telegram pay returns {ok: false, error:
'use_buy'}: leaving for a payment system is forbidden there by
Telegram rules — purchases go through buy.
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 |
sandbox | a sandbox call: nothing is posted to the game page from there, even for a released game (sendToWall only) |
bad_text | the text has a link outside zloy.net (reason: "link") or code (reason: "code") — sendToWall, postResult |
no_page | the game has no promo page (sendToWall only) |
not_released | the game hasn't been released yet: the page is closed until the first approved release (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.