Base URL
https://api.playbrokenoath.com/v1/
Start at version.json. It lists the leagues, the refresh times, when the ladder and the builds were last written, and the game data files of the current patch.
Endpoints
Every file (except the game data files) starts with the same four fields:
schemathe file format version,1today.generated_atwhen the file was written, UTC.game_buildthe game build most players online in the last hour are running.patchthe game data patch, for example0.68.0.
A field with no value is left out of the file, so treat a missing field as null. Ids of things the community has not discovered yet (secret runewords, uniques, recipes, nodes, supports) show as "hidden".
The examples below are trimmed outputs of the real publisher code from a test run, so the league ids, skills and numbers in them are test data.
version.json
Refresh times, the public league ids, ladder_updated_at, builds_updated_at (the last hourly run), builds_published, the game data patch with its file list, and the endpoint list.
{
"schema": 1,
"generated_at": "2026-11-10T21:46:48.258Z",
"game_build": 64,
"patch": "0.68.0",
"api": "v1",
"base": "https://api.playbrokenoath.com/v1/",
"cache_seconds": 300,
"ladder_updated_at": "2026-11-10T21:46:48.258Z",
"builds_updated_at": "2026-11-10T21:46:48.258Z",
"refresh": { "ladder_minutes": 15, "leagues_minutes": 15, "builds_minutes": 60,
"meta_minutes": 60, "game_data": "every patch" },
"leagues": ["eat-sc-trade", "eat-sc-ssf", "eat-hc-trade", "eat-hc-ssf", "pt-sc-trade"],
"builds_published": true,
"game_data": { "game_build": 68, "patch": "0.68.0", "files": ["passive_tree.json"] },
"endpoints": ["version.json", "leagues.json", "ladder/<league>.json",
"builds/<league>/<class>.json", "builds/<league>/<class>/<hero>.json",
"meta/<league>.json", "game-data/index.json", "game-data/<file>.json"]
}
If builds_published is false, no build files are written that run and every ladder row has has_build: false.
leagues.json
Every public league: official seasons only, still running or ended less than 14 days ago. Private custom leagues are never in it.
{
"schema": 1, "generated_at": "2026-11-10T21:46:48.258Z", "game_build": 64, "patch": "0.68.0",
"leagues": [
{ "id": "eat-sc-trade", "name": "Early Access Trials", "trade": true, "hardcore": false,
"season": "early-access-trials", "season_name": "Early Access Trials",
"starts_at": "2026-10-18T21:00:00-04:00", "ends_at": "2026-12-13T13:00:00-05:00" }
]
}
ladder/<league>.json
The top 200 heroes of each class in one league, in overall ladder order. Ranked by best Rupture Level, then level, then experience, then fastest clear, then who got there first. Each row:
rankoverall,class_rankinside the class,herothe opaque hero id.class,ascendancy(the hero's Dragonling aspect),level,rupture_level(best Rupture Level),best_seconds,best_at,dead.main_skill,main_supports,guardian_dps_median(measured by the game, only for heroes with a build file),updated_at.has_buildandbuild: the path of the hero's build file (top 50 per class), relative to the base URL.
{
"schema": 1, "generated_at": "2026-11-10T21:46:48.258Z", "game_build": 64, "patch": "0.68.0",
"league": "zz1-hc-ssf", "top_per_class": 200, "builds_per_class": 50, "count": 3,
"heroes": [
{ "rank": 1, "class_rank": 1, "hero": "a98f03f5f3aad95f", "class": "Mage", "ascendancy": "Frost",
"level": 84, "rupture_level": 76, "best_at": "2026-11-10T16:46:43.624821-05:00", "dead": false,
"main_skill": "split_shot", "main_supports": ["chain"], "guardian_dps_median": 5002,
"updated_at": "2026-11-10T16:46:43.73075-05:00",
"has_build": true, "build": "builds/zz1-hc-ssf/Mage/a98f03f5f3aad95f.json" }
]
}
builds/<league>/<class>.json
The class build list: every hero of that class in that league that has a published build, in ladder order, with a short summary and the path of each build file. Class ids are the ones in the ladder's class field: Warrior, Mage, Necromancer, Paladin, Ranger, Samurai. A class with no heroes in a league has no list file.
{
"schema": 1, "generated_at": "2026-11-10T21:46:48.258Z", "game_build": 64, "patch": "0.68.0",
"league": "zz1-sc-trade", "class": "Mage", "count": 7,
"heroes": [
{ "hero": "204bc62d559d5105", "class_rank": 1, "rank": 1, "ascendancy": "Ember", "level": 69,
"rupture_level": 500, "dead": false, "main_skill": "split_shot", "main_supports": ["chain"],
"guardian_dps_median": 5002, "updated_at": "2026-11-10T16:46:38.280707-05:00",
"build": "builds/zz1-sc-trade/Mage/204bc62d559d5105.json" }
]
}
builds/<league>/<class>/<hero>.json
One full build. Every worn item (slot, base, rarity, unique, item level, quality, sockets, runes, forged, affixes with id, value, value2, tier, crafted, oath), passive nodes and tree version, bonus passive points, the Dragonling (aspect, nodes, tree version), skills with their supports, gem levels, hotbar, main skill, damage per skill, and the game's own measured Guardian numbers: kills, losses, median dps and median kill time over the last day of Guardian fights.
The file disappears when the hero leaves the top 50 of its class, is hidden, deleted, or its player opts out. An item whose runes spell a secret Oathword shows no runes and "runes_hidden": true.
{
"schema": 1, "generated_at": "2026-11-10T21:46:46.093Z", "game_build": 64, "patch": "0.68.0",
"hero": "a98f03f5f3aad95f", "league": "zz1-hc-ssf", "class": "Mage", "ascendancy": "Frost",
"level": 84, "rupture_level": 76, "class_rank": 1, "dead": false, "hardcore": true,
"updated_at": "2026-11-10T16:46:43.726732-05:00",
"measured": { "at": "2026-11-10T16:46:46.19383-05:00", "window_days": 1,
"guardian_kills": 3, "guardian_losses": 0,
"guardian_dps_median": 5002, "guardian_kill_seconds_median": 30 },
"build": {
"items": [
{ "slot": "Ring1", "base": "copper_band", "rarity": "Rare", "item_level": 80, "quality": 5,
"sockets": 0, "runes": [], "forged": 0,
"affixes": [{ "id": "Life", "value": 84, "value2": 0, "tier": 1, "crafted": false, "oath": false }] },
{ "slot": "Body", "base": "plate", "rarity": "Unique", "unique": "hidden", "item_level": 80,
"sockets": 3, "runes": [], "affixes": [], "runes_hidden": true }
],
"passive_nodes": ["hub_0", "ks_test_0", "hidden"], "tree_version": 7, "bonus_passive_points": 2,
"ascendancy": { "aspect": "Frost", "nodes": ["d1", "d2"], "tree_version": 3 },
"skills": [{ "skill": "split_shot", "supports": ["chain", "hidden"], "conduit_link": "" }],
"gem_levels": { "split_shot": 20, "chain": 18, "dash": 5 },
"hotbar": ["split_shot", "dash"], "main_skill": "split_shot",
"skill_damage": { "split_shot": 1024 }
}
}
meta/<league>.json
Usage shares for one league. share is a percent with one decimal.
classes,main_skills(with best and median Rupture Level),supportsper main skill andrupture_level_bands(25 levels wide, the key is the band start) count every hero active in the lastwindow_days(7) with a Rupture Level above 0.ascendancies,keystones,notables(top 100),uniquesandskillscount the published builds (the top 50 per class), described insample.
{
"schema": 1, "generated_at": "2026-11-10T21:46:48.258Z", "game_build": 64, "patch": "0.68.0",
"league": "zz1-sc-trade", "window_days": 7, "heroes": 20,
"classes": [{ "id": "Mage", "heroes": 7, "share": 35 }, { "id": "Ranger", "heroes": 6, "share": 30 }],
"main_skills": [{ "class": "Mage", "skill": "split_shot", "heroes": 7,
"best_rupture_level": 500, "median_rupture_level": 88, "share": 35 }],
"supports": [{ "skill": "split_shot", "support": "chain", "heroes": 20 }],
"rupture_level_bands": { "75": 19, "500": 1 },
"sample": { "heroes": 20, "note": "the published builds (top heroes per class)" },
"ascendancies": [{ "id": "Mage/Ember", "heroes": 4, "share": 20 }],
"keystones": [{ "id": "ks_test_0", "heroes": 11, "share": 55 }],
"notables": [],
"uniques": [{ "id": "hidden", "heroes": 3, "share": 100 }],
"skills": [{ "id": "split_shot", "heroes": 20, "share": 100 }]
}
game-data/index.json and game-data/<file>.json
The game's own data for the current patch, with secrets masked: skills, supports, passive_tree, item_bases, affixes, runes, runewords, uniques, stat_text, scaling, classes, dragonling and more. index.json lists the files. Ids in the build and meta files point into these files.
The shape of index.json (written by my upload script each patch; the file list here is trimmed):
{
"schema": 1, "game_build": 68, "patch": "0.68.0", "archive": "v1/static/b68",
"uploaded_at": "...",
"files": ["affixes.json", "item_bases.json", "passive_tree.json", "runes.json",
"skills.json", "supports.json", "uniques.json"]
}
Each build's data is also kept in the folder named by archive (relative to the site root, so https://api.playbrokenoath.com/v1/static/b68/ for build 68), so older patches stay readable. Archive files never change and are cached for a day.
Refresh times
My server checks four times an hour, at minutes 3, 18, 33 and 48 (UTC). Each check takes a minute or two and writes:
| Files | How often |
|---|---|
version.json, leagues.json, ladder/* | Every check, so every 15 minutes |
builds/*, meta/* | Once an hour, on the first check at least 55 minutes after the last hourly run. Only builds that changed are uploaded again. |
| Measured Guardian numbers | Every 6 hours (the hourly run at 00, 06, 12 and 18 UTC), over the last day of fights |
game-data/* | With every patch |
version.json carries the live values in refresh, ladder_updated_at and builds_updated_at. If I ever slow a refresh down, those numbers change first, so read them instead of hard coding mine.
Caching and polite polling
- Every file is served with
Cache-Control: max-age=300(5 minutes), plusETagandLast-Modified. SendIf-None-MatchorIf-Modified-Sinceand you get a cheap304when nothing changed. - Please do not poll faster than the refresh: the ladder at most every 15 minutes, builds and meta at most once an hour, game data only when
game_data.patchinversion.jsonchanges. - The easy way: fetch
version.json, and only fetch the rest whenladder_updated_atorbuilds_updated_atmoved. - If you run a site, cache on your side and serve your readers from your copy. Do not fetch my files once per visitor.
Examples
JavaScript (browser or Node 18+, as a module)
const base = "https://api.playbrokenoath.com/v1/";
const get = (path) => fetch(base + path).then((r) => r.json());
const version = await get("version.json");
for (const league of version.leagues) {
const ladder = await get(`ladder/${league}.json`);
const top = ladder.heroes.find((h) => h.has_build);
if (!top) continue;
const hero = await get(top.build);
console.log(league, top.class, "RL", top.rupture_level, hero.build.main_skill);
}
curl
# what is live right now (leagues, refresh times, game data patch)
curl -s https://api.playbrokenoath.com/v1/version.json
# one league's ladder and meta (take the league id from version.json)
LEAGUE=eat-sc-trade
curl -s --compressed https://api.playbrokenoath.com/v1/ladder/$LEAGUE.json
curl -s --compressed https://api.playbrokenoath.com/v1/meta/$LEAGUE.json
# the Mage build list of that league, then one build from it
curl -s https://api.playbrokenoath.com/v1/builds/$LEAGUE/Mage.json
curl -s https://api.playbrokenoath.com/v1/builds/$LEAGUE/Mage/<hero>.json
# headers only: Cache-Control, ETag, Last-Modified
curl -sI https://api.playbrokenoath.com/v1/version.json
Who is in the files
- Only heroes in the official public leagues. Offline heroes never reach my server, and private custom leagues are never published.
- The same heroes the in game ladder shows. Bots, banned accounts and cheat flagged accounts are left out.
- Players who opted out of telemetry are left out, and so is any hero its player asked me to hide. Want a hero hidden? Ask me on Discord.
- Hero ids are opaque 16 character ids made from a salted hash. They stay the same for a hero, so you can follow a hero over time, but they cannot be turned back into a player.
- The files carry no player or hero names, no Steam ids, account ids, emails or IPs, no item ids or seeds, no positions or map data, no gold, stash, chat or cosmetics.
- Secrets stay secret: undiscovered runewords, uniques and recipes show as
"hidden"until the community finds them, so the API never spoils the hunt.
The rules
- Credit "Data: Broken Oath public API" on your page, ideally with a link to the game.
- Your tool is yours. Do not present it as official or as made by me.
- Never try to tie a hero to a real person.
- Use these files, never the game's servers. Do not scrape the game, its login or the in game ladder.
- Respect the refresh times above.
It is an alpha
Fields can be added or change while the game is in alpha. Check schema before you parse and generated_at to know how fresh a file is, and ignore fields you do not know. Numbers, skills and the meta move with every patch, so please tell your users that too.
Talk to me
Made something, found a bug, or need a field that is not here? Come to the Discord: discord.gg/rU5NNFtTEF. I read every post, and I would love to see what you build.