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>

# Godot Web: строку параметров берём из адреса страницы
var query := JavaScriptBridge.eval("location.search.slice(1)", true)

func api_url(method: String) -> String:
    return "https://api.zloy.net/v1/%s?%s" % [method, query]
// Адрес, по которому открылась игра:
// 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, появляется после первой публикации) — добавлять его в проект не нужно.

# zloy_sdk.gd подключён автозагрузкой под именем «Zloy»

func _ready() -> void:
    if await Zloy.login():
        print("привет, ", Zloy.user.nickname)
        # кубики авторизуются тем же входом:
        var ws := WebSocketPeer.new()
        ws.connect_to_url(await Zloy.cubes_url())
    else:
        # игрок отказался — играем без аккаунта
        pass

# запасной вход, если браузер не смог вернуться в игру
func sign_in_by_code() -> void:
    Zloy.device_code_ready.connect(
        func(code, uri): $Label.text = "Введите %s на %s" % [code, uri])
    await Zloy.login_by_code()

# выход на этом устройстве
func sign_out() -> void:
    await Zloy.logout()
// 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#

Для веб-игр — одна строка; для Godot — обычные HTTP-запросы.

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

Из Godot удобнее звать REST напрямую через HTTPRequest — строка запроса та же самая.

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

extends Node

var query := ""

func _ready() -> void:
    query = JavaScriptBridge.eval("location.search.slice(1)", true)
    call_api("getUserInfo", _on_user_info)

func call_api(method: String, cb: Callable) -> void:
    var req := HTTPRequest.new()
    add_child(req)
    req.request_completed.connect(
        func(_result, _code, _headers, body):
            cb.call(JSON.parse_string(body.get_string_from_utf8()))
            req.queue_free())
    req.request("https://api.zloy.net/v1/%s?%s" % [method, query])

func _on_user_info(res: Dictionary) -> void:
    if res.get("ok"):
        print("привет, ", res.user.nickname)
<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}>;
  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
}
{
  "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"). Это его выбор на платформе, поэтому он точнее языка браузера и системы: включайте локализацию игры по нему.

call_api("getUserInfo", func(res):
    if res.get("ok"):
        $Name.text = res.user.nickname
        _load_avatar(res.user.avatar)
        TranslationServer.set_locale(res.user.get("lang", "ru")))
const {user} = await zloy.getUserInfo();
nameLabel.textContent = user.nickname;
if (user.avatar) avatarImg.src = user.avatar;
setLocale(user.lang);            // 'ru' | 'en' — выбор игрока на платформе

// Ответ:
// { "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 — сколько всего.

call_api("getUserFriends&offset=0&limit=100", func(res):
    for f in res.friends:
        _add_friend_row(f.nickname, f.avatar))
const {total, friends} = await zloy.getUserFriends(0, 100);
console.log(`друзей: ${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#

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

call_api("getUserFriendsInApp", func(res):
    $InviteList.set_friends(res.friends))
const {friends} = await zloy.getUserFriendsInApp();
showInviteList(friends);
const {friends}: FriendsPage = await zloy.getUserFriendsInApp();

getUserInstalledAppsGET#

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

call_api("getUserInstalledApps", func(res):
    for a in res.apps:
        _add_promo(a.name, a.icon, a.url))
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.

var text := "Набрал %d очков!" % score
call_api("postResult&text=" + text.uri_encode(), func(res):
    if res.get("ok"):
        print("запись: ", res.url)
    elif res.get("error") == "posting_locked":
        pass  # аккаунт моложе недели — молча пропускаем
)
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} очков!`);

payпо допуску#

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

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

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

# Платёжная панель живёт в JS SDK; из Godot Web зовём через мост:
JavaScriptBridge.eval("zloy.pay('100 кристаллов', 199)", true)
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)
rate_limitedслишком часто

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

call_api("getUserInfo", func(res):
    if not res.get("ok"):
        match res.get("error"):
            "expired": _ask_reload()
            _:         push_warning("API: %s" % res.error))
const r = await zloy.getUserInfo();
if (!r.ok) {
  if (r.error === 'expired') askReload();
  else console.warn('API:', r.error);
}
type ApiError = 'bad_request' | 'expired' | 'unknown_app'
  | 'bad_sign' | 'unknown_user' | 'posting_locked' | 'rate_limited';

const r = await zloy.getUserInfo();
if (!r.ok) handle((r as {error: ApiError}).error);

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

  • Подпись действует 48 часов, дальше — перезагрузка страницы игры.
  • limit в списках — максимум 100 за запрос.
  • postResult: до 280 символов, не раньше недели с регистрации игрока, с часовым лимитом.
  • app_key не должен попадать в код игры: подписи выдаёт платформа, ключ живёт только на сервере.
  • Игра исполняется на отдельном домене s.zloy.net — чужой код не делит origin с сессией сайта.
  • Каждая версия игры проходит модерацию: файлы, список разрешённых внешних доменов и настройки бекенда.

← К играм  ·  Zloy Backend →