Docs/Dota 2 API/Webhooks

Dota 2 webhooks

Get a signed POST with the picks and bans of a Dota 2 game. There is one Dota 2 event today, dota2.draft.updated. For scores and results, use the match routes.

Which events do I need?

Live scoreboard

dota2.draft.updated

Picks and bans. For the in-game score, poll /dota2/matches/live: there is no in-game score event.

Settle bets

Dota 2 has no result event yet to subscribe to. Poll /dota2/matches/recent for finished matches.

Results only

Dota 2 has no result event yet to subscribe to. Poll /dota2/matches/recent for finished matches.

Alerts

dota2.draft.updated

The draft of each game. Overwrite your message: it can repeat as the draft fills in.

Filters

Set on the webhook, for example {"filters":{"matchId":"..."}}. Each takes one value or a list, compared case-insensitively. A filter on a field an event does not carry stops every delivery of that event, so each event below lists the filters it supports.

matchId
Only this match. Take the id from the schedule or live routes.
gameId
Only one game inside a series.

Recipes

Show the draft

Overwrite by data.gameId, and order by order inside data.picks and data.bans.
JavaScript
if (event === "dota2.draft.updated") {
  const byOrder = (a, b) => a.order - b.order;
  drafts.set(data.gameId, { picks: data.picks.sort(byOrder), bans: data.bans.sort(byOrder) });
}

Results

Read GET /api/v1/dota2/matches/recent on a schedule, and /dota2/matches/{matchId}/player-stats for each new match.

More: recipes for every sport.

Good to know

  • dota2.draft.updated can repeat for one game as it fills in. Overwrite your stored draft instead of appending.
  • There is no result event for Dota 2. Poll /dota2/matches/recent for finished matches.

All 1 events

1 of 1 were sent in the 7 days to 2026-11-08. None recorded yet means you can subscribe, but do not rely on the event. For the current status of each, read GET /api/v1/webhooks/events?game=dota2.

dota2.draft.updatedActive

The picks and bans of a Dota 2 game, with hero names and team ids.

When
When a draft is seen for a game. Can repeat for one game as it fills in.
Frequency
about 22 per day across all matches (about 3 per match)
Delivery
Median 1.1 s, p95 2.4 s, from the moment Cito recorded the event to the moment a customer endpoint answered (635 recent deliveries).
Plan
Pro and Scale
Filters
matchId, gameId
Use it to
Show picks and bans. It can repeat for one game as the draft fills in, so overwrite rather than append.
Fields in data (9)
FieldTypeMeaning
bansarrayBanned heroes with order, phase, hero id, hero name and team id.
direTeamstringTeam on Dire.
draftRowsnumberNumber of picks and bans so far. It grows as the draft fills in.
gameIdstringOne game inside a series.
gameNumbernumberWhich game of the series, from 1.
matchIdstringThe match id. Works on the match routes for this sport.
picksarrayPicked heroes with order, hero id, hero name and team id.
radiantTeamstringTeam on Radiant.
teamsarrayThe teams in this match.
Full example (real payload)
JSON
{
  "event": "dota2.draft.updated",
  "id": "a1b2c3d4-0000-4000-8000-000000000000",
  "timestamp": "2026-11-08T10:57:12.737Z",
  "event_type": "dota2.draft.updated",
  "match_id": "663676",
  "previous_state": null,
  "data": {
    "bans": [
      {
        "order": 0,
        "phase": 1,
        "heroId": 33,
        "teamId": "cyberhero",
        "heroName": "Enigma"
      },
      {
        "order": 1,
        "phase": 1,
        "heroId": 100,
        "teamId": "cyberhero",
        "heroName": "Tusk"
      },
      {
        "order": 2,
        "phase": 1,
        "heroId": 62,
        "teamId": "angry",
        "heroName": "Bounty Hunter"
      },
      {
        "order": 3,
        "phase": 1,
        "heroId": 105,
        "teamId": "angry",
        "heroName": "Techies"
      },
      {
        "order": 4,
        "phase": 1,
        "heroId": 50,
        "teamId": "cyberhero",
        "heroName": "Dazzle"
      },
      {
        "order": 5,
        "phase": 1,
        "heroId": 155,
        "teamId": "angry",
        "heroName": "Largo"
      },
      {
        "order": 6,
        "phase": 1,
        "heroId": 112,
        "teamId": "angry",
        "heroName": "Winter Wyvern"
      },
      {
        "order": 9,
        "phase": 2,
        "heroId": 55,
        "teamId": "cyberhero",
        "heroName": "Dark Seer"
      },
      {
        "order": 10,
        "phase": 2,
        "heroId": 135,
        "teamId": "cyberhero",
        "heroName": "Dawnbreaker"
      },
      {
        "order": 11,
        "phase": 2,
        "heroId": 106,
        "teamId": "angry",
        "heroName": "Ember Spirit"
      },
      {
        "order": 18,
        "phase": 3,
        "heroId": 77,
        "teamId": "cyberhero",
        "heroName": "Lycan"
      },
      {
        "order": 19,
        "phase": 3,
        "heroId": 21,
        "teamId": "angry",
        "heroName": "Windranger"
      },
      {
        "order": 20,
        "phase": 3,
        "heroId": 78,
        "teamId": "cyberhero",
        "heroName": "Brewmaster"
      },
      {
        "order": 21,
        "phase": 3,
        "heroId": 18,
        "teamId": "angry",
        "heroName": "Sven"
      }
    ],
    "picks": [
      {
        "order": 7,
        "phase": 2,
        "heroId": 49,
        "teamId": "cyberhero",
        "heroName": "Dragon Knight"
      },
      {
        "order": 8,
        "phase": 2,
        "heroId": 107,
        "teamId": "angry",
        "heroName": "Earth Spirit"
      },
      {
        "order": 12,
        "phase": 2,
        "heroId": 30,
        "teamId": "angry",
        "heroName": "Witch Doctor"
      },
      {
        "order": 13,
        "phase": 2,
        "heroId": 123,
        "teamId": "cyberhero",
        "heroName": "Hoodwink"
      },
      {
        "order": 14,
        "phase": 2,
        "heroId": 43,
        "teamId": "cyberhero",
        "heroName": "Death Prophet"
      },
      {
        "order": 15,
        "phase": 2,
        "heroId": 119,
        "teamId": "angry",
        "heroName": "Dark Willow"
      },
      {
        "order": 16,
        "phase": 2,
        "heroId": 80,
        "teamId": "angry",
        "heroName": "Lone Druid"
      },
      {
        "order": 17,
        "phase": 3,
        "heroId": 7,
        "teamId": "cyberhero",
        "heroName": "Earthshaker"
      },
      {
        "order": 22,
        "phase": 3,
        "heroId": 76,
        "teamId": "cyberhero",
        "heroName": "Outworld Devourer"
      },
      {
        "order": 23,
        "phase": 3,
        "heroId": 47,
        "teamId": "angry",
        "heroName": "Viper"
      }
    ],
    "teams": [
      "Angry Birds",
      "CyberHero"
    ],
    "gameId": "valve-live-663676-g1",
    "matchId": "663676",
    "direTeam": "Angry Birds",
    "draftRows": 24,
    "gameNumber": 1,
    "radiantTeam": "CyberHero"
  }
}

Handler

Verify the signature on the raw bytes, answer fast, dedupe on the id. The same for every sport. PHP, Ruby and more.

Node / Express
JavaScript
// server.js  (npm install express)
import crypto from "node:crypto";
import express from "express";

const app = express();
const seen = new Set(); // use your database in production

// express.raw keeps the exact bytes. express.json() here would break the signature.
app.post("/webhooks/cito", express.raw({ type: "*/*" }), (req, res) => {
  const expected = crypto
    .createHmac("sha256", process.env.CITO_WEBHOOK_SECRET)
    .update(req.body)
    .digest("hex");
  const got = req.get("X-Cito-Signature") || "";
  const valid =
    got.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected));
  if (!valid) return res.sendStatus(401);

  res.sendStatus(200); // answer within 10 seconds, then work

  const { id, event, data } = JSON.parse(req.body.toString("utf8"));
  // The id is the same on every retry and replay. A replay you asked for gets through, so make the work safe to repeat.
  // test is true on the envelope of test deliveries: keep those out of your real data.
  if (seen.has(id) && !data.replayed) return;
  seen.add(id);

  switch (event) {
    case "dota2.draft.updated":
      // handle dota2.draft.updated
      break;
  }
});

app.listen(3000);
Next.js
TypeScript
// app/api/webhooks/cito/route.ts
import crypto from "node:crypto";

const seen = new Set<string>(); // use your database in production

export async function POST(req: Request) {
  const raw = await req.text(); // the exact body, before any JSON parsing
  const expected = crypto
    .createHmac("sha256", process.env.CITO_WEBHOOK_SECRET!)
    .update(raw, "utf8")
    .digest("hex");
  const got = req.headers.get("x-cito-signature") ?? "";
  const valid =
    got.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(got), Buffer.from(expected));
  if (!valid) return new Response("invalid signature", { status: 401 });

  const payload = JSON.parse(raw);
  // The id is the same on every retry and replay. A replay you asked for gets through, so make handle() safe to repeat.
  // payload.test is true on test deliveries: keep those out of your real data.
  if (!seen.has(payload.id) || payload.data?.replayed) {
    seen.add(payload.id);
    // Do slow work in a queue. Answer within 10 seconds.
    handle(payload).catch(console.error);
  }
  return new Response("ok");
}

async function handle({ event, data }: { event: string; data: any }) {
  switch (event) {
    case "dota2.draft.updated":
      // handle dota2.draft.updated
      break;
  }
}
Python / FastAPI
Python
# main.py  (pip install fastapi uvicorn)
import hashlib, hmac, json, os
from fastapi import BackgroundTasks, FastAPI, HTTPException, Request

app = FastAPI()
SECRET = os.environ["CITO_WEBHOOK_SECRET"].encode()
seen = set()  # use your database in production

def handle(event, data):
    if event == "dota2.draft.updated":
        pass  # handle dota2.draft.updated

@app.post("/webhooks/cito")
async def cito_webhook(request: Request, tasks: BackgroundTasks):
    raw = await request.body()  # the exact bytes, before any JSON parsing
    expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected.encode(), request.headers.get("x-cito-signature", "").encode()):
        raise HTTPException(status_code=401, detail="invalid signature")

    payload = json.loads(raw)
    # The id is the same on every retry and replay. A replay you asked for gets through, so make handle() safe to repeat.
    # payload["test"] is True on test deliveries: keep those out of your real data.
    if payload["id"] not in seen or payload["data"].get("replayed"):
        seen.add(payload["id"])
        tasks.add_task(handle, payload["event"], payload["data"])  # runs after we answer
    return {"ok": True}
Python / Flask
Python
# app.py  (pip install flask)
import hashlib, hmac, json, os
from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["CITO_WEBHOOK_SECRET"].encode()
seen = set()  # use your database in production

def handle(event, data):
    if event == "dota2.draft.updated":
        pass  # handle dota2.draft.updated

@app.post("/webhooks/cito")
def cito_webhook():
    raw = request.get_data()  # the exact bytes, before any JSON parsing
    expected = hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected.encode(), request.headers.get("X-Cito-Signature", "").encode()):
        abort(401)

    payload = json.loads(raw)
    # The id is the same on every retry and replay. A replay you asked for gets through, so make handle() safe to repeat.
    # payload["test"] is True on test deliveries: keep those out of your real data.
    if payload["id"] not in seen or payload["data"].get("replayed"):
        seen.add(payload["id"])
        handle(payload["event"], payload["data"])  # move slow work to a queue: answer within 10 seconds
    return "", 200
Go
Go
// main.go
package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"io"
	"log"
	"net/http"
	"os"
	"sync"
)

var (
	secret = []byte(os.Getenv("CITO_WEBHOOK_SECRET"))
	seen   sync.Map // use your database in production
)

type envelope struct {
	Event string          `json:"event"`
	ID    string          `json:"id"`
	Data  json.RawMessage `json:"data"`
}

func handle(event string, data json.RawMessage) {
	switch event {
	case "dota2.draft.updated":
		// handle dota2.draft.updated
	}
}

func citoWebhook(w http.ResponseWriter, r *http.Request) {
	raw, err := io.ReadAll(r.Body) // the exact bytes, before any JSON parsing
	if err != nil {
		http.Error(w, "bad body", http.StatusBadRequest)
		return
	}
	mac := hmac.New(sha256.New, secret)
	mac.Write(raw)
	expected := hex.EncodeToString(mac.Sum(nil))
	if !hmac.Equal([]byte(expected), []byte(r.Header.Get("X-Cito-Signature"))) {
		http.Error(w, "invalid signature", http.StatusUnauthorized)
		return
	}

	var p envelope
	if err := json.Unmarshal(raw, &p); err != nil {
		http.Error(w, "bad json", http.StatusBadRequest)
		return
	}
	// The id is the same on every retry and replay. A replay you asked for gets through, so make handle safe to repeat.
	// "test" is true on test deliveries: keep those out of your real data.
	var replay struct {
		Replayed bool `json:"replayed"`
	}
	_ = json.Unmarshal(p.Data, &replay)
	if _, dup := seen.LoadOrStore(p.ID, true); !dup || replay.Replayed {
		go handle(p.Event, p.Data) // answer first, work after
	}
	w.WriteHeader(http.StatusOK)
}

func main() {
	http.HandleFunc("/webhooks/cito", citoWebhook)
	log.Fatal(http.ListenAndServe(":3000", nil))
}
Was this page helpful?