Skip to content

Battle reports API

Send Rise of Kingdoms battle reports from a script, a bot or another app, with an upload token. The same files the upload page takes, the same privacy.

Quick start

  1. Sign in and open Battle reports. Under Upload tokens, name a token and create it. Copy it: it is shown once.
  2. Send one mail file from the game's mail cache:
Windows, in PowerShell or the Command Prompt, as one line (curl.exe ships with Windows 10 and 11)
curl.exe -H "Authorization: Bearer report_your_token_here" -F "file=@C:\Program Files (x86)\Rise of Kingdoms\Rise of Kingdoms Game\save\mailcache\Persistent.Mail.16948011179036099820" https://xerobytes.net/api/rok/battle-reports
macOS or Linux
curl -H "Authorization: Bearer report_your_token_here" \
  -F "file=@Persistent.Mail.16948011179036099820" \
  https://xerobytes.net/api/rok/battle-reports

The answer says, for each file, whether it was accepted, was already on file or was refused, and for an accepted battle report it returns exactly what was stored. If a file is not a battle report, it is refused and nothing of it is kept.

Where the files are

The Rise of Kingdoms PC client saves each mail in full, as one file in its mail cache, when it receives it. On Windows that is usually C:\Program Files (x86)\Rise of Kingdoms\Rise of Kingdoms Game\save\mailcache, and each mail is a file named Persistent.Mail. followed by a long number. The folder holds every kind of mail (system, alliance, player, scouting and more); battle reports are one kind among them, and a file's name does not say which kind it is. Only the contents do.

The cache is a rolling window: older mail drops out as new mail comes in, so upload the reports you want while they are there. Opening a report in the game adds nothing to its file. The files carry a checksum, so a changed file is refused. This site never contacts the game's servers: it reads only files you send it.

Sending a whole folder by script sends every kind of mail, and the server refuses and discards everything that is not a battle report without keeping any of it. If you would rather not send other mail at all, the upload page picks the battle reports out in your browser and sends only those.

Upload tokens

Every request carries an upload token as a bearer credential: Authorization: Bearer report_.... A token is report_ followed by 32 lowercase letters and digits. Create and revoke tokens on the Battle reports page; an account can hold ten live tokens.

  • A token can upload, list, read and delete your battle reports. It cannot sign in, change your account or reach anything else on the site.
  • The site stores only a SHA-256 fingerprint of the token and its last four characters, so it cannot show you a token again. Lost one? Revoke it and create another.
  • Treat it like a password. Whoever holds it can upload in your name and read your reports. Revoking is immediate and permanent.
  • The API takes tokens only. The site's sign-in session does not work here, so a web page elsewhere cannot use your browser to upload.

Upload reports

POST /api/rok/battle-reports

Send the files one of two ways:

Content typeBodyNotes
multipart/form-dataOne to 50 file parts. The field name does not matter; file is conventional.Each part keeps its file name, which is echoed back in the results to match them up. It is not stored.
application/octet-streamThe bytes of one mail file.Optional X-File-Name header, echoed back the same way.

A Content-Length header is required (curl, browsers and the usual libraries send one). Each file may be up to 256 KB; real battle reports are a few kilobytes.

The answer is 200 whenever the request itself was sound, even if some files were refused: one bad file never costs the others their place. The counts say what happened overall and results holds one entry per file, in the order sent:

200 OK
{
  "accepted": 1,
  "duplicates": 1,
  "rejected": 1,
  "results": [
    {
      "file": "Persistent.Mail.16948011179036099820",
      "status": "accepted",
      "report": {
        "id": "4b0d6f7e-2f3a-4d0e-9c55-0f6f3c1e2a10",
        "foughtOn": "2026-09-25",
        "mode": "field",
        "pairings": 1,
        "createdAt": "2026-09-26T18:02:11.482113+00:00",
        "report": { "format": "xerobytes.battle/1", "...": "the whole report, see below" }
      }
    },
    { "file": "Persistent.Mail.16931114179036077320", "status": "duplicate" },
    {
      "file": "Persistent.Mail.16426540179035434620",
      "status": "rejected",
      "error": "not_a_battle_report",
      "message": "A mail, but not a battle report. Only battle reports are kept."
    }
  ]
}
statusMeaning
acceptedA new battle report, now stored. report is what was kept.
duplicateThat report is already on file, uploaded before by you or by anyone else who holds the same mail. Nothing changes.
rejectedRefused and not stored; error says why:
errorWhy
not_a_battle_reportA mail, but another kind: system, alliance, player, scouting and so on.
not_a_mail_fileNot a Rise of Kingdoms mail file, or changed after the game wrote it (the checksum does not match).
damagedThe header is right but the contents cannot be read.
unsupported_formatA battle report laid out in a way this reader does not know yet, usually after a game update. The message names the field.
too_largeOver 256 KB.
quota_reachedYour account already holds 5000 reports. Delete some to make room.

List your reports

GET /api/rok/battle-reports?limit=50&mode=field&since=2026-09-01
ParameterMeaning
limitHow many to return, 1 to 100. Default 50.
modeOnly this kind of fight: field, rally, garrison or npc.
sinceOnly fights on or after this day, YYYY-MM-DD.
cursorThe nextCursor from the previous page, to get the next one.

Reports come newest upload first, each with the whole stored report. When there are more, nextCursor is a string to pass back as cursor; on the last page it is null.

200 OK
{
  "reports": [
    { "id": "4b0d6f7e-...", "foughtOn": "2026-09-25", "mode": "field", "pairings": 1,
      "createdAt": "2026-09-26T18:02:11.482113+00:00", "report": { "format": "xerobytes.battle/1", "...": "..." } }
  ],
  "nextCursor": "WyIyMDI2LTA5LTI2VDE4OjAyOjExLjQ4MjExMyswMDowMCIsIjRiMGQ2ZjdlLTJmM2EtNGQwZS05YzU1LTBmNmYzYzFlMmExMCJd"
}

Read or delete one report

GET    /api/rok/battle-reports/{id}
DELETE /api/rok/battle-reports/{id}

GET returns one stored report in the same shape as a list entry. DELETE removes it for good and answers { "deleted": "<id>" }. Either answers 404 for an id that is not one of yours, exactly as for one that does not exist.

Delete with curl
curl -X DELETE -H "Authorization: Bearer report_your_token_here" \
  https://xerobytes.net/api/rok/battle-reports/4b0d6f7e-2f3a-4d0e-9c55-0f6f3c1e2a10

Errors

A refused request answers JSON of one shape: error, a stable code to branch on, and message, a sentence for a person. Codes do not change; messages may.

{ "error": "rate_limited", "message": "Too many requests for this account. Wait 12 seconds and send it again.", "retry_after": 12 }
StatuserrorMeaning
400bad_requestThe multipart body could not be read.
400no_filesA multipart upload with no file in it.
400bad_cursor, bad_mode, bad_sinceA list parameter this API does not accept.
401invalid_tokenNo token, a malformed one, or one that does not exist.
401token_revokedThe token was revoked. Create another.
403account_disabledThe account behind the token is disabled.
404not_foundNo report of yours has that id.
411length_requiredNo Content-Length header.
413too_large, too_many_filesMore than 50 files, or a body larger than they could be.
415unsupported_media_typeNeither multipart/form-data nor application/octet-stream.
429rate_limitedOver the rate limit. Wait the Retry-After header's seconds, then send the same request again.
500upload_failed, list_failed, read_failed, delete_failed, token_check_failed, rate_limit_check_failedSomething failed on the site. Nothing was half-done: try again shortly.
503not_configuredUploads are switched off on this deployment.

Limits

LimitValue
Files per request50
Size of one file256 KB
Requests per account30 a minute, reads included
Files per account300 a minute
Reports one account can hold5,000

The rate limit is per account, not per token: the upload page and all of an account's tokens share it. It runs in fixed windows of 60 seconds. Past it, the answer is 429 with a Retry-After header; wait that long and send the same request again. A whole mail cache, a few hundred reports, fits comfortably in one window.

The battle report format

Every stored report is JSON in the format named by its format field, currently xerobytes.battle/1. It is read from the game's own report and holds what the fight was made of and nothing about who fought it.

A report, abridged
{
  "format": "xerobytes.battle/1",
  "foughtOn": "2026-09-25",
  "durationSeconds": 32,
  "kvk": true,
  "mode": "field",
  "scene": "gsmp",
  "you": {
    "primary": {
      "heroId": 141, "slug": "thutmose", "level": 60, "stars": 6, "awakened": false,
      "skills": [5, 5, 5, 1], "expertise": false, "awakenedSkill": null, "unplacedSkills": []
    },
    "secondary": {
      "heroId": 6, "slug": "yi-seong-gye", "level": 50, "stars": 5, "awakened": true,
      "skills": [5, 5, 5, 5], "expertise": true, "awakenedSkill": null, "unplacedSkills": []
    },
    "equipment": [{ "slot": 1, "item": 20060, "code": 0, "iconic": 0 }, { "slot": 3, "item": 20021, "code": 21, "iconic": 1 }],
    "formation": null, "armaments": [], "watchtower": null, "morale": 0,
    "assistSkills": [], "supportSkills": [], "rally": false, "structure": false,
    "participants": [{ "primary": { "heroId": 141, "slug": "thutmose", "level": 60 },
                       "secondary": { "heroId": 6, "slug": "yi-seong-gye", "level": 50 } }]
  },
  "engagements": [
    {
      "opponent": {
        "kind": "player", "npc": null,
        "primary": { "heroId": 7, "slug": "cao-cao", "level": 60, "...": "as above" },
        "secondary": { "heroId": 99, "slug": "aethelflaed", "level": 47, "...": "as above" },
        "equipment": [{ "slot": 1, "item": 20100, "code": 0, "iconic": 0 }], "...": "the rest of a setup"
      },
      "start": 0, "end": 31, "rounds": 32, "result": "win",
      "losses": {
        "you": {
          "troops": 210000, "marchSize": 210000, "remaining": 192780,
          "dead": 0, "severelyWounded": 6640, "slightlyWounded": 10580, "healed": 0,
          "killPoints": 788470, "powerLost": 66400,
          "powerLostToAttacks": 48790, "powerLostToSkills": 17610,
          "reinforcementsJoined": 0, "reinforcementsLeft": 0, "watchtower": null, "tier": 5
        },
        "opponent": { "troops": 180000, "remaining": 0, "severelyWounded": 78847, "tier": 4, "...": "as above" }
      }
    }
  ],
  "totals": null,
  "troopsOverTime": [[0, 210000], [31, 192780]],
  "reinforcements": []
}

The report

FieldMeaning
formatxerobytes.battle/1. A new version gets a new number.
foughtOnThe day the fight began, in UTC. The time of day is not kept.
durationSecondsFrom the first blow to the last.
kvkWhether it was fought on a KvK map, when the report can tell; otherwise null.
modenpc when any opponent is the game's (barbarians, forts, events); rally when either side is a rally; garrison when either is a structure; otherwise field. A city garrison is not marked in the report and reads as field.
sceneThe game's own name for the report's context, such as gsmp.
youYour side: a setup (below).
engagementsOne entry per march you fought, in the order the report lists them.
totalsThe report’s own totals across all engagements, when it has them (fights with more than one); otherwise null.
troopsOverTimeYour troops over the fight as [second, troops] pairs.
reinforcementsMarches that joined or left mid-fight: second, the game's event number (18 joined, 26 left), troops, own, and their commanders.

A setup

Your side and each opponent share this shape.

FieldMeaning
primary, secondaryThe two commanders (below), or null.
equipmentThe primary commander's gear, the only gear the game applies: slot, item (the game's item id), code, the report's own number for the piece, and iconic, the piece's iconic tier read from the code's last digit: 0 for none (only Legendary pieces have one), or null for a digit no tier has. The code's tens digit is not read yet and may be the refinement.
formationThe formation number, when reported.
armamentsArmaments, when reported, with the game’s own affix and buff text.
watchtower, moraleThe city watchtower level and KvK morale, when reported.
assistSkills, supportSkillsAssist and support skills, when reported: the commander, the skill id and its level.
rally, structureWhether this side is a rally, and whether it is a structure rather than a march.
participantsEveryone in the march or rally, by commanders only.
kind, npcOpponents only: player or npc, and for an NPC the game's type, category and level numbers.

A commander

FieldMeaning
heroIdThe game’s id for the commander.
slugThe site's commander, as in /gaming/rise-of-kingdoms/commanders/<slug>; null for one the roster does not have, such as a barbarian leader.
level, stars, awakenedAs the report gives them.
skillsThe levels of skills 1 to 4, placed by the game’s skill ids; 0 where the report lists none.
expertiseTrue when the report lists a skill’s expertise version, which only an awakened commander has.
awakenedSkillThe level of the fifth, awakened skill, for commanders that have one.
unplacedSkillsSkill ids the site cannot place yet, with their levels: usually a commander newer than the site.

An engagement

FieldMeaning
opponentThe march you fought: a setup with kind and npc.
start, endSeconds from the start of the report.
roundsRounds, as the report counts them; about one a second.
resultThe game's verdict for you: win when their march broke, loss when yours did, draw when neither did (a retreat, or the fight was cut short).
losses.you, losses.opponentWhat each side brought and lost in this engagement (below).

Losses

FieldMeaning
troopsTroops in this engagement when it began.
marchSizeThe march’s size when it set out, before any earlier fight.
dead, severelyWounded, slightlyWounded, healed, remainingAs the report counts them.
killPointsKill points this side earned in the engagement.
powerLostPower this side lost, split into powerLostToAttacks (normal attacks) and powerLostToSkills (skills).
reinforcementsJoined, reinforcementsLeftTroops that joined or left during it.
watchtowerFor a garrison with a watchtower, its remaining and maximum durability; otherwise null.
tierThe troop tier, 2 to 5, worked out from the report; null when the march mixed tiers or it cannot tell.

The report never states a tier, so it is worked out: every troop lost costs its side a fixed amount of power and hands the other side a fixed number of kill points, both rising with the tier (T2 is 2 power and 2 kill points a troop, T3 is 3 and 4, T4 is 4 and 10, T5 is 10 and 20). A tier is given only when both figures match one tier exactly, so a mixed march is left null rather than guessed. A fight against the game's own troops gives no kill points, so its tiers are null too.

What a report does not hold: troop types (infantry, cavalry, archers, siege), talents, and buffs. The turn-by-turn battle log is not in the file either; the game downloads it separately when you open it, and this site does not fetch it.

More examples

PowerShell: a whole folder
# Upload every mail in the folder, 50 files a request. Windows PowerShell 5.1 or later.
$token  = "report_your_token_here"
$folder = "C:\Program Files (x86)\Rise of Kingdoms\Rise of Kingdoms Game\save\mailcache"
$files  = Get-ChildItem -Path $folder -Filter "Persistent.Mail.*" -File |
          Where-Object { $_.Length -le 262144 }

for ($i = 0; $i -lt $files.Count; $i += 50) {
    $batch = $files[$i..([Math]::Min($i + 49, $files.Count - 1))]
    $form  = $batch | ForEach-Object { "-F"; "file=@$($_.FullName)" }
    curl.exe --silent --show-error `
        -H "Authorization: Bearer $token" @form `
        https://xerobytes.net/api/rok/battle-reports
    Start-Sleep -Seconds 3
}
Python (requests)
import pathlib, time, requests

TOKEN = "report_your_token_here"
URL = "https://xerobytes.net/api/rok/battle-reports"
folder = pathlib.Path(r"C:\Program Files (x86)\Rise of Kingdoms\Rise of Kingdoms Game\save\mailcache")
files = [p for p in folder.glob("Persistent.Mail.*") if p.stat().st_size <= 262144]

for start in range(0, len(files), 50):
    batch = files[start:start + 50]
    while True:
        r = requests.post(
            URL,
            headers={"Authorization": f"Bearer {TOKEN}"},
            files=[("file", (p.name, p.read_bytes())) for p in batch],
            timeout=60,
        )
        if r.status_code != 429:
            break
        time.sleep(int(r.headers.get("Retry-After", "10")))
    r.raise_for_status()
    body = r.json()
    print(f"{body['accepted']} new, {body['duplicates']} already on file, {body['rejected']} refused")
Node.js
// Node 18 or later: fetch, FormData and Blob are built in.
import { readdir, readFile, stat } from 'node:fs/promises';
import { join } from 'node:path';

const TOKEN = 'report_your_token_here';
const URL = 'https://xerobytes.net/api/rok/battle-reports';
const folder = 'C:/Program Files (x86)/Rise of Kingdoms/Rise of Kingdoms Game/save/mailcache';

const names = (await readdir(folder)).filter((n) => n.startsWith('Persistent.Mail.'));
for (let i = 0; i < names.length; i += 50) {
  const form = new FormData();
  for (const name of names.slice(i, i + 50)) {
    const path = join(folder, name);
    if ((await stat(path)).size > 262144) continue;
    form.append('file', new Blob([await readFile(path)]), name);
  }
  const res = await fetch(URL, { method: 'POST', headers: { Authorization: `Bearer ${TOKEN}` }, body: form });
  if (res.status === 429) {
    await new Promise((r) => setTimeout(r, Number(res.headers.get('retry-after') ?? 10) * 1000));
    i -= 50;
    continue;
  }
  const body = await res.json();
  console.log(`${body.accepted} new, ${body.duplicates} already on file, ${body.rejected} refused`);
}
One file as a raw body
curl -H "Authorization: Bearer report_your_token_here" \
  -H "Content-Type: application/octet-stream" \
  -H "X-File-Name: Persistent.Mail.16948011179036099820" \
  --data-binary @Persistent.Mail.16948011179036099820 \
  https://xerobytes.net/api/rok/battle-reports
Everything you have uploaded, page by page (bash and jq)
cursor=""
while :; do
  page=$(curl -s -H "Authorization: Bearer report_your_token_here" \
    "https://xerobytes.net/api/rok/battle-reports?limit=100${cursor:+&cursor=$cursor}")
  echo "$page" | jq -c '.reports[] | {foughtOn, mode, id}'
  cursor=$(echo "$page" | jq -r '.nextCursor // empty')
  [ -z "$cursor" ] && break
done

Privacy

  • Each file is decoded on the site's server and only the fight is copied out of it, field by field. The file itself is never stored, and neither is anything that identifies a player: governor and alliance names, tags and ids, player, account and device ids, kingdom and server numbers, map and city coordinates, avatars, the mail's title and the time of day.
  • A mail that is not a battle report is refused as soon as its kind is known. Nothing from it is kept or written to a log.
  • To recognise a report uploaded twice, the site keeps a SHA-256 fingerprint over the report's number, the game server and the uploader's player id. None of those is stored, and the fingerprint cannot be turned back into them.
  • Your reports are readable by you and by the site's admins, who use real fights to calibrate The Crucible, and by nobody else. Delete any of them here or on the page; deleting your account deletes them all, with your tokens.
  • Questions about your data go to privacy@kirobyte.com, as in the privacy policy.

Versions and changes

The address and the request shapes above are stable. If the report format has to change in a way that would break a reader, it gets a new format number and reports already stored keep theirs. Changes are noted in the site's changelog. When the game changes its reports, a new report may be refused as unsupported_format until the reader catches up; the message names the field it stopped at, and the file can be sent again once it has.

Battle Reports API: Rise of Kingdoms Reports from Scripts — xeroBytes