Публичный API

/public/v1 — доступ к вашим же данным в кабинете фермы из вашего кода: учётки, задания, дропы, матчи, слоты. Ключ действует от вашего имени; подаккаунтов, тенантов и OAuth-согласия здесь нет.

Адрес: https://split-team.com/public/v1

Через API можно поставить только ручной прогон cs2_claim_drop — забор дропа. Матчмейкингом (cs2_farm) управляют только задания (/tasks): они распределяют слоты между учётками в заданном владельцем порядке, и прямая постановка в обход них здесь недоступна.

Ключ доступа

Каждый запрос несёт заголовок Authorization: Bearer sft_…. Кука кабинета farm_session на /public/v1 не принимается вовсе: браузер не может прийти сюда кукой, и CSRF здесь невозможен по построению.

Ключ выпускается в кабинете, Настройки → API.

GET /public/v1/whoami работает с любым действующим ключом, даже без единого скоупа — им можно спросить, чей это ключ и что ему разрешено.

Скоупы

Ключ выпускается с одним или несколькими скоупами. Операция, для которой скоупа не хватает, отвечает 403 insufficient_scope со списком недостающих скоупов в поле error.missing.

СкоупЧто даёт
account:read
Аккаунт: чтениеВидеть слоты, подписки и неделю дропа.
bots:read
Учётки: чтениеВидеть учётки, их дропы, матчи и прогоны.
bots:write
Учётки: изменениеМенять метку и группы учёток, удалять их, отменять прогоны и ставить ручной забор дропа.
bots:credentials
Учётки: паролиДобавлять учётки и менять их пароль и shared secret. Прочитать пароль этим ключом нельзя.
tasks:read
Задания: чтениеВидеть задания и их состав.
tasks:write
Задания: изменениеСоздавать и менять задания.

Пагинация

Списки — курсорные: limit (1–200, по умолчанию 50) и cursor — непрозрачное значение из next_cursor предыдущей страницы. Ответ — объект с полями items и next_cursor; на последней странице поля next_cursor нет вовсе.

У части списков (дропы учётки, наблюдения дропа, матчи) под курсором лежат только последние записи — сколько именно, названо в описании операции в справочнике ниже.

Idempotency-Key

POST /bots, /bots/batch, /tasks и /jobs принимают заголовок Idempotency-Key — от 1 до 255 печатных ASCII-символов, хранится 24 часа. Повтор с тем же телом отдаёт сохранённый ответ первой попытки (заголовок Idempotent-Replayed: true); тот же ключ с другим телом — 422 idempotency_key_reused; повтор, пока первый запрос ещё выполняется — 409 idempotency_in_progress.

Заголовок необязателен для одиночных запросов, но обязателен для пачек: без него повтор из-за обрыва сети может создать дубли учёток.

Открытые перечисления

Состояния учётки, причины остановки прогона, типы событий и коды отказа могут получить новое значение без смены версии /public/v1. Клиент обязан пережить незнакомое значение как неизвестное — это не ошибка разбора.

Лимит частоты

10 запросов в секунду на владельца — общий лимит на все его ключи сразу, всплеск 30. POST /bots/batch ограничена отдельно: 1 запрос в 5 секунд. RateLimit-Limit и RateLimit-Remaining приходят в каждом ответе на запрос с действующим ключом; превышение — 429 rate_limited, и только в нём есть RateLimit-Reset и Retry-After (секунды до следующей попытки).

Значения не измерены и могут измениться.

Коды отказа

Тело ошибки — объект error с полями code, message и, у отказа по скоупу, missing, плюс request_id — тот же request_id приходит и заголовком X-Request-Id. Набор кодов открытый: ниже — то, чем сервис отвечает сегодня.

  • invalid_api_key
  • insufficient_scope
  • rate_limited
  • internal
  • bad_idempotency_key
  • idempotency_key_reused
  • idempotency_in_progress
  • not_found
  • bad_limit
  • bad_cursor
  • bad_request
  • method_not_allowed
  • job_type_not_allowed
  • bad_game_id
  • bad_label
  • bad_steam_username
  • bad_steam_password
  • bad_steam_shared_secret
  • bad_steam_identity_secret
  • duplicate_steam_username
  • bot_limit
  • bad_batch_size
  • bad_name
  • bad_webhook_url
  • duplicate_name
  • bad_bot_id
  • bot_group_mismatch
  • field_not_supported
  • bad_config
  • unknown_type
  • type_not_live
  • bad_job_data
  • bot_busy
  • bot_needs_relogin
  • bot_quarantined
  • not_cancellable
  • bad_schedule
  • bad_minutes
  • no_targets
  • bad_target
  • duplicate_target
  • target_not_found
  • bot_in_other_task
  • bad_state

Формат данных

  • 64-битные идентификаторы (steam_id, item_id) приходят строкой — числом они не помещаются в JS.
  • Деньги — целое число центов в поле вида *_cents плюс currency.
  • Время — RFC 3339 в UTC.
  • Пароль Steam, общий секрет и секрет идентичности — только на запись: ни один ответ /public/v1 их не возвращает, даже сразу после того, как вы их прислали.

Операции

Справочник порождён из спеки /public/v1: метод, путь, нужный скоуп и краткое описание. Если у операции есть параметры или тело запроса, они показаны под строкой.

  • GET/public/v1/whoami
    любой ключ

    Ключ и его владелец.

    Работает с любым действующим ключом, независимо от скоупов.

  • GET/public/v1/bots
    bots:read

    Список учёток владельца, курсорная пагинация.

    Строка запроса: limit, cursor, status, group_id, task_id

  • POST/public/v1/bots
    bots:credentials

    Добавить учётку Steam.

    Тело: PubBotCreate

    Отправляйте заголовок Idempotency-Key. Повтор без него может создать дубль или вернуть duplicate_steam_username на строку, которую вы уже создали сами. Пароль, общий секрет и секрет идентичности шифруются перед записью. Ни один ответ их не возвращает.

  • POST/public/v1/bots/batch
    bots:credentials

    Добавить пачку учёток Steam одним запросом.

    Тело: PubBotBatchCreate

    Отправляйте Idempotency-Key по той же причине, что и у одиночного добавления. Каждая строка живёт и падает отдельно: плохая строка не отменяет остальные, общей транзакции на пачку нет. duplicate_in_batch виден только внутри одного запроса. Уберите повторы steam_username по всему файлу до того, как резать его на пачки: иначе повтор, разделённый между двумя запросами, получит на второй строке duplicate_steam_username («уже добавлена, пропустите») вместо правды «она дважды в вашем файле».

  • GET/public/v1/bots/{id}
    bots:read

    Одна учётка владельца.

    Путь: id

  • PATCH/public/v1/bots/{id}
    bots:write

    Переименовать учётку.

    Путь: id · Тело: PubBotUpdate

    Здесь можно изменить только label. Учётные данные меняются через PUT .../credentials. Незнакомое поле игнорируется, а не отклоняется: гарантия в том, что больше в учётке ничего не меняется.

  • DELETE/public/v1/bots/{id}
    bots:write

    Отвязать учётку и стереть её учётные данные.

    Путь: id

    Стирание данных и удаление учётки происходят одной транзакцией.

  • PUT/public/v1/bots/{id}/credentials
    bots:credentials

    Заменить пароль Steam и/или общий секрет учётки.

    Путь: id · Тело: PubBotCredentials

    Нужно хотя бы одно из двух полей. Пустое тело отвечает bad_request. Заодно снимает needs_relogin в pending_login: это единственное действие, которого ждёт такой статус.

  • GET/public/v1/bots/{id}/drops
    bots:read

    Предметы учётки, сначала новые.

    Путь: id · Строка запроса: limit, cursor

    Отдаёт не больше 500 последних предметов, дальше курсорная пагинация.

  • GET/public/v1/bots/{id}/drop-offers
    bots:read

    Журнал предложений дропа учётки, сначала новые.

    Путь: id · Строка запроса: limit, cursor

    Отдаёт не больше 200 последних наблюдений, дальше курсорная пагинация.

  • GET/public/v1/bots/{id}/matches
    bots:read

    Завершённые матчи учётки, сначала новые.

    Путь: id · Строка запроса: limit, cursor

    Отдаёт не больше 200 последних матчей, дальше курсорная пагинация.

  • GET/public/v1/account
    account:read

    Занятость слотов и действующие подписки.

  • GET/public/v1/account/overview
    account:read

    Итоги дропа за неделю по всем предметам владельца.

  • GET/public/v1/groups
    bots:read

    Список групп владельца, курсорная пагинация.

    Строка запроса: limit, cursor

  • POST/public/v1/groups
    bots:write

    Создать группу.

    Тело: PubGroupCreate

    У группы есть только имя и список участников, настроек в ней нет. Каждая группа, созданная через /public/v1, относится к CS2, как и учётки.

  • GET/public/v1/groups/{id}
    bots:read

    Одна группа владельца.

    Путь: id

  • PATCH/public/v1/groups/{id}
    bots:write

    Переименовать группу.

    Путь: id · Тело: PubGroupUpdate

  • DELETE/public/v1/groups/{id}
    bots:write

    Удалить группу со всем составом.

    Путь: id

    Строки членства удаляются каскадом, отдельный шаг не нужен. Сами учётки при этом не затрагиваются.

  • PUT/public/v1/groups/{id}/members/{bot_id}
    bots:write

    Добавить учётку в группу.

    Путь: id, bot_id

    Владение и группой, и учёткой проверяется перед добавлением. Чужой bot_id и id, который вообще ни на что не ссылается, отвечают одинаково: 404 not_found. 409 в этом месте подтвердил бы существование чужой учётки. 409 зарезервирован для одного случая: это ваша учётка, но другой игры, чем группа. Через /public/v1 это недостижимо сегодня, потому что каждая группа и учётка, созданные им, относятся к CS2; код оставлен для учёток и групп, заведённых до этого API. Повторное добавление уже состоящей в группе учётки идемпотентно.

  • DELETE/public/v1/groups/{id}/members/{bot_id}
    bots:write

    Убрать учётку из группы.

    Путь: id, bot_id

  • GET/public/v1/jobs
    bots:read

    Список прогонов владельца, курсорная пагинация.

    Строка запроса: limit, cursor, bot_id, state

    Плоский список по учёткам: учётка с активным и последним завершённым прогоном даёт до двух записей. Показывает активный и последний прогон каждой учётки любого типа, включая cs2_farm, поставленный заданием, а не только записи, созданные через этот API. Создать через /public/v1 можно только ручной забор дропа (см. POST /public/v1/jobs), но этот список всё равно показывает все прогоны, какие у учётки есть.

  • POST/public/v1/jobs
    bots:write

    Создать ручной прогон забора дропа.

    Тело: PubJobCreate

    Принимается только type: cs2_claim_drop. cs2_farm (матчмейкинг) отвечает 400 job_type_not_allowed ещё до поиска учётки: этой работой управляют задания, которые распределяют слоты между учётками, и прямая постановка здесь позволила бы обойти их очередь. Отправляйте Idempotency-Key.

  • GET/public/v1/jobs/capabilities
    bots:read

    Поля, которые принимает настройка задания.

    Всегда реестр CS2, потому что /public/v1 сегодня не работает ни с одной другой игрой и параметра игры для выбора нет. Эти поля относятся к config задания (PubTaskInput.config), а не к телу постановки прогона. Прогон, созданный через /public/v1, всегда cs2_claim_drop, и у него нет настроек.

  • POST/public/v1/jobs/{id}/cancel
    bots:write

    Отменить прогон.

    Путь: id

    Отменённый прогон не возвращается в очередь и не тратит попытку. Раннер узнаёт об отмене на следующем heartbeat, не мгновенно.

  • GET/public/v1/tasks
    tasks:read

    Список заданий владельца, курсорная пагинация.

    Строка запроса: limit, cursor

  • POST/public/v1/tasks
    tasks:write

    Создать задание.

    Тело: PubTaskInput

    Отправляйте Idempotency-Key. Пустой список целей допустим: задание без целей ничего не планирует. Учётка, которую уже держит другое активное или приостановленное задание, отвечает 409 bot_in_other_task со списком заданий, которые её держат.

  • GET/public/v1/tasks/{id}
    tasks:read

    Одно задание владельца.

    Путь: id

  • PATCH/public/v1/tasks/{id}
    tasks:write

    Заменить задание целиком (имя, цели, расписание, настройки, минуты).

    Путь: id · Тело: PubTaskInput

    409 bot_in_other_task отвечает только на учётку, которую правка ДОБАВЛЯЕТ в цели и которую уже держит другое активное или приостановленное задание. Учётка, уже состоящая в этом задании до правки, конфликтом не считается.

  • DELETE/public/v1/tasks/{id}
    tasks:write

    Удалить задание.

    Путь: id

    Идущие матчи доигрываются, ожидающие прогоны снимаются.

  • POST/public/v1/tasks/{id}/pause
    tasks:write

    Поставить задание на паузу.

    Путь: id

    Идущие матчи доигрываются, ожидающие прогоны снимаются. Учётки на паузе остаются за этим заданием.

  • POST/public/v1/tasks/{id}/resume
    tasks:write

    Снять задание с паузы.

    Путь: id

    Учётка, которую тем временем забрало другое активное или приостановленное задание, отвечает 409 bot_in_other_task.

  • POST/public/v1/tasks/{id}/stop
    tasks:write

    Остановить задание: пауза и отмена идущих прогонов.

    Путь: id

    Раннер узнаёт об отмене на следующем heartbeat, поэтому учётка останавливается примерно через один интервал heartbeat, не мгновенно. Снятые незавершённые прогоны не идут в счёт недели учётки. Работает и с заданием на паузе: его уже идущие матчи всё равно доигрываются.

Вебхуки

Вебхуки шлют POST-запрос на ваш адрес при событии учётки, дропа, прогона, задания или оплаты. Заводятся и правятся только в кабинете — Настройки → API → Вебхуки, ключом API это не делается.

До 5 эндпоинтов на владельца. Адрес — только https и только публичный: кабинет отвергает локальные и служебные адреса при сохранении.

Тело запроса

POST с телом {id, type, created_at, data}: id — идентификатор события (ключ дедупликации), type — один из типов в таблице ниже, data — как есть, без второй обёртки. Заголовок Content-Type: application/json.

Заголовки

ЗаголовокЗначение
X-Split-EventТип события, тот же, что в теле.
X-Split-DeliveryИдентификатор доставки — один и тот же на всех попытках повтора.
X-Split-SignatureПодпись — см. раздел ниже.
User-AgentВсегда SplitTeam-Webhooks/1.

Проверка подписи

X-Split-Signature несёт отметку времени и подпись — HMAC-SHA256 от строки «отметка времени, точка, сырое тело», шестнадцатеричной строкой (формат — строкой ниже). Подпись считается от СЫРЫХ байт тела, до разбора JSON. Отклоняйте доставку старше 5 минут от отметки времени и сравнивайте подпись временем, не зависящим от совпадающих байт: hmac.Equal в Go, crypto.timingSafeEqual в Node, hmac.compare_digest в Python.

Формат заголовка
t=<unix>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>
Go
package splitwebhook

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"errors"
	"math"
	"strconv"
	"strings"
	"time"
)

// Verify checks the X-Split-Signature header against the RAW request body —
// before any JSON parsing, since the signature is computed over the exact
// bytes that were sent. header looks like "t=<unix seconds>,v1=<hex
// HMAC-SHA256(secret, "<t>.<raw body>")>".
func Verify(header string, body []byte, secret string, now time.Time) error {
	var t, v1 string
	for _, part := range strings.Split(header, ",") {
		kv := strings.SplitN(part, "=", 2)
		if len(kv) != 2 {
			continue
		}
		switch kv[0] {
		case "t":
			t = kv[1]
		case "v1":
			v1 = kv[1]
		}
	}
	if t == "" || v1 == "" {
		return errors.New("malformed signature header")
	}

	ts, err := strconv.ParseInt(t, 10, 64)
	if err != nil {
		return errors.New("malformed timestamp")
	}
	// Reject a delivery older or newer than 5 minutes — this stops a
	// captured delivery from being replayed later.
	if age := now.Sub(time.Unix(ts, 0)); math.Abs(age.Seconds()) > 5*60 {
		return errors.New("timestamp outside 5-minute tolerance")
	}

	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(t + "."))
	mac.Write(body)
	expected := hex.EncodeToString(mac.Sum(nil))

	if !hmac.Equal([]byte(expected), []byte(v1)) {
		return errors.New("signature mismatch")
	}
	return nil
}
Node
import { createHmac, timingSafeEqual } from 'node:crypto';

/**
 * Verifies the `X-Split-Signature` header of a Split Team webhook delivery
 * against the RAW request body — before any JSON parsing, since the
 * signature is computed over the exact bytes that were sent.
 *
 * The header looks like `t=<unix seconds>,v1=<hex HMAC-SHA256(secret,
 * "<t>.<raw body>")>`. A delivery older or newer than `toleranceSeconds`
 * (default 5 minutes) is rejected — that stops a captured delivery from
 * being replayed later. `now` is only there so this same function can be
 * exercised against fixed test vectors; leave it out in real code and it
 * defaults to the current time.
 */
export function verifySplitSignature(
  header: string,
  rawBody: string | Buffer,
  secret: string,
  options: { toleranceSeconds?: number; now?: number } = {}
): boolean {
  const toleranceSeconds = options.toleranceSeconds ?? 5 * 60;
  const now = options.now ?? Date.now();

  const parts = new Map(
    header.split(',').map((pair) => {
      const [key, value] = pair.split('=');
      return [key, value] as const;
    })
  );
  const t = parts.get('t');
  const v1 = parts.get('v1');
  if (!t || !v1) return false;

  const timestamp = Number(t);
  if (!Number.isFinite(timestamp)) return false;
  if (Math.abs(now / 1000 - timestamp) > toleranceSeconds) return false;

  const body = typeof rawBody === 'string' ? Buffer.from(rawBody, 'utf8') : rawBody;
  const expected = createHmac('sha256', secret).update(`${t}.`).update(body).digest('hex');

  const expectedBuf = Buffer.from(expected, 'utf8');
  const receivedBuf = Buffer.from(v1, 'utf8');
  // Length check before timingSafeEqual: it throws on mismatched lengths
  // instead of returning false, and a thrown error here must not be read
  // as "signature valid" by a caller that only checks for an exception.
  if (expectedBuf.length !== receivedBuf.length) return false;

  return timingSafeEqual(expectedBuf, receivedBuf);
}

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

Python
import hashlib
import hmac
import time


def verify_split_signature(header, body, secret, now=None, tolerance_seconds=5 * 60):
    """Verifies X-Split-Signature against the RAW request body (bytes),
    before any JSON parsing — the signature is computed over the exact
    bytes that were sent. header looks like "t=<unix seconds>,v1=<hex
    HMAC-SHA256(secret, "<t>.<raw body>")>".
    """
    if now is None:
        now = time.time()

    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    t, v1 = parts.get("t"), parts.get("v1")
    if not t or not v1:
        return False

    # Reject a delivery older or newer than 5 minutes — this stops a
    # captured delivery from being replayed later.
    if abs(now - int(t)) > tolerance_seconds:
        return False

    signed_payload = f"{t}.".encode() + body
    expected = hmac.new(secret.encode(), signed_payload, hashlib.sha256).hexdigest()

    return hmac.compare_digest(expected, v1)

Успех, таймаут, редиректы

Успех — любой код 2xx в течение 10 секунд. Редиректы не выполняются: 3xx — отказ, как и любой другой код вне 2xx. Тело ответа не разбирается, в журнале доставки хранится только его начало.

Повторы и автоотключение

Отказ повторяется по расписанию: 1 мин, 5 мин, 30 мин, 2 ч, 6 ч, 12 ч, затем раз в сутки. Доставка становится отказавшей окончательно на девятой попытке подряд — это около 2,9 суток от первого отказа.

Эндпоинт без единой успешной доставки трое суток подряд отключается сам. Включить его снова можно только в кабинете; после включения ферма досылает доставки, которые к этому моменту ещё не стали отказавшими окончательно (события хранятся 30 дней, так что доставка старше этого срока уже недоступна для повтора). События, случившиеся, пока эндпоинт был выключен, не записываются вовсе и после включения не придут — сверьте состояние через API (GET /bots, /jobs, /tasks).

Журнал доставок в кабинете хранит записи за 7 дней; сами события — 30 дней.

Минимум один раз, без порядка

Доставка — минимум один раз, и порядок между разными событиями не гарантирован. Используйте id как ключ дедупликации и относитесь к событию как к сигналу «что-то изменилось»: перечитывайте ресурс через API, а не доверяйте data как последнему состоянию.

Типы событий

Двенадцать типов, плюс отдельный ping (кнопка «Отправить тестовое» в кабинете) — на него нельзя подписаться, в список ниже он не входит.

ТипКогдаdata
bot.logged_inУчётка получила refresh-токен и готова к работе.PubBot
bot.needs_reloginSteam отклонил пароль или сессию — учётка снята с очереди.PubBot
bot.quarantinedКик VACnet, бан или другой окончательный разрыв.PubBot + code
bot.cooldownМатчмейкинг штрафует учётку временно.PubBot + until
drop.receivedУчётка получила предмет недельного дропа.PubDrop + bot_id
drop.claimedФерма отправила запрос на забор предложения.PubDropOffer + bot_id
job.completedЗадание учётки закончилось успешно.PubJob
job.failedЗадание учётки закончилось отказом.PubJob
task.doneЗадание владельца выполнено полностью.PubTask
payment.paidПокупка слотов подтверждена платёжным шлюзом.PubPayment
subscription.expiringСлоты закончатся в ближайшее время.PubSubscription
subscription.expiredСлоты по этой подписке больше не действуют.PubSubscription

Клиент

Полная спека OpenAPI лежит по адресу ниже; сгенерируйте клиент любым инструментом (openapi-typescript, openapi-generator и подобными).

Открыть openapi.yaml