Публичный 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_keyinsufficient_scoperate_limitedinternalbad_idempotency_keyidempotency_key_reusedidempotency_in_progressnot_foundbad_limitbad_cursorbad_requestmethod_not_allowedjob_type_not_allowedbad_game_idbad_labelbad_steam_usernamebad_steam_passwordbad_steam_shared_secretbad_steam_identity_secretduplicate_steam_usernamebot_limitbad_batch_sizebad_namebad_webhook_urlduplicate_namebad_bot_idbot_group_mismatchfield_not_supportedbad_configunknown_typetype_not_livebad_job_databot_busybot_needs_reloginbot_quarantinednot_cancellablebad_schedulebad_minutesno_targetsbad_targetduplicate_targettarget_not_foundbot_in_other_taskbad_state
Формат данных
- 64-битные идентификаторы (
steam_id,item_id) приходят строкой — числом они не помещаются в JS. - Деньги — целое число центов в поле вида
*_centsплюсcurrency. - Время — RFC 3339 в UTC.
- Пароль Steam, общий секрет и секрет идентичности — только на запись: ни один ответ
/public/v1их не возвращает, даже сразу после того, как вы их прислали.
Операции
Справочник порождён из спеки /public/v1: метод, путь, нужный скоуп и краткое описание. Если у операции есть параметры или тело запроса, они показаны под строкой.
GET/public/v1/whoamiлюбой ключКлюч и его владелец.
Работает с любым действующим ключом, независимо от скоупов.
GET/public/v1/botsbots:readСписок учёток владельца, курсорная пагинация.
Строка запроса: limit, cursor, status, group_id, task_id
POST/public/v1/botsbots:credentialsДобавить учётку Steam.
Тело: PubBotCreate
Отправляйте заголовок
Idempotency-Key. Повтор без него может создать дубль или вернутьduplicate_steam_usernameна строку, которую вы уже создали сами. Пароль, общий секрет и секрет идентичности шифруются перед записью. Ни один ответ их не возвращает.POST/public/v1/bots/batchbots: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}/credentialsbots:credentialsЗаменить пароль Steam и/или общий секрет учётки.
Путь: id · Тело: PubBotCredentials
Нужно хотя бы одно из двух полей. Пустое тело отвечает
bad_request. Заодно снимаетneeds_reloginвpending_login: это единственное действие, которого ждёт такой статус.GET/public/v1/bots/{id}/dropsbots:readПредметы учётки, сначала новые.
Путь: id · Строка запроса: limit, cursor
Отдаёт не больше 500 последних предметов, дальше курсорная пагинация.
GET/public/v1/bots/{id}/drop-offersbots:readЖурнал предложений дропа учётки, сначала новые.
Путь: id · Строка запроса: limit, cursor
Отдаёт не больше 200 последних наблюдений, дальше курсорная пагинация.
GET/public/v1/bots/{id}/matchesbots:readЗавершённые матчи учётки, сначала новые.
Путь: id · Строка запроса: limit, cursor
Отдаёт не больше 200 последних матчей, дальше курсорная пагинация.
GET/public/v1/accountaccount:readЗанятость слотов и действующие подписки.
GET/public/v1/account/overviewaccount:readИтоги дропа за неделю по всем предметам владельца.
GET/public/v1/groupsbots:readСписок групп владельца, курсорная пагинация.
Строка запроса: limit, cursor
POST/public/v1/groupsbots: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/jobsbots:readСписок прогонов владельца, курсорная пагинация.
Строка запроса: limit, cursor, bot_id, state
Плоский список по учёткам: учётка с активным и последним завершённым прогоном даёт до двух записей. Показывает активный и последний прогон каждой учётки любого типа, включая
cs2_farm, поставленный заданием, а не только записи, созданные через этот API. Создать через /public/v1 можно только ручной забор дропа (см.POST /public/v1/jobs), но этот список всё равно показывает все прогоны, какие у учётки есть.POST/public/v1/jobsbots:writeСоздать ручной прогон забора дропа.
Тело: PubJobCreate
Принимается только
type: cs2_claim_drop.cs2_farm(матчмейкинг) отвечает400 job_type_not_allowedещё до поиска учётки: этой работой управляют задания, которые распределяют слоты между учётками, и прямая постановка здесь позволила бы обойти их очередь. ОтправляйтеIdempotency-Key.GET/public/v1/jobs/capabilitiesbots:readПоля, которые принимает настройка задания.
Всегда реестр CS2, потому что /public/v1 сегодня не работает ни с одной другой игрой и параметра игры для выбора нет. Эти поля относятся к
configзадания (PubTaskInput.config), а не к телу постановки прогона. Прогон, созданный через /public/v1, всегдаcs2_claim_drop, и у него нет настроек.POST/public/v1/jobs/{id}/cancelbots:writeОтменить прогон.
Путь: id
Отменённый прогон не возвращается в очередь и не тратит попытку. Раннер узнаёт об отмене на следующем heartbeat, не мгновенно.
GET/public/v1/taskstasks:readСписок заданий владельца, курсорная пагинация.
Строка запроса: limit, cursor
POST/public/v1/taskstasks: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}/pausetasks:writeПоставить задание на паузу.
Путь: id
Идущие матчи доигрываются, ожидающие прогоны снимаются. Учётки на паузе остаются за этим заданием.
POST/public/v1/tasks/{id}/resumetasks:writeСнять задание с паузы.
Путь: id
Учётка, которую тем временем забрало другое активное или приостановленное задание, отвечает
409 bot_in_other_task.POST/public/v1/tasks/{id}/stoptasks: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>")>
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
}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);
}Этот же файл прогоняется тестом на векторах, которыми ферма проверяет собственную реализацию подписи — примеры и код фермы не могут разойтись молча.
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_relogin | Steam отклонил пароль или сессию — учётка снята с очереди. | 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