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.

ScopeGrants
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_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

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, plus currency.
  • Times are RFC 3339 in UTC.
  • The Steam password, shared secret and identity secret are write-only: no /public/v1 response 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/whoami
    any key

    The key and its owner.

    Works with any valid key, regardless of scopes.

  • GET/public/v1/bots
    bots:read

    List this owner's accounts, cursor-paginated.

    Query: limit, cursor, status, group_id, task_id

  • POST/public/v1/bots
    bots:credentials

    Attach a new Steam account.

    Body: PubBotCreate

    Send the Idempotency-Key header. A retry without it may create a duplicate or return duplicate_steam_username for 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/batch
    bots:credentials

    Attach a batch of Steam accounts in one request.

    Body: PubBotBatchCreate

    Send Idempotency-Key for 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_batch is only visible within one request. Dedupe steam_username across your whole file before cutting it into batches: otherwise a repeat split across two requests gets duplicate_steam_username on the second one (“already attached, skip it”) instead of the truth (“it's in your file twice”).

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

    One account of this owner.

    Path: id

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

    Rename an account.

    Path: id · Body: PubBotUpdate

    Only label can be changed here. Credentials go through PUT .../credentials. An unrecognized field is ignored, not rejected: the guarantee is that nothing else about the account changes.

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

    Detach an account and wipe its credentials.

    Path: id

    The credentials are wiped and the account deleted in one transaction.

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

    Replace 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 resets needs_relogin to pending_login: it's the one action that status is waiting for.

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

    Items 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-offers
    bots:read

    Drop 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}/matches
    bots:read

    Completed 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/account
    account:read

    Slot usage and active subscriptions.

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

    Weekly drop totals across the owner's items.

  • GET/public/v1/groups
    bots:read

    List this owner's groups, cursor-paginated.

    Query: limit, cursor

  • POST/public/v1/groups
    bots:write

    Create 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:read

    One group of this owner.

    Path: id

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

    Rename a group.

    Path: id · Body: PubGroupUpdate

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

    Delete 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:write

    Add 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_id and 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. 409 is 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:write

    Remove an account from a group.

    Path: id, bot_id

  • GET/public/v1/jobs
    bots:read

    List 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_farm runs a task put there, not only entries created through this API. Only a manual claim-drop run can be created through /public/v1 (see POST /public/v1/jobs), but this list still shows whatever runs an account actually has.

  • POST/public/v1/jobs
    bots:write

    Create a manual claim-drop run.

    Body: PubJobCreate

    Only type: cs2_claim_drop is accepted. cs2_farm (matchmaking) answers 400 job_type_not_allowed before 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. Send Idempotency-Key.

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

    Fields 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 always cs2_claim_drop, and it takes no settings.

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

    Cancel 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/tasks
    tasks:read

    List this owner's tasks, cursor-paginated.

    Query: limit, cursor

  • POST/public/v1/tasks
    tasks:write

    Create 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 answers 409 bot_in_other_task with the list of tasks holding it.

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

    One task of this owner.

    Path: id

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

    Replace a task whole (name, targets, schedule, config, minutes).

    Path: id · Body: PubTaskInput

    409 bot_in_other_task only 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:write

    Delete a task.

    Path: id

    Running matches play out, pending runs are dropped.

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

    Pause a task.

    Path: id

    Running matches play out, pending runs are dropped. Paused accounts stay held by this task.

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

    Resume 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}/stop
    tasks:write

    Stop 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

HeaderValue
X-Split-EventThe event type, the same one as in the body.
X-Split-DeliveryThe delivery id — the same value across every retry attempt.
X-Split-SignatureThe signature — see the section below.
User-AgentAlways 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.

Header format
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);
}

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.

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)

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.

TypeWhendata
bot.logged_inThe account got a refresh token and is ready to work.PubBot
bot.needs_reloginSteam rejected the password or session — the account left the queue.PubBot
bot.quarantinedA VACnet kick, a ban, or another final disconnect.PubBot + code
bot.cooldownMatchmaking is penalizing the account temporarily.PubBot + until
drop.receivedThe account received a weekly drop item.PubDrop + bot_id
drop.claimedThe farm sent a claim request for the offer.PubDropOffer + bot_id
job.completedThe account's job finished successfully.PubJob
job.failedThe account's job ended in failure.PubJob
task.doneThe owner's task ran to completion.PubTask
payment.paidA slot purchase was confirmed by the payment gateway.PubPayment
subscription.expiringSlots from this subscription are about to run out.PubSubscription
subscription.expiredSlots 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