Public API
/public/v1 gives your own code access to your own data in the farm cabinet: accounts, tasks, drops, matches, slots. The key acts on your behalf — there are no sub-accounts, tenants or OAuth consent here.
Base URL: https://split-team.com/public/v1
The API can only create a manual cs2_claim_drop run — claiming a drop. Matchmaking (cs2_farm) is managed only by tasks (/tasks): they order slots across accounts the way the owner set up, and a direct run that bypasses that order isn't available here.
The key
Every request carries an Authorization: Bearer sft_… header. The cabinet's cookie, farm_session, is never accepted on /public/v1 — a browser can't reach it by cookie, so CSRF is impossible here by construction.
A key is issued in the cabinet, Settings → API.
GET /public/v1/whoami works with any valid key, even one with no scopes at all — use it to ask which key this is and what it's allowed to do.
Scopes
A key is issued with one or more scopes. An operation the key lacks a scope for answers 403 insufficient_scope, listing what's missing in error.missing.
| Scope | Grants |
|---|---|
account:read | Account: readSee slots, subscriptions and the drop week. |
bots:read | Accounts: readSee accounts with their drops, matches and runs. |
bots:write | Accounts: changeChange account labels and groups, delete accounts, cancel runs and start a manual drop claim. |
bots:credentials | Accounts: passwordsAdd accounts and change their password and shared secret. The key can never read a password back. |
tasks:read | Tasks: readSee tasks and what they contain. |
tasks:write | Tasks: changeCreate and change tasks. |
Pagination
Lists are cursor-paginated: limit (1–200, default 50) and cursor — an opaque value from the previous page's next_cursor. The response is an object with items and next_cursor; the last page carries no next_cursor at all.
Some lists (an account's drops, drop offers, matches) only return the most recent entries under the cursor — how many is named in that operation's description in the reference below.
Idempotency-Key
POST /bots, /bots/batch, /tasks and /jobs accept an Idempotency-Key header — 1 to 255 printable ASCII characters, kept for 24 hours. A retry with the same body gets back the first attempt's stored response (Idempotent-Replayed: true); the same key with a different body gets 422 idempotency_key_reused; a retry while the first request is still running gets 409 idempotency_in_progress.
The header is optional for a single request, but required for batches: without it, a retry after a network drop can create duplicate accounts.
Open enums
Account states, run stop reasons, event types and error codes can gain a new value without a version bump of /public/v1. A client must treat an unfamiliar value as unknown — it isn't a parsing error.
Rate limit
10 requests per second per owner — one shared limit across all of that owner's keys, burst 30. POST /bots/batch has its own, narrower limit: 1 request per 5 seconds. RateLimit-Limit and RateLimit-Remaining come with every response to a request made with a valid key; going over answers 429 rate_limited, and only that response carries RateLimit-Reset and Retry-After (seconds until the next try).
The numbers aren't measured and may change.
Error codes
The error body is an error object with code, message and, on a scope refusal, missing, plus request_id — the same request_id also comes back as the X-Request-Id header. The code set is open: below is what the service answers with today.
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
Data format
- 64-bit ids (
steam_id,item_id) come as strings — they don't fit a JS number. - Money is an integer count of cents in a field named
*_cents, pluscurrency. - Times are RFC 3339 in UTC.
- The Steam password, shared secret and identity secret are write-only: no
/public/v1response ever returns them, not even right after you sent them.
Operations
The reference is generated from the /public/v1 spec: method, path, the required scope and a short summary. Parameters and a request body, where the operation has either, are shown under the row.
GET/public/v1/whoamiany keyThe key and its owner.
Works with any valid key, regardless of scopes.
GET/public/v1/botsbots:readList this owner's accounts, cursor-paginated.
Query: limit, cursor, status, group_id, task_id
POST/public/v1/botsbots:credentialsAttach a new Steam account.
Body: PubBotCreate
Send the
Idempotency-Keyheader. A retry without it may create a duplicate or returnduplicate_steam_usernamefor a row you created yourself. The password, shared secret and identity secret are encrypted before writing. No response ever returns them.POST/public/v1/bots/batchbots:credentialsAttach a batch of Steam accounts in one request.
Body: PubBotBatchCreate
Send
Idempotency-Keyfor the same reason as a single account. Each row lives and fails on its own: a bad row doesn't cancel the rest, and there's no shared transaction across the batch.duplicate_in_batchis only visible within one request. Dedupesteam_usernameacross your whole file before cutting it into batches: otherwise a repeat split across two requests getsduplicate_steam_usernameon the second one (“already attached, skip it”) instead of the truth (“it's in your file twice”).GET/public/v1/bots/{id}bots:readOne account of this owner.
Path: id
PATCH/public/v1/bots/{id}bots:writeRename an account.
Path: id · Body: PubBotUpdate
Only
labelcan be changed here. Credentials go throughPUT .../credentials. An unrecognized field is ignored, not rejected: the guarantee is that nothing else about the account changes.DELETE/public/v1/bots/{id}bots:writeDetach an account and wipe its credentials.
Path: id
The credentials are wiped and the account deleted in one transaction.
PUT/public/v1/bots/{id}/credentialsbots:credentialsReplace an account's Steam password and/or shared secret.
Path: id · Body: PubBotCredentials
At least one of the two fields is required. An empty body answers
bad_request. This also resetsneeds_relogintopending_login: it's the one action that status is waiting for.GET/public/v1/bots/{id}/dropsbots:readItems of one account, newest first.
Path: id · Query: limit, cursor
Returns at most the 500 most recent items, cursor-paginated on top of that.
GET/public/v1/bots/{id}/drop-offersbots:readDrop offer log of one account, newest first.
Path: id · Query: limit, cursor
Returns at most the 200 most recent observations, cursor-paginated on top of that.
GET/public/v1/bots/{id}/matchesbots:readCompleted matches of one account, newest first.
Path: id · Query: limit, cursor
Returns at most the 200 most recent matches, cursor-paginated on top of that.
GET/public/v1/accountaccount:readSlot usage and active subscriptions.
GET/public/v1/account/overviewaccount:readWeekly drop totals across the owner's items.
GET/public/v1/groupsbots:readList this owner's groups, cursor-paginated.
Query: limit, cursor
POST/public/v1/groupsbots:writeCreate a group.
Body: PubGroupCreate
A group only has a name and a membership list, no settings. Every group created through /public/v1 is CS2, same as accounts.
GET/public/v1/groups/{id}bots:readOne group of this owner.
Path: id
PATCH/public/v1/groups/{id}bots:writeRename a group.
Path: id · Body: PubGroupUpdate
DELETE/public/v1/groups/{id}bots:writeDelete a group and its whole membership.
Path: id
Membership rows are deleted cascadingly, no separate step needed. Accounts themselves aren't touched.
PUT/public/v1/groups/{id}/members/{bot_id}bots:writeAdd an account to a group.
Path: id, bot_id
Ownership of both the group and the account is checked before the insert. A foreign
bot_idand one that doesn't reference any account at all answer the same way:404 not_found. A 409 there would confirm someone else's account exists.409is reserved for one case: it's your own account, just of a different game than the group. That's unreachable through /public/v1 today, since every group and account it creates is CS2; it's kept for accounts or groups that predate this API. Adding an account that's already a member is idempotent.DELETE/public/v1/groups/{id}/members/{bot_id}bots:writeRemove an account from a group.
Path: id, bot_id
GET/public/v1/jobsbots:readList this owner's runs, cursor-paginated.
Query: limit, cursor, bot_id, state
A flat list across accounts: an account with both an active and a last-finished run contributes up to two entries. Lists the active and last run of each account, of any type, including
cs2_farmruns a task put there, not only entries created through this API. Only a manual claim-drop run can be created through /public/v1 (seePOST /public/v1/jobs), but this list still shows whatever runs an account actually has.POST/public/v1/jobsbots:writeCreate a manual claim-drop run.
Body: PubJobCreate
Only
type: cs2_claim_dropis accepted.cs2_farm(matchmaking) answers400 job_type_not_allowedbefore the account is even looked up, because that work is managed by tasks, which order slots across accounts; a direct run here would let an integrator cut that queue. SendIdempotency-Key.GET/public/v1/jobs/capabilitiesbots:readFields a task's config accepts.
Always the CS2 registry, because /public/v1 doesn't run any other game today and there's no game parameter to pick one. These fields apply to a task's
config(PubTaskInput.config), not to the run-creation body. A run created through /public/v1 is alwayscs2_claim_drop, and it takes no settings.POST/public/v1/jobs/{id}/cancelbots:writeCancel a run.
Path: id
A cancelled run isn't returned to the queue and doesn't spend an attempt. The runner learns of it on its next heartbeat, not instantly.
GET/public/v1/taskstasks:readList this owner's tasks, cursor-paginated.
Query: limit, cursor
POST/public/v1/taskstasks:writeCreate a task.
Body: PubTaskInput
Send
Idempotency-Key. An empty targets list is legal: a task with no targets schedules nothing. An account already held by another active or paused task answers409 bot_in_other_taskwith the list of tasks holding it.GET/public/v1/tasks/{id}tasks:readOne task of this owner.
Path: id
PATCH/public/v1/tasks/{id}tasks:writeReplace a task whole (name, targets, schedule, config, minutes).
Path: id · Body: PubTaskInput
409 bot_in_other_taskonly answers for an account the edit ADDS to the targets and that's already held by another active or paused task. An account already in this task before the edit is not a conflict.DELETE/public/v1/tasks/{id}tasks:writeDelete a task.
Path: id
Running matches play out, pending runs are dropped.
POST/public/v1/tasks/{id}/pausetasks:writePause a task.
Path: id
Running matches play out, pending runs are dropped. Paused accounts stay held by this task.
POST/public/v1/tasks/{id}/resumetasks:writeResume a paused task.
Path: id
An account taken by another active or paused task in the meantime answers
409 bot_in_other_task.POST/public/v1/tasks/{id}/stoptasks:writeStop a task: pause and cancel runs in progress.
Path: id
The runner learns of the cancellation on its next heartbeat, so the account stops roughly one heartbeat interval later, not instantly. Dropped in-progress runs don't count toward the account's week. Works on a paused task too: its already-running matches still play out.
Webhooks
Webhooks POST to your address on an account, drop, run, task or payment event. Endpoints are created and edited only in the cabinet — Settings → API → Webhooks, not with an API key.
Up to 5 endpoints per owner. The address must be https and public — the cabinet refuses a local or internal address when you save it.
Request body
A POST with the body {id, type, created_at, data}: id is the event id (your dedup key), type is one of the types in the table below, data is delivered as-is, with no extra envelope. Content-Type: application/json.
Headers
| Header | Value |
|---|---|
X-Split-Event | The event type, the same one as in the body. |
X-Split-Delivery | The delivery id — the same value across every retry attempt. |
X-Split-Signature | The signature — see the section below. |
User-Agent | Always SplitTeam-Webhooks/1. |
Verifying the signature
X-Split-Signature carries a timestamp and a signature — HMAC-SHA256 of "timestamp, dot, raw body", hex-encoded (the exact format is below). The signature is computed over the RAW body bytes, before any JSON parsing. Reject a delivery older than 5 minutes from the timestamp, and compare the signature with a constant-time comparison: hmac.Equal in Go, crypto.timingSafeEqual in Node, hmac.compare_digest in 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);
}This exact file is run by a test against the same vectors the farm uses to check its own signature implementation — the example and the farm's code can't drift apart silently.
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)Success, timeout, redirects
Success is any 2xx status within 10 seconds. Redirects aren't followed: a 3xx is a failure, like any other status outside 2xx. The response body isn't parsed; the delivery log keeps only its start.
Retries and auto-disable
A failure is retried on a schedule: 1 min, 5 min, 30 min, 2 h, 6 h, 12 h, then once a day. A delivery becomes permanently failed on its ninth attempt in a row — about 2.9 days from the first failure.
An endpoint with no successful delivery for three straight days disables itself. Turning it back on can only be done in the cabinet; once you do, the farm sends the deliveries that hadn't become permanently failed yet by then (events are kept for 30 days, so a delivery older than that is no longer available to retry). Events that happen while the endpoint is disabled are not recorded at all and won't arrive after you turn it back on — resync through the API (GET /bots, /jobs, /tasks).
The delivery log in the cabinet keeps 7 days of records; the events themselves are kept for 30 days.
At-least-once, no ordering
Delivery is at-least-once, and there's no guaranteed order between different events. Use id as your dedup key and treat an event as a signal that something changed — re-read the resource through the API, don't trust data as the current state.
Event types
Twelve types, plus a separate ping (the cabinet's «Send test» button) — it can't be subscribed to and isn't in the table below.
| Type | When | data |
|---|---|---|
bot.logged_in | The account got a refresh token and is ready to work. | PubBot |
bot.needs_relogin | Steam rejected the password or session — the account left the queue. | PubBot |
bot.quarantined | A VACnet kick, a ban, or another final disconnect. | PubBot + code |
bot.cooldown | Matchmaking is penalizing the account temporarily. | PubBot + until |
drop.received | The account received a weekly drop item. | PubDrop + bot_id |
drop.claimed | The farm sent a claim request for the offer. | PubDropOffer + bot_id |
job.completed | The account's job finished successfully. | PubJob |
job.failed | The account's job ended in failure. | PubJob |
task.done | The owner's task ran to completion. | PubTask |
payment.paid | A slot purchase was confirmed by the payment gateway. | PubPayment |
subscription.expiring | Slots from this subscription are about to run out. | PubSubscription |
subscription.expired | Slots from this subscription no longer apply. | PubSubscription |
Client
The full OpenAPI spec is at the address below; generate a client with any tool (openapi-typescript, openapi-generator, and the like).
Open openapi.yaml