API приложений

Игра открывается внутри платформы с уже подписанной личностью игрока — ни регистрации, ни своего логина не нужно. Отсюда доступны профиль, друзья, публикация результата и платежи.

Нужен мультиплеер, чат, сохранения, рекорды или игровая экономика — они тоже готовы: Zloy Backend, свой сервер писать не придётся.

Как это работает#

Платформа подписывает игрока и передаёт его игре в адресе страницы.

Игра открывается на странице zloy.net/<адрес_игры> внутри iframe. К адресу платформа добавляет четыре параметра — подписанную личность игрока:

app_idидентификатор вашей игры
user_idидентификатор игрока, открывшего игру
tsunix-время выдачи подписи (действует 48 часов)
signHMAC-SHA256(app_key, "app_id:user_id:ts") в hex

Эти параметры передаются в каждый запрос без изменений. Подделать подпись без app_key нельзя; ключ виден только вам на странице редактирования игры, там же его можно отозвать — старые подписи мгновенно перестают действовать.

// Адрес, по которому открылась игра:
// https://ваш-адрес/?app_id=1&user_id=42&ts=1760000000&sign=<hex>

// SDK подхватывает параметры сам — руками их читать не нужно
const p = new URLSearchParams(location.search);
console.log('игрок', 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') ?? '',
};

Вход вне браузера: Windows, Linux, магазины Android#

Отдельное приложение авторизуется само — через системный браузер, без пароля внутри игры.

Когда игра собрана отдельным приложением — .exe, сборка под Linux, APK для RuStore, Huawei, Xiaomi или Samsung — адреса страницы с подписью нет: подписать личность игрока некому. Ключ игры app_key в сборку класть нельзя — его достанут из любого пакета.

Поэтому вход идёт по схеме OAuth 2.0 Authorization Code + PKCE (RFC 7636 и 8252): игра открывает zloy.net в системном браузере, игрок подтверждает вход на нашем домене, браузер возвращает в игру одноразовый код, и уже код меняется на токены. Пароль игра не видит никогда, а перехваченный код бесполезен без секрета, который остался у игры в памяти.

Всё это делает SDK: вам нужен один вызов await Zloy.login(). Он же сам определит, что игра запущена внутри платформы, и тогда не покажет игроку ничего.

Возврат в игруhttp://127.0.0.1:<любой порт>/… — работает на всех платформах и ничего не требует от сборки: ни intent-filter, ни схемы в реестре Windows. Дополнительно разрешена схема zloy.<адрес игры>://…
Если браузер не вернулсяawait Zloy.login_by_code(): игра показывает шесть символов, игрок вводит их на zloy.net/link
Повторные запускивход не требуется: SDK хранит долгий токен в user:// и обновляет доступ сам
Отзыв доступаигрок в любой момент выходит из игры в разделе Вход и безопасность
Один аккаунт — много игрсессия живёт в системном браузере, поэтому во вторую игру Zloy игрок входит одним нажатием

app_id и адрес игры платформа кладёт в пакет сама (файл zloy_client.json, появляется после первой публикации) — добавлять его в проект не нужно.

// 1. открыть в системном браузере
GET https://zloy.net/oauth/authorize
      ?response_type=code
      &client_id=<app_id>
      &redirect_uri=http://127.0.0.1:<порт>/zloy
      &code_challenge=<base64url(sha256(verifier))>
      &code_challenge_method=S256
      &state=<случайное>

// 2. браузер вернёт ?code=…&state=… на локальный адрес

// 3. обменять код на токены
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. дальше: методы API — с Authorization: Bearer <access_token>,
//    кубики — wss://api.zloy.net/rt/ws?access_token=…

// обновление доступа (старый refresh при этом гасится)
POST /oauth/token  grant_type=refresh_token&refresh_token=…
// выход
POST /oauth/revoke token=<refresh_token>

// вход по коду с экрана
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#

Одна строка в игре — и вызовы уже подписаны.

JavaScript SDK добавляет подписанные параметры в каждый вызов сам. Методы возвращают промис с разобранным JSON: {ok: true, …} либо {ok: false, error: "…"}.

Если SDK не подходит, те же методы доступны обычным fetch — строка запроса та же самая.

Базовый адрес: https://api.zloy.net/v1 (зеркало https://zloy.net/api/v1). Все методы — GET, ответы JSON, CORS открыт.

<script src="https://zloy.net/assets/zloy-sdk.js"></script>
<script>
  const me = await zloy.getUserInfo();
  console.log('привет,', 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';   // язык аккаунта — только у самого игрока
}
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;   // Zloy Backend, см. /docs/backend
};

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

Модель игрока#

Одинаковая во всех методах. nickname — отображаемое имя, username — адрес страницы игрока (zloy.net/<username>), avatar — абсолютная ссылка либо пустая строка.

У самого игрока (getUserInfo) есть ещё lang — язык его аккаунта на платформе, "ru" или "en". Открывайте игру на нём, а не по языку браузера: человек уже выбрал язык на zloy.net, и в игре ожидает тот же. В списках друзей поля нет — чужой язык игре не нужен.

{
  "user_id": 42,
  "username": "zloysega",
  "nickname": "Сергей",
  "avatar": "https://zloy.net/uploads/avatars/u42-abc123.png",
  "lang": "ru"        // только в getUserInfo
}
interface User {
  user_id: number;
  username: string;
  nickname: string;
  avatar: string;   // '' если аватар не задан
}

interface Self extends User {
  lang: 'ru' | 'en';   // язык аккаунта; есть только у самого игрока
}

Методы API#

Обязательные параметры каждого запроса — app_id, user_id, ts, sign.

getUserInfoGET#

Профиль игрока, открывшего игру. Спрашивать его при старте — нормальная практика: имя, аватар и язык обычно нужны сразу.

lang — язык аккаунта игрока ("ru" или "en"). Это его выбор на платформе, поэтому он точнее языка браузера и системы: включайте локализацию игры по нему.

const {user} = await zloy.getUserInfo();
nameLabel.textContent = user.nickname;
if (user.avatar) avatarImg.src = user.avatar;
setLocale(user.lang);            // 'ru' | 'en' — выбор игрока на платформе

// Ответ:
// { "ok": true, "user": { "user_id": 42, "username": "zloysega",
//   "nickname": "Сергей", "avatar": "…", "lang": "ru" } }
const {user}: UserInfo = await zloy.getUserInfo();
render(user);
i18n.locale = user.lang;         // 'ru' | 'en'

getUserFriendsGET#

Друзья игрока с пагинацией: offset (по умолчанию 0) и limit (по умолчанию и максимум 100). В ответе есть total — сколько всего.

const {total, friends} = await zloy.getUserFriends(0, 100);
console.log(`друзей: ${total}`);

// Ответ:
// { "ok": true, "total": 152, "offset": 0, "limit": 100,
//   "friends": [ { "user_id": 7, "nickname": "Алиса", … } ] }
const page: FriendsPage = await zloy.getUserFriends(0, 100);
page.friends.forEach(renderFriend);

getUserFriendsInAppGET#

То же самое, но только друзья, которые уже установили вашу игру. Годится для «позови своих» и для показа, с кем можно сыграть прямо сейчас.

const {friends} = await zloy.getUserFriendsInApp();
showInviteList(friends);
const {friends}: FriendsPage = await zloy.getUserFriendsInApp();

getUserInstalledAppsGET#

Игры, которые игрок установил. Пагинация как у друзей. Полезно для кросс-промо между вашими играми.

const {apps} = await zloy.call('getUserInstalledApps');

// Ответ:
// { "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": "Бегай во тьме.",
//     "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#

Публикует результат на стене самого игрока: запись появляется по адресу zloy.net/<игрок>/<seq>, где seq — номер записи в пределах страницы игрока (поэтому zloy.net/alice/7 и zloy.net/bob/7 — разные записи). Автор записи — игрок; вместе с ней сохраняется IP запроса (требование закона о соцсетях).

text — до 280 символов, обязателен. Публиковать можно через неделю после регистрации игрока, иначе ответ posting_locked. Часовой лимит на игрока защищает от спама — rate_limited.

const r = await zloy.postResult(`Набрал ${score} очков!`);
if (r.ok) window.open(r.url, '_blank');

// Ответ:
// { "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(`Набрал ${score} очков!`);

sendToWallPOST#

Кладёт достижение на страницу самой игры (zloy.net/<адрес_игры>game): таблицу результатов в markdown и снимок или короткий ролик. Запись подписана игроком, от чьего имени пришёл вызов, поэтому она же появляется в ленте его друзей. Медиа попадает в галерею страницы.

text — markdown до 4000 символов, imagecanvas, Blob или File: принимаем png, jpeg, gif, bmp, webp (до 4 МБ, сторона до 1920). videoBlob или File в любом формате, который читает сервер (webm с MediaRecorder, mp4, mov, mkv): до 200 МБ и до 180 секунд. Жать перед отправкой не нужно — сожмём мы.

Снимок или ролик, но не оба сразу (иначе image_and_video): в карточке поста место под медиа одно, и выбирать за вас, что показать, платформа не станет. Нужно хотя бы одно из трёх — текст, снимок или ролик.

Хранится снимок всегда как JPEG, чем бы вы его ни прислали: кадр из игры — фотография по природе, и PNG держал бы её впятеро дороже без разницы на глаз. Прозрачность при этом теряется — то, что было прозрачным, станет чёрным. Ролик — всегда MP4 720p (H.264 + AAC): игра пишет тем, что есть под рукой, а смотрят пост с чужого телефона по ссылке из ленты, и второй попытки у него не будет. 1080p и 4K уменьшаются до 720p — в ленте разницы не видно, а весит втрое меньше.

Ролик перекодируется в очереди, и запись появляется раньше него. Ответ приходит сразу и несёт media_status: "processing"; сам ролик доезжает к записи через десятки секунд, а до тех пор на странице написано, что он готовится. Держать ваш вызов всё это время платформа не станет: минута ожидания — это замерший экран у игрока и оборванный по таймауту запрос у вас. Отказы, которые видны сразу (не видео, слишком длинный, слишком большой), приходят обычной ошибкой — их ждать не нужно.

Медиа на zloy.net выкладывает только игра. Человеку загрузка закрыта — поток произвольных картинок и роликов нечем модерировать; за то, что кладёт игра, она отвечает своим ключом. Файлы раздаются с отдельного домена s.zloy.net.

Водяной знак платформа ставит сама — и на снимок, и на ролик: чёрную плашку с иконкой и адресом вашей игры в левом нижнем углу (высота — 3% кадра). Пост уходит в ленту друзей и дальше по пересылкам, и метка — единственное, что приводит по нему обратно в игру. Рисовать свою не нужно, а вот левый нижний угол кадра лучше держать свободным: счётчик или кнопка там окажутся под плашкой.

В ответе — адрес ЗАПИСИ, а не файла. Публикуется запись: у неё адрес, срок, реакции и комментарии, и именно её показывают игроку. Полей image_url и video_url больше нет — у ролика в момент ответа файла ещё и не существует.

Запись живёт 30 суток, дальше платформа удаляет и её, и файл (срок возвращается в expires_at). Страница игры — не архив: таблица прошлого сезона вытесняет сегодняшнюю.

Частота — пост в час и не больше 12 в сутки на игрока в этой игре. Лимит принадлежит игре: сколько игрок постил в других, вашей не касается, и наоборот. Перебор — rate_limited вместе с retry_after в секундах; повторять раньше бессмысленно. Это не поломка: покажите «поделиться можно будет позже», а не ошибку. Если у игры ещё нет промо-страницы, ответ — no_page: постить некуда.

const r = await zloy.sendToWall({
  text: `**Сезон закрыт!**\n\n| место | игрок | очки |\n|---|---|---|\n| 1 | Алиса | 9120 |`,
  image: document.querySelector('canvas'),   // canvas | Blob | File
});

if (r.ok) console.log(r.url, 'исчезнет', r.expires_at);
else if (r.error === 'rate_limited') laterMsg(r.retry_after);

// Ответ — про ЗАПИСЬ, а не про файл:
// { "ok": true, "seq": 41,
//   "url": "https://zloy.net/darkrunnergame/41",
//   "expires_at": "2026-09-16T21:03:11Z" }

// РОЛИК — вместо снимка. Записать его прямо с холста игры:
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 секунд трюка
rec.onstop = async () => {
  const clip = new Blob(parts, { type: 'video/webm' });
  const r = await zloy.sendToWall({ text: 'Вот это проход!', video: clip });
  // { "ok": true, "seq": 42, "url": "https://zloy.net/darkrunnergame/42",
  //   "expires_at": "…", "media_status": "processing" }
  // Ссылку можно показывать сразу: запись уже есть, ролик подтянется к ней.
};
interface WallPost {
  ok: boolean; seq: number; url: string;
  expires_at: string;
  media_status?: 'processing';   // ролик ещё перекодируется
  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;   // секунды, только при rate_limited
}

const r: WallPost = await zloy.sendToWall({ text: 'Рекорд!' });

payпо допуску#

SDK показывает оверлей «Пополнить игру» с описанием и суммой в рублях; после подтверждения создаётся платёж (GET /v1/createPayment?amount=<₽>&description=…) и браузер переходит по полученному url в платёжную систему.

Платежи доступны ограниченному списку игр. Если игра не в списке, вызов тихо возвращает {ok: false, error: 'payments_not_allowed'} и ничего не показывает. Проверка допуска — GET /v1/paymentsAllowed{"ok":true,"allowed":true|false}.

Куда зачислять купленное (какой банк и валюта) настраивается в свойствах игры — см. кубик «Банк».

const r = await zloy.pay('100 кристаллов', 199);
// r.ok === true       → игрок подтвердил, идёт переход в платёжную систему
// r.error === 'payments_not_allowed' | 'cancelled'
const r: {ok: boolean; error?: 'payments_not_allowed' | 'cancelled'} =
  await zloy.pay('100 кристаллов', 199);

Проверка подписи на вашем сервере#

Нужна, только если у игры есть собственный бэкенд.

Пересчитайте подпись своим app_key — сойдётся, значит user_id, пришедшему от клиента, можно доверять.

Не хотите держать app_key на стороннем сервере — используйте токен личности: там проверка публичным ключом платформы, общий секрет не нужен.

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

Токен личности (JWT)#

Для своего бэкенда, которому вы не доверяете app_key.

zloy.getToken() делает POST https://zloy.net/studio/v1/token с теми же подписанными параметрами и возвращает {"ok":true,"token":"<jwt>","expires_in":300} — JWT (EdDSA/Ed25519) на 5 минут. SDK кеширует его и обновляет сам.

Claims: iss="https://zloy.net", sub — id игрока строкой, aud — адрес (slug) вашей игры, iat/exp, nickname.

Ваш сервер проверяет подпись публичным ключом платформы: GET https://zloy.net/.well-known/jwks.json (JWKS, ключ OKP/Ed25519, alg="EdDSA", стабильный kid). Проверяйте подпись, exp, iss и aud.

Для мультиплеера на платформе токен не нужен: кубики проверяют личность сами.

// клиент: получить токен и отдать своему серверу
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="адрес-вашей-игры")
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: 'адрес-вашей-игры',
});
const userId = Number(payload.sub);

Ошибки#

Ответ всегда JSON; ошибка — {"ok": false, "error": "код"}:

bad_requestне хватает параметров
expiredподпись старше 48 часов — перезагрузите страницу игры
unknown_appнеизвестный app_id
bad_signподпись не сошлась (например, ключ отозван)
unknown_userигрок не найден
posting_lockedаккаунт моложе недели (postResult, sendToWall)
rate_limitedслишком часто; у sendToWall рядом лежит retry_after — через сколько секунд пустят
no_pageу игры нет промо-страницы (только sendToWall)
image_and_videoприслали и снимок, и ролик: место под медиа в посте одно — выберите что-то одно (sendToWall)
video_too_largeролик больше 200 МБ
video_too_longролик длиннее 180 секунд
bad_videoпод видом ролика приехало не видео — формат определяется разбором, а не именем файла
video_unsupportedприём роликов сейчас недоступен на стороне платформы; снимок в этот момент работает — это осмысленный запасной путь
bannedразработчик игры закрыл этому игроку доступ — повтор не поможет, покажите заглушку

Вкладку с игрой держат открытой сутками, а подпись живёт 48 часов — обработайте expired предложением обновить страницу, а не общей ошибкой.

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

Лимиты и правила#

  • Подпись действует 48 часов, дальше — перезагрузка страницы игры.
  • limit в списках — максимум 100 за запрос.
  • postResult: до 280 символов, не раньше недели с регистрации игрока, с часовым лимитом.
  • sendToWall: markdown до 4000 символов, картинка до 4 МБ либо ролик до 200 МБ и 180 секунд (не оба сразу); пост в час и 12 в сутки на игрока в этой игре (лимит у каждой игры свой); запись живёт 30 суток.
  • app_key не должен попадать в код игры: подписи выдаёт платформа, ключ живёт только на сервере.
  • Игра исполняется на отдельном домене s.zloy.net — чужой код не делит origin с сессией сайта.
  • Каждая версия игры проходит модерацию: файлы, список разрешённых внешних доменов и настройки бекенда.

← К играм  ·  Zloy Backend →

Ссылка на конкретное место в игре

К адресу игры можно дописать ?start=…https://zloy.net/supergame?start=234678. Значение доезжает до игры, и по нему она открывает то место, ради которого ссылку и прислали: ферму друга, комнату, приглашение.

Это единственный параметр адреса, который игра получает. Остальные платформа отбрасывает: пробросить их значило бы дать приславшему ссылку дописать туда подпись личности игрока или флаг черновика.

Значение проверяет платформа: латинские буквы, цифры, ., -, _, не длиннее 64 символов. Всё остальное молча отбрасывается — игра получит пустую строку, а не ошибку: по ссылке переходит человек, и «неверный параметр» вместо игры было бы худшим ответом.

Проверять значение всё равно обязана игра. Платформа отвечает только за то, что в нём нет опасных символов, — но не за то, что такая ферма или комната у вас есть. Ссылку пришлёт кто угодно и с чем угодно внутри разрешённого набора.

if (zloy.start) {
  const room = rooms.find(r => r.id === zloy.start);  // ПРОВЕРЯЕМ, что такая есть
  if (room) enterRoom(room); else showLobby();
} else {
  showLobby();
}