Citadel game data API

The data behind Citadel's ships, modules and NPCs, read straight from the build players are on, the fit evaluator the game itself uses, and every ship players lose. It's free for fit tools, wikis, killboards and bots, and you don't need a key.

Start

Everything here comes from the live build. When a new build ships, the API moves to it as part of the same release, and every response names the build in X-Citadel-Build. Poll /v1/status if you want to know when that happens.

RouteReturns
GET /v1/statusThe live build, when it went live, and how many of each thing it has.
GET /v1/items, /v1/items/{id}Every module and conduit a player can get in the live game. Unreleased and NPC-only items are left out.
GET /v1/hulls, /v1/hulls/{id}Hulls with their fitting grids.
GET /v1/ciphers, /v1/artifacts, /v1/regalia, /v1/regalia-sets, /v1/heretical-specials, /v1/harmonic-sets, /v1/affinityCiphers, artifacts, Regalia and the other things a rolled item can carry.
GET /v1/modifier-pools, /v1/qualities, /v1/placement-rulesHow items roll, what each quality means, and where items may be placed.
GET /v1/npcsNPC ships, bosses and their weapons.
GET /v1/statsEvery stat, with the label and sign the game shows in its tooltips.
GET /v1/grid-consumables, /v1/breach-tiersThe consumables that change a fitting grid, and the breach tiers a modifier roll can be made at.
GET /v1/ship-classes, /v1/ship-roles, /v1/death-causesThe game's ship classes with their overlay icons, the roles a hull can have, and how a ship can die, each in the game's order.
GET /v1/zonesEvery zone a kill can name, with its breach tiers and whether it is a travel lane or a private event route. The kill routes filter by its id with zoneId.
GET /v1/ui/fitting-gridHow the game draws a fitting grid: cell size, port notches, hardpoint icons and state colours. This is UI style, not game data, so sde.zip leaves it out.
GET /v1/art/{file}Icons as WebP. Take the file names from each record's art object.
POST /v1/fits/evaluate, /v1/fits/import, /v1/fits/export, /v1/items/factsCheck a fit, convert it to and from the in-game fit string, or read an item's stat lines. These run the game's own code.
POST /v1/grid-mods, /v1/rollSee where each grid consumable can be used on a fit and what it would do, or roll an item's modifiers the way a drop rolls them. These run the game's own code too.
GET /v1/kills, /v1/kills/{id}Ships players have lost, newest first, with filters; one kill with its fitting, cargo and everyone who damaged it. See Kills.
GET /v1/kills/feed, /v1/kills/streamNew kills in the order they become public: a feed to poll, or a server-sent events stream.
GET /v1/kills/summary, /v1/battles, /v1/pilots/{id}, /v1/leaderboardsTotals for any filter, battles, a pilot's record, and the leaderboards.

An id is the game's asset GUID written as a decimal string, and it doesn't change between builds. Fixed-point numbers come as raw decimal strings; divide by 65536 to get the value. Errors look like { "error": { "code", "message" } } and use status 400, 404, 405, 413, 415, 429, 500 or 503.

Rules

User-Agent

Put your app's name and a way to reach you in the User-Agent, for example MyFitTool/1.0 (me@example.com). If something you run starts misbehaving, that's how we get in touch before we have to block it. Requests with no User-Agent, or a bare curl/8.4.0 or python-requests/2.31, still work, but the response carries a Warning header.

Rate limits

You can make 60 requests in a burst, and the allowance refills at 10 a second. Art has its own, larger allowance (600, refilling at 100 a second) so a page full of icons doesn't eat into your data requests. sde.zip allows 5 downloads, then one every 6 seconds. The kill routes read a database rather than the build, so they share a smaller allowance: 20 in a burst, refilling at 2 a second. Rejected requests count too. When you hit the limit you get a 429 with Retry-After in seconds. Request bodies can be up to 256 KB.

Caching

Every response has an ETag and a Cache-Control. Send the tag back in If-None-Match and you'll get an empty 304 until the data changes. Art file names are a hash of the image, so a file never changes and you can cache it for a year. CORS is open to every origin, so a web page can call the API directly.

Examples

Each example makes the same three calls. It reads the live build from /v1/status, asks for /v1/hulls again with its ETag and gets a 304, then turns a fit string copied from the game into a fit and evaluates it. Put your own app and contact in the User-Agent, and paste a fit string over the placeholder.

curl

API=https://api.thecitadel.com
UA='MyFitTool/1.0 (me@example.com)'
FIT_STRING='<a fit string from the game>'

curl -A "$UA" "$API/v1/status"

# Conditional request: 304 with no body while the data is unchanged
etag=$(curl -s -D - -o /dev/null -A "$UA" "$API/v1/hulls" | tr -d '\r' | awk 'tolower($1) == "etag:" { print $2 }')
curl -i -A "$UA" -H "If-None-Match: $etag" "$API/v1/hulls"

# Turn an in-game fit string into a fit, then check it
curl -s -A "$UA" -H 'Content-Type: application/json' \
     -d "{ \"fitString\": \"$FIT_STRING\" }" "$API/v1/fits/import" |
  curl -A "$UA" -H 'Content-Type: application/json' -d @- "$API/v1/fits/evaluate"

JavaScript

Node 18 or later, as an ES module. Browsers send their own User-Agent and don't let you change it, so leave that header out in a web page; those calls get no warning.

const api = "https://api.thecitadel.com";
const headers = { "User-Agent": "MyFitTool/1.0 (me@example.com)" };
const fitString = "<a fit string from the game>";

const status = await fetch(`${api}/v1/status`, { headers }).then((r) => r.json());
console.log(status.build);

// Conditional request: 304 with no body while the data is unchanged.
const first = await fetch(`${api}/v1/hulls`, { headers });
const { hulls } = await first.json();
const again = await fetch(`${api}/v1/hulls`, {
  headers: { ...headers, "If-None-Match": first.headers.get("ETag") },
});
console.log(again.status); // 304

// Art: every item and hull has an `art` object of API-relative URLs.
console.log(`${api}${hulls[0].art.base}`);

// Turn an in-game fit string into a fit, then check it.
const post = (path, body) =>
  fetch(`${api}${path}`, {
    method: "POST",
    headers: { ...headers, "Content-Type": "application/json" },
    body: JSON.stringify(body),
  }).then((r) => r.json());
const { fit } = await post("/v1/fits/import", { fitString });
const result = await post("/v1/fits/evaluate", { fit });
console.log(result.valid, result.rating);

Python

Python 3, standard library only.

import json
import urllib.error
import urllib.request

API = "https://api.thecitadel.com"
USER_AGENT = "MyFitTool/1.0 (me@example.com)"
FIT_STRING = "<a fit string from the game>"


def call(path, body=None, headers=None):
    request = urllib.request.Request(API + path, headers={"User-Agent": USER_AGENT, **(headers or {})})
    if body is not None:
        request.data = json.dumps(body).encode()
        request.add_header("Content-Type", "application/json")
    try:
        with urllib.request.urlopen(request) as response:
            return response.status, response.headers, response.read()
    except urllib.error.HTTPError as error:
        if error.code != 304:
            raise
        return error.code, error.headers, b""


_, _, status = call("/v1/status")
print(json.loads(status)["build"])

# Conditional request: 304 with no body while the data is unchanged.
_, headers, _ = call("/v1/hulls")
code, _, _ = call("/v1/hulls", headers={"If-None-Match": headers["ETag"]})
print(code)  # 304

# Turn an in-game fit string into a fit, then check it.
_, _, imported = call("/v1/fits/import", {"fitString": FIT_STRING})
_, _, evaluated = call("/v1/fits/evaluate", {"fit": json.loads(imported)["fit"]})
result = json.loads(evaluated)
print(result["valid"], result["rating"])

C#

.NET 8, as a top-level program.

using System.Net.Http.Json;
using System.Text.Json;

using var http = new HttpClient { BaseAddress = new Uri("https://api.thecitadel.com") };
http.DefaultRequestHeaders.UserAgent.ParseAdd("MyFitTool/1.0 (me@example.com)");
var fitString = "<a fit string from the game>";

var status = await http.GetFromJsonAsync<JsonElement>("/v1/status");
Console.WriteLine(status.GetProperty("build"));

// Conditional request: 304 with no body while the data is unchanged.
using var first = await http.GetAsync("/v1/hulls");
var conditional = new HttpRequestMessage(HttpMethod.Get, "/v1/hulls");
conditional.Headers.IfNoneMatch.Add(first.Headers.ETag!);
using var again = await http.SendAsync(conditional);
Console.WriteLine((int)again.StatusCode); // 304

// Turn an in-game fit string into a fit, then check it.
var imported = await (await http.PostAsJsonAsync("/v1/fits/import", new { fitString }))
    .Content.ReadFromJsonAsync<JsonElement>();
var result = await (await http.PostAsJsonAsync("/v1/fits/evaluate", new { fit = imported.GetProperty("fit") }))
    .Content.ReadFromJsonAsync<JsonElement>();
Console.WriteLine($"{result.GetProperty("valid")} {result.GetProperty("rating")}");

Go

Go 1.18 or later, standard library only.

package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"log"
	"net/http"
)

const api = "https://api.thecitadel.com"
const userAgent = "MyFitTool/1.0 (me@example.com)"
const fitString = "<a fit string from the game>"

func call(path string, body any, header http.Header) (*http.Response, []byte) {
	method, reader := http.MethodGet, io.Reader(nil)
	if body != nil {
		encoded, err := json.Marshal(body)
		if err != nil {
			log.Fatal(err)
		}
		method, reader = http.MethodPost, bytes.NewReader(encoded)
	}
	req, err := http.NewRequest(method, api+path, reader)
	if err != nil {
		log.Fatal(err)
	}
	for name, values := range header {
		req.Header[name] = values
	}
	req.Header.Set("User-Agent", userAgent)
	if body != nil {
		req.Header.Set("Content-Type", "application/json")
	}
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		log.Fatal(err)
	}
	defer resp.Body.Close()
	data, err := io.ReadAll(resp.Body)
	if err != nil {
		log.Fatal(err)
	}
	return resp, data
}

func main() {
	_, status := call("/v1/status", nil, nil)
	fmt.Println(string(status))

	// Conditional request: 304 with no body while the data is unchanged.
	first, _ := call("/v1/hulls", nil, nil)
	again, _ := call("/v1/hulls", nil, http.Header{"If-None-Match": {first.Header.Get("ETag")}})
	fmt.Println(again.StatusCode) // 304

	// Turn an in-game fit string into a fit, then check it.
	_, imported := call("/v1/fits/import", map[string]string{"fitString": fitString}, nil)
	var fit struct {
		Fit json.RawMessage `json:"fit"`
	}
	if err := json.Unmarshal(imported, &fit); err != nil {
		log.Fatal(err)
	}
	_, evaluated := call("/v1/fits/evaluate", fit, nil)
	var result struct {
		Valid  bool `json:"valid"`
		Rating int  `json:"rating"`
	}
	if err := json.Unmarshal(evaluated, &result); err != nil {
		log.Fatal(err)
	}
	fmt.Println(result.Valid, result.Rating)
}

Kills

The kill routes serve the killboard: every ship a player loses, in a breach or in the hub, to other pilots or to NPCs. A kill is recorded within seconds of the ship dying and becomes public 5 minutes later, so nothing here shows a fight that is still going on.

Following new kills

There are two ways, and both list kills in the order they become public.

Both take the same filters as /v1/kills, so ?kind=pvp follows only PvP kills and ?pilot=<id> one pilot's.

What to expect

Each example below follows new PvP kills. The JavaScript one runs in a web page and uses the stream; the others start from the newest kill and poll the feed every 15 seconds.

curl

The polling half uses jq.

API=https://api.thecitadel.com
UA='MyKillBot/1.0 (me@example.com)'

# Follow the stream; -N prints each event as it arrives. Ctrl-C to stop.
curl -N -A "$UA" "$API/v1/kills/stream?kind=pvp"

# Or poll: start from the newest kill, then ask for whatever was listed after it.
cursor=$(curl -s -A "$UA" "$API/v1/kills?limit=1" | jq -r '.kills[0].cursor')
while sleep 15; do
  page=$(curl -s -A "$UA" "$API/v1/kills/feed?kind=pvp&after=$cursor")
  echo "$page" | jq -r '.kills[] | "\(.victim.name) lost a \(.victim.hull.name)"'
  cursor=$(echo "$page" | jq -r '.cursor')
done

JavaScript

In a web page. EventSource reconnects by itself and resumes after the last kill it saw.

const stream = new EventSource("https://api.thecitadel.com/v1/kills/stream?kind=pvp");

stream.addEventListener("kill", (event) => {
  const kill = JSON.parse(event.data);
  console.log(`${kill.victim.name} lost a ${kill.victim.hull.name}`, kill.value?.total);
});

// Sent instead of a replay when the page was away for over an hour: fetch the
// kills after `after`, up to and including `until`, from /v1/kills/feed.
stream.addEventListener("gap", (event) => console.log(JSON.parse(event.data)));

Python

Python 3, standard library only.

import json
import time
import urllib.request

API = "https://api.thecitadel.com"
USER_AGENT = "MyKillBot/1.0 (me@example.com)"


def get(path):
    request = urllib.request.Request(API + path, headers={"User-Agent": USER_AGENT})
    with urllib.request.urlopen(request) as response:
        return json.load(response)


# Start from the newest kill, then ask for whatever was listed after it.
cursor = get("/v1/kills?limit=1")["kills"][0]["cursor"]
while True:
    time.sleep(15)
    page = get(f"/v1/kills/feed?kind=pvp&after={cursor}")
    for kill in page["kills"]:
        print(f'{kill["victim"]["name"]} lost a {kill["victim"]["hull"]["name"]}')
    cursor = page["cursor"]

C#

.NET 8, as a top-level program.

using System.Net.Http.Json;
using System.Text.Json;

using var http = new HttpClient { BaseAddress = new Uri("https://api.thecitadel.com") };
http.DefaultRequestHeaders.UserAgent.ParseAdd("MyKillBot/1.0 (me@example.com)");

// Start from the newest kill, then ask for whatever was listed after it.
var newest = await http.GetFromJsonAsync<JsonElement>("/v1/kills?limit=1");
var cursor = newest.GetProperty("kills")[0].GetProperty("cursor").GetString();
while (true)
{
    await Task.Delay(TimeSpan.FromSeconds(15));
    var page = await http.GetFromJsonAsync<JsonElement>($"/v1/kills/feed?kind=pvp&after={cursor}");
    foreach (var kill in page.GetProperty("kills").EnumerateArray())
    {
        var victim = kill.GetProperty("victim");
        Console.WriteLine($"{victim.GetProperty("name")} lost a {victim.GetProperty("hull").GetProperty("name")}");
    }
    cursor = page.GetProperty("cursor").GetString();
}

Go

Go 1.18 or later, standard library only.

package main

import (
	"encoding/json"
	"fmt"
	"log"
	"net/http"
	"time"
)

const api = "https://api.thecitadel.com"
const userAgent = "MyKillBot/1.0 (me@example.com)"

type kill struct {
	Cursor string `json:"cursor"`
	Victim struct {
		Name string `json:"name"`
		Hull struct {
			Name string `json:"name"`
		} `json:"hull"`
	} `json:"victim"`
}

type page struct {
	Kills  []kill `json:"kills"`
	Cursor string `json:"cursor"`
}

func get(path string) page {
	req, err := http.NewRequest(http.MethodGet, api+path, nil)
	if err != nil {
		log.Fatal(err)
	}
	req.Header.Set("User-Agent", userAgent)
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		log.Fatal(err)
	}
	defer resp.Body.Close()
	var p page
	if err := json.NewDecoder(resp.Body).Decode(&p); err != nil {
		log.Fatal(err)
	}
	return p
}

func main() {
	// Start from the newest kill, then ask for whatever was listed after it.
	cursor := get("/v1/kills?limit=1").Kills[0].Cursor
	for {
		time.Sleep(15 * time.Second)
		p := get("/v1/kills/feed?kind=pvp&after=" + cursor)
		for _, k := range p.Kills {
			fmt.Printf("%s lost a %s\n", k.Victim.Name, k.Victim.Hull.Name)
		}
		cursor = p.Cursor
	}
}

Bulk download

If you want all of it, take one file instead of walking every route.

Download sde.zip

Inside: one JSON file per route, byte for byte what that route serves, the icons under art/, and a manifest.json with the build and a SHA-256 for each file. The zip only changes when the game data does, so If-None-Match works here too.

Stability policy

Terms Draft

Use the data and art in community projects that don't make money. Credit it as "Data from the Citadel game data API", don't suggest we made or back your tool, and expect the service to change. These terms are still a draft: read the draft terms.

Reference

Every route and field, built from /v1/openapi.json (OpenAPI 3.1). Every operation has request samples in the same five languages.