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
- Sign in and open Battle reports. Under Upload tokens, name a token and create it. Copy it: it is shown once.
- Send one mail file from the game's mail cache:
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-reportscurl -H "Authorization: Bearer report_your_token_here" \
-F "file=@Persistent.Mail.16948011179036099820" \
https://xerobytes.net/api/rok/battle-reportsThe 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-reportsSend the files one of two ways:
| Content type | Body | Notes |
|---|---|---|
multipart/form-data | One 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-stream | The 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:
{
"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."
}
]
}| status | Meaning |
|---|---|
accepted | A new battle report, now stored. report is what was kept. |
duplicate | That report is already on file, uploaded before by you or by anyone else who holds the same mail. Nothing changes. |
rejected | Refused and not stored; error says why: |
| error | Why |
|---|---|
not_a_battle_report | A mail, but another kind: system, alliance, player, scouting and so on. |
not_a_mail_file | Not a Rise of Kingdoms mail file, or changed after the game wrote it (the checksum does not match). |
damaged | The header is right but the contents cannot be read. |
unsupported_format | A battle report laid out in a way this reader does not know yet, usually after a game update. The message names the field. |
too_large | Over 256 KB. |
quota_reached | Your 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| Parameter | Meaning |
|---|---|
limit | How many to return, 1 to 100. Default 50. |
mode | Only this kind of fight: field, rally, garrison or npc. |
since | Only fights on or after this day, YYYY-MM-DD. |
cursor | The 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.
{
"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.
curl -X DELETE -H "Authorization: Bearer report_your_token_here" \
https://xerobytes.net/api/rok/battle-reports/4b0d6f7e-2f3a-4d0e-9c55-0f6f3c1e2a10Errors
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 }| Status | error | Meaning |
|---|---|---|
| 400 | bad_request | The multipart body could not be read. |
| 400 | no_files | A multipart upload with no file in it. |
| 400 | bad_cursor, bad_mode, bad_since | A list parameter this API does not accept. |
| 401 | invalid_token | No token, a malformed one, or one that does not exist. |
| 401 | token_revoked | The token was revoked. Create another. |
| 403 | account_disabled | The account behind the token is disabled. |
| 404 | not_found | No report of yours has that id. |
| 411 | length_required | No Content-Length header. |
| 413 | too_large, too_many_files | More than 50 files, or a body larger than they could be. |
| 415 | unsupported_media_type | Neither multipart/form-data nor application/octet-stream. |
| 429 | rate_limited | Over the rate limit. Wait the Retry-After header's seconds, then send the same request again. |
| 500 | upload_failed, list_failed, read_failed, delete_failed, token_check_failed, rate_limit_check_failed | Something failed on the site. Nothing was half-done: try again shortly. |
| 503 | not_configured | Uploads are switched off on this deployment. |
Limits
| Limit | Value |
|---|---|
| Files per request | 50 |
| Size of one file | 256 KB |
| Requests per account | 30 a minute, reads included |
| Files per account | 300 a minute |
| Reports one account can hold | 5,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.
{
"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
| Field | Meaning |
|---|---|
format | xerobytes.battle/1. A new version gets a new number. |
foughtOn | The day the fight began, in UTC. The time of day is not kept. |
durationSeconds | From the first blow to the last. |
kvk | Whether it was fought on a KvK map, when the report can tell; otherwise null. |
mode | npc 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. |
scene | The game's own name for the report's context, such as gsmp. |
you | Your side: a setup (below). |
engagements | One entry per march you fought, in the order the report lists them. |
totals | The report’s own totals across all engagements, when it has them (fights with more than one); otherwise null. |
troopsOverTime | Your troops over the fight as [second, troops] pairs. |
reinforcements | Marches 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.
| Field | Meaning |
|---|---|
primary, secondary | The two commanders (below), or null. |
equipment | The 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. |
formation | The formation number, when reported. |
armaments | Armaments, when reported, with the game’s own affix and buff text. |
watchtower, morale | The city watchtower level and KvK morale, when reported. |
assistSkills, supportSkills | Assist and support skills, when reported: the commander, the skill id and its level. |
rally, structure | Whether this side is a rally, and whether it is a structure rather than a march. |
participants | Everyone in the march or rally, by commanders only. |
kind, npc | Opponents only: player or npc, and for an NPC the game's type, category and level numbers. |
A commander
| Field | Meaning |
|---|---|
heroId | The game’s id for the commander. |
slug | The 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, awakened | As the report gives them. |
skills | The levels of skills 1 to 4, placed by the game’s skill ids; 0 where the report lists none. |
expertise | True when the report lists a skill’s expertise version, which only an awakened commander has. |
awakenedSkill | The level of the fifth, awakened skill, for commanders that have one. |
unplacedSkills | Skill ids the site cannot place yet, with their levels: usually a commander newer than the site. |
An engagement
| Field | Meaning |
|---|---|
opponent | The march you fought: a setup with kind and npc. |
start, end | Seconds from the start of the report. |
rounds | Rounds, as the report counts them; about one a second. |
result | The 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.opponent | What each side brought and lost in this engagement (below). |
Losses
| Field | Meaning |
|---|---|
troops | Troops in this engagement when it began. |
marchSize | The march’s size when it set out, before any earlier fight. |
dead, severelyWounded, slightlyWounded, healed, remaining | As the report counts them. |
killPoints | Kill points this side earned in the engagement. |
powerLost | Power this side lost, split into powerLostToAttacks (normal attacks) and powerLostToSkills (skills). |
reinforcementsJoined, reinforcementsLeft | Troops that joined or left during it. |
watchtower | For a garrison with a watchtower, its remaining and maximum durability; otherwise null. |
tier | The 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
# 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
}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 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`);
}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-reportscursor=""
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
donePrivacy
- 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.