WvW Insights API
Upload Guild Wars 2 WvW combat logs and get back full parsed reports. Build your own uploader, in-game addon, Discord bot, or squad tool on the same API our own Nexus addon uses.
https://parser.rethl.net/api/v1
CORS enabled
No API key needed
Overview
The API does one job: you send it two or more .zevtc combat logs, it
combines and parses them with Elite Insights and TopStats, and gives you back a shareable HTML report.
Three things worth knowing before you start:
- No registration or API key. You mint a user token in one call and start uploading.
- Uploads are grouped into a “batch.” You stage files, review them, remove any you don't want, and only then commit the batch for parsing. Nothing is parsed until you say so.
- Parsing is asynchronous. Committing returns immediately with a job id; you poll that job until the reports are ready. A parse can take anywhere from seconds to several minutes.
Every response uses the same envelope, so you only need to write your error handling once.
File uploads are the one exception — those are multipart/form-data.
Quickstart
From nothing to a finished report in five calls. This uses curl; equivalents in
JavaScript, Python and Rust are further down.
1. Get a user token (once — then save it)
# A token is your identity. It links reports to you so you can list them later.
curl -X POST https://parser.rethl.net/api/v1/tokens
# → {"ok":true,"data":{"token":"aB3xK9mN2pQ7rS4tV6wY8zC1dE"}}
2. Create a batch
curl -X POST https://parser.rethl.net/api/v1/batches
# → {"ok":true,"data":{
# "batch_id":"aB3xK9",
# "edit_token":"d79b7ec8…", ← keep this, it authorises the next steps
# "urls":{ … } }}
3. Stage at least two logs (repeat per file)
curl -X POST https://parser.rethl.net/api/v1/batches/aB3xK9/files \
-H "Authorization: Bearer d79b7ec8…" \
-F "file=@20260720-202900.zevtc"
# → {"ok":true,"data":{
# "file_id":"20260720-202900.zevtc",
# "map":"GBL", "commander":"Pineapple Lonewolf", "is_wvw":true }}
You must stage at least two logs before processing. The server parses each file's header as it arrives, so you get the map, commander and recorder back immediately — handy for showing a review list without reading the file yourself.
4. Commit the batch for parsing (requires 2+ logs)
curl -X POST https://parser.rethl.net/api/v1/batches/aB3xK9/process \
-H "Authorization: Bearer d79b7ec8…" \
-d "token=aB3xK9mN2pQ7rS4tV6wY8zC1dE" \
-d "guild_name=My Guild"
# → 202 {"ok":true,"data":{"job_id":"aB3xK9","queued":false, … }}
5. Poll until it's done
curl https://parser.rethl.net/api/v1/jobs/aB3xK9
# → {"ok":true,"data":{"state":"processing","progress":62,
# "component":"topstats_parsing"}}
# …keep polling every 2–3s until state is "complete":
# → {"ok":true,"data":{"state":"complete","progress":100,
# "reports":[{"role":"main","url":"https://output.rethl.net/…/Report.html"}]}}
Everything else — listing what's staged, removing a file, fetching past reports — is optional convenience on top of these five calls.
How it works
The batch lifecycle
A batch is a group of at least two logs parsed together into one combined report. It has three phases:
The review phase is the point of the design. A batch sits open for as long as you like — you can
add files, list what's staged, and remove any that shouldn't be included. Nothing is parsed until
you call /process. If your tool has a “are these the right logs?” screen, this is what
backs it.
The server requires at least two staged .zevtc files when you call /process.
There is no “expected files” parameter to get wrong, and no race between your last upload and the commit.
Two kinds of token
| Token | What it is | Lifetime |
|---|---|---|
tokenuser token |
Your identity. 26 characters. Links parsed reports to you so /reports can list them. |
Permanent — save it |
edit_tokenbatch token |
Proves you own one specific batch. Required to add, list, delete or process its files. | 3 hours, per batch |
They're separate on purpose: the edit_token is short-lived and scoped to a single
batch, so passing it around (or logging it) can't expose a user's report history.
Authentication
There is no account system and no API key. Only batch operations need authenticating, using the
edit_token you got when creating the batch. Three ways to send it — pick whichever
suits your HTTP client:
# 1. Bearer header (recommended)
-H "Authorization: Bearer d79b7ec8…"
# 2. Query string — convenient for GET and DELETE
GET /api/v1/batches/aB3xK9?edit_token=d79b7ec8…
# 3. Form field — convenient for POST
-d "edit_token=d79b7ec8…"
Query strings end up in server access logs and browser history. The Bearer header doesn't. Use option 2 only where your client makes headers awkward.
Responses & errors
Every endpoint returns the same envelope, and the HTTP status always agrees with it.
Success
{ "ok": true, "data": { … } }
Failure
{ "ok": false, "error": { "code": "unauthorized",
"message": "Missing or invalid edit_token for this batch" } }
Branch on error.code — it's stable and meant for your code. error.message
is written for humans and may be reworded; don't match on it.
Service info
Health check and feature flags. Cheap — use it to verify the service is up and to see which optional features are enabled before offering them in your UI.
{ "ok": true, "data": {
"api_version": "1",
"status": "ok", // or "maintenance"
"plugin_version": "2.5.5.6",
"features": { "legacy_parser": true, "discord": true,
"highscores": true, "dpsreport": true } } }
If status is "maintenance", expect uploads to fail — surface that to
your users rather than retrying.
Tokens
Mint a new user token. Takes no parameters. Returns 201.
{ "ok": true, "data": { "token": "aB3xK9mN2pQ7rS4tV6wY8zC1dE" } }
This is the only time the token is shown, and it's the sole link to a user's report history. Minting a new one on every run orphans everything they parsed before. Save it to your config and reuse it.
Check whether a token exists — use it to validate what a user typed in before you rely on it.
{ "ok": true, "data": { "token": "aB3xK9…", "valid": true } }
A malformed or unknown token returns 200 with "valid": false — the
question was well-formed, the answer is just no. It is not an error.
Batches
Reserve a new batch. No parameters. Returns 201.
{ "ok": true, "data": {
"batch_id": "aB3xK9",
"edit_token": "d79b7ec8bc1f427f19c754…",
"urls": { "self": "…/batches/aB3xK9",
"files": "…/batches/aB3xK9/files",
"process": "…/batches/aB3xK9/process" } } }
Batches expire after 3 hours if never processed.
List what's currently staged, with the metadata the server parsed from each file. This is what you'd render on a review screen.
{ "ok": true, "data": {
"batch_id": "aB3xK9",
"file_count": 2,
"files": [ { "file_id": "20260720-202900.zevtc",
"name": "20260720-202900.zevtc",
"size": 501662,
"map": "GBL",
"map_name": "Green Alpine Borderlands",
"commander": "Pineapple Lonewolf",
"recorder": "Pineapple Lonewolf",
"is_wvw": true } ] } }
Files
Stage one WvW .zevtc into the batch. Send it as multipart/form-data in a
field named file. One file per request. PvE or unknown-map logs are rejected and
removed. Returns 201 for an accepted WvW log.
| Field | Type | Notes |
|---|---|---|
file | file | Required. A .zevtc between 1 KB and 1 GB. |
{ "ok": true, "data": {
"file_id": "20260720-202900.zevtc", // use this to delete it
"name": "20260720-202900.zevtc",
"size": 501662, "map": "GBL",
"commander": "Pineapple Lonewolf", "is_wvw": true,
"total_size": 501662, "remaining_size": 1073240162 } }
total_size and remaining_size are byte counts for the current batch.
The server sanitises filenames on the way in, so the stored name may differ from what you sent.
Deleting by your local filename will eventually fail — key off file_id.
A PvE or unrecognised-map upload returns HTTP 400 with error code
not_wvw_log. The rejected file is not kept in the batch. If an upload would take the
batch above the 1 GB total limit, it returns HTTP 413 with
error code session_quota_exceeded.
Uploading the same filename twice is not an error. You get 200 with
"duplicate": true and nothing is re-uploaded.
Remove one staged file before committing.
{ "ok": true, "data": { "deleted": "20260720-202900.zevtc" } }
Processing
Commit the batch. Everything currently staged gets parsed. Returns 202 Accepted
immediately — the parse runs in the background.
At least two logs are required. A one-file batch is rejected with HTTP
400 and error code minimum_files.
Options can be sent as form fields or a JSON body. All are optional.
| Option | Type | Description |
|---|---|---|
token | string | Your user token. Strongly recommended — without it the report won't be linked to anyone and can't be listed later. |
guild_name | string | Shown as the report title. Defaults to a generic name. |
client | string | Identifies your app in usage stats, e.g. my-uploader. Defaults to api. |
json_only | bool | Only output the enriched LogCombiner JSON summary (WvW_Combat_Summary.json) directly in the session folder without generating an HTML report. Automatically skips legacy parsing and dps.report uploads. |
legacy | bool | Also produce the legacy report. Roughly doubles parse time. |
legacy_only | bool | Produce only the legacy report. |
react_parser | bool | Render the main report using the newer interactive React template. This replaces the main report's layout — it does not produce an extra report. |
highscores | bool | Inject high-score data. Defaults to on. |
dpsreport_token | string | Also upload to dps.report using this user token. |
webhook_url | string | A Discord webhook URL. When set, the finished report is posted there automatically — no polling needed. |
report_name | string | Title used in the Discord post. Only relevant with webhook_url. |
{ "ok": true, "data": {
"batch_id": "aB3xK9",
"job_id": "aB3xK9", // same value — poll this
"queued": false,
"queue_position": 0,
"status_url": "https://parser.rethl.net/api/v1/jobs/aB3xK9" } }
Set webhook_url and you never have to poll — the report is posted to Discord when
it's ready. Ideal for a bot or an addon that uploads when a squad disbands.
Job status
Poll a running parse. No authentication — the id is unguessable. Poll every 2–3 seconds; faster gains you nothing.
| Query | Description |
|---|---|
since |
Log-line cursor. Pass back the next_log_index from your last poll to fetch only
new lines instead of the whole log. |
While running
{ "ok": true, "data": {
"job_id": "aB3xK9",
"state": "processing",
"progress": 62,
"component": "topstats_parsing", // current pipeline stage
"elapsed": 44,
"logs": [ { "message": "Processing batch 1/1", "type": "info" } ],
"next_log_index": 12 } }
When complete
{ "ok": true, "data": {
"state": "complete", "progress": 100,
"reports": [
{ "role": "main", "name": "Report.html",
"url": "https://output.rethl.net/aB3xK9/Report.html" },
{ "role": "legacy", "name": "LegacyReport.html", "url": "…" } ] } }
| state | Meaning |
|---|---|
queued | Waiting for a slot. queue_position tells you where. |
processing | Running. Use progress and component. |
complete | Done — reports[] holds the links. |
failed | Parse failed. error explains why. |
Take the report whose role is "main"; don't rely on array order or
match on filenames. If component comes back as "stalled", the worker
has gone quiet for over two minutes — worth surfacing to the user.
Reports
List everything a user token has parsed, newest first. Paginated.
| Query | Default | Description |
|---|---|---|
token | — | Required. The 26-character user token. |
page | 1 | Page number. |
per_page | 20 | 1–100. |
{ "ok": true, "data": {
"reports": [ { "id": "aB3xK9",
"processed_at": "2026-08-06 21:14:03",
"report_url": "https://output.rethl.net/aB3xK9/Report.html",
"legacy_report_url": null } ],
"pagination": { "page": 1, "per_page": 20,
"total": 37, "total_pages": 2 } } }
Fetch a single report by its session id. No token needed — report URLs are public.
{ "ok": true, "data": {
"report": {
"id": "aB3xK9",
"processed_at": "2026-08-06 21:14:03",
"report_url": "https://output.rethl.net/20260806/aB3xK9/Report.html",
"json_url": "https://parser.rethl.net/api/v1/reports/aB3xK9/data.json",
"legacy_report_url": null
} } }
Direct download / streaming endpoint for raw enriched combat summary JSON (WvW_Combat_Summary.json). You can also request GET /reports/{id}?format=json or GET /jobs/{id}/data.json.
Returns Content-Type: application/json with full CORS headers (Access-Control-Allow-Origin: *) so custom dashboards, scripts, and Discord bots can consume squad stats without HTML scraping.
// Returns array of tiddlers / enriched combat summary data
[
{
"title": "20260816-Log-Summary",
"caption": "2026-08-16 - Squad Fight Summary",
"tags": "2026 2026-08 summary"
},
...
]
Full examples
The complete flow — create, upload, process, poll — in four languages.
const API = 'https://parser.rethl.net/api/v1';
// Unwraps the envelope so callers just get `data`, and throws on failure.
async function call(path, opts = {}) {
const res = await fetch(API + path, opts);
const body = await res.json();
if (!body.ok) throw new Error(body.error.message);
return body.data;
}
async function uploadLogs(files, userToken) {
const batch = await call('/batches', { method: 'POST' });
const auth = { 'Authorization': `Bearer ${batch.edit_token}` };
// Stage each file. `staged` carries map/commander for a review UI.
for (const file of files) {
const form = new FormData();
form.append('file', file);
const staged = await call(`/batches/${batch.batch_id}/files`,
{ method: 'POST', headers: auth, body: form });
console.log(staged.name, staged.map, staged.commander);
}
await call(`/batches/${batch.batch_id}/process`, {
method: 'POST', headers: auth,
body: new URLSearchParams({ token: userToken, client: 'my-web-uploader' })
});
// Poll until it settles.
while (true) {
const job = await call(`/jobs/${batch.batch_id}`);
if (job.state === 'complete')
return job.reports.find(r => r.role === 'main').url;
if (job.state === 'failed') throw new Error(job.error);
await new Promise(r => setTimeout(r, 2500));
}
}
import time, requests
API = 'https://parser.rethl.net/api/v1'
def call(method, path, **kw):
"""Unwrap the envelope; raise on failure."""
body = requests.request(method, API + path, timeout=300, **kw).json()
if not body['ok']:
raise RuntimeError(body['error']['message'])
return body['data']
def upload_logs(paths, user_token):
batch = call('POST', '/batches')
auth = {'Authorization': f"Bearer {batch['edit_token']}"}
bid = batch['batch_id']
for path in paths:
with open(path, 'rb') as fh:
staged = call('POST', f'/batches/{bid}/files',
headers=auth, files={'file': fh})
print(staged['name'], staged['map'], staged['commander'])
call('POST', f'/batches/{bid}/process', headers=auth,
data={'token': user_token, 'client': 'my-python-tool'})
while True:
job = call('GET', f'/jobs/{bid}')
if job['state'] == 'complete':
return next(r['url'] for r in job['reports'] if r['role'] == 'main')
if job['state'] == 'failed':
raise RuntimeError(job.get('error', 'parse failed'))
print(job['state'], job['progress'], job.get('component', ''))
time.sleep(2.5)
// Cargo.toml: ureq = { version = "2", features = ["json"] }
// ureq_multipart = "1" serde = { version = "1", features = ["derive"] }
use serde::Deserialize;
const API: &str = "https://parser.rethl.net/api/v1";
// Every endpoint answers with this shape.
#[derive(Deserialize)]
struct Envelope<T> { ok: bool, data: Option<T>, error: Option<ApiError> }
#[derive(Deserialize)]
struct ApiError { message: String }
fn unwrap<T: serde::de::DeserializeOwned>(
res: Result<ureq::Response, ureq::Error>,
) -> Result<T, String> {
// A non-2xx still carries an error envelope — parse it rather than
// discarding it, so users see "Invalid token" not "status 401".
let response = match res {
Ok(r) => r,
Err(ureq::Error::Status(_, r)) => r,
Err(e) => return Err(format!("network error: {e}")),
};
let env: Envelope<T> = response.into_json().map_err(|e| e.to_string())?;
if env.ok { env.data.ok_or_else(|| "empty response".into()) }
else { Err(env.error.map(|e| e.message).unwrap_or_default()) }
}
#[derive(Deserialize)]
struct Batch { batch_id: String, edit_token: String }
fn create_batch() -> Result<Batch, String> {
unwrap(ureq::post(&format!("{API}/batches")).call())
}
fn add_file(batch: &Batch, path: &std::path::Path) -> Result<(), String> {
let (content_type, body) = ureq_multipart::MultipartBuilder::new()
.add_file("file", path).map_err(|e| e.to_string())?
.finish().map_err(|e| e.to_string())?;
let _: serde_json::Value = unwrap(
ureq::post(&format!("{API}/batches/{}/files", batch.batch_id))
.set("Authorization", &format!("Bearer {}", batch.edit_token))
.set("Content-Type", &content_type)
.send_bytes(&body),
)?;
Ok(())
}
#!/usr/bin/env bash
# Upload every .zevtc in a folder and print the report URL.
set -euo pipefail
API="https://parser.rethl.net/api/v1"
TOKEN="$1" # your saved user token
DIR="$2" # folder of .zevtc files
# 1. create the batch
BATCH=$(curl -sX POST "$API/batches")
ID=$(jq -r '.data.batch_id' <<<"$BATCH")
EDIT=$(jq -r '.data.edit_token' <<<"$BATCH")
# 2. stage every log
for f in "$DIR"/*.zevtc; do
curl -sX POST "$API/batches/$ID/files" \
-H "Authorization: Bearer $EDIT" \
-F "file=@$f" | jq -r '"staged \(.data.name) [\(.data.map)]"'
done
# 3. commit
curl -sX POST "$API/batches/$ID/process" \
-H "Authorization: Bearer $EDIT" \
-d "token=$TOKEN" -d "client=bash-uploader" > /dev/null
# 4. poll
while :; do
JOB=$(curl -s "$API/jobs/$ID")
STATE=$(jq -r '.data.state' <<<"$JOB")
case "$STATE" in
complete) jq -r '.data.reports[] | select(.role=="main") | .url' <<<"$JOB"; break ;;
failed) jq -r '.data.error' <<<"$JOB" >&2; exit 1 ;;
*) jq -r '"\(.data.state) \(.data.progress)%"' <<<"$JOB"; sleep 3 ;;
esac
done
Limits & rules
| Rule | Value |
|---|---|
| Accepted file type | .zevtc only |
| File size | 1 KB – 1 GB per file |
| Total batch size | 1 GB maximum across all staged files |
| Files per batch | At least 2; count is also limited by the 1 GB total |
| Batch lifetime | 3 hours if never processed |
| Poll interval | Every 2–3 seconds |
| Non-WvW logs | Rejected with HTTP 400 and not_wvw_log; the file is not retained |
Being a good citizen
- Send a
clientname when processing. It costs nothing and tells us which tools are being used — that's how integrations get supported rather than broken. - Reuse the user token. Minting one per run orphans your users' history.
- Don't poll faster than every 2 seconds. Parsing takes as long as it takes.
- Prefer
webhook_urlover polling for unattended tools. - Check
/metaon startup and back off ifstatusismaintenance.
Error codes
Branch on error.code, never on the message text.
| Code | HTTP | Meaning & what to do |
|---|---|---|
missing_parameter | 400 | A required field wasn't sent. |
invalid_id | 400 | Malformed batch or job id. |
invalid_file | 400/413 | Wrong type or outside the size limits. |
no_file | 400 | No file field in the multipart body. |
empty_batch | 400 | Tried to process a batch with nothing staged. |
invalid_webhook | 400 | webhook_url isn't a Discord webhook. |
invalid_token | 400 | Token isn't 26 alphanumeric characters. |
unauthorized | 401 | Missing or wrong edit_token. Re-check the header, or the batch may have expired. |
not_found | 404 | No such batch, job, file or report. |
unknown_token | 404 | That user token doesn't exist. |
method_not_allowed | 405 | Right path, wrong verb. |
process_error | 400 | The parse couldn't be started — message explains why. |
server_error | 500 | Something broke on our side. Retry with backoff; if it persists, get in touch. |
Getting help
Building something with this? I'd genuinely like to know — it helps me avoid breaking your tool.
- Discord:
retherichus - In-game:
BigFatCharrKnot.2617 - Contact form: on the main site
Reference implementation
The official Nexus addon uses exactly this API — nothing private, no special access. If you're unsure how something is meant to work, that's the worked example.
This is v1. Breaking changes go to v2 at a new path; v1
keeps working. New optional fields may be added to responses, so parse leniently and ignore what
you don't recognise.