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.
| Route | Returns |
|---|---|
GET /v1/status | The 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/affinity | Ciphers, artifacts, Regalia and the other things a rolled item can carry. |
GET /v1/modifier-pools, /v1/qualities, /v1/placement-rules | How items roll, what each quality means, and where items may be placed. |
GET /v1/npcs | NPC ships, bosses and their weapons. |
GET /v1/stats | Every stat, with the label and sign the game shows in its tooltips. |
GET /v1/grid-consumables, /v1/breach-tiers | The 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-causes | The 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/zones | Every 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-grid | How 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/facts | Check 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/roll | See 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/stream | New 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/leaderboards | Totals 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.
/v1/kills/feed?after=returns every kill listed after a cursor. Poll it with thecursorit hands back and you see each kill once. Responses are cached for 10 seconds, so polling faster than that gains nothing./v1/kills/streamis a server-sent events stream. Each kill arrives as akillevent a few seconds after it becomes public, and the event's id is the kill's cursor. The server ends a stream after 10 minutes. Reconnect with the last id inLast-Event-IDand you get what you missed first, for up to an hour; a browser'sEventSourcedoes all of this by itself. Each client can hold 3 streams open.
Both take the same filters as /v1/kills, so ?kind=pvp follows only PvP kills and ?pilot=<id> one pilot's.
What to expect
- Kills from older recordings are sometimes added later. They reach the feed and the stream when they become public, with their real
occurredAt, so check it if you only want fresh fights. - A kill can be withdrawn: when it repeats another loss of the same ship, or when a pilot asks to be anonymised. It then answers 404. Neither the feed nor the stream announces it.
- Hull, item, cipher and artifact ids in a kill are the ids the rest of this API uses, so
/v1/items/{id}tells you what a fitted module does. Kills recorded before the killboard kept ids carry null there; filter those byhullName. - Each kill names the
buildthat recorded it.X-Citadel-Buildis still the live build.
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.
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
- New routes and new fields can appear at any time, without notice. Ignore fields you don't recognise.
- Removing or renaming a field, or changing what it means or its type, only happens in a new version. When
/v2ships,/v1keeps running for at least 90 days and its responses carry aDeprecationheader. - Game data isn't part of that promise. Items, hulls and numbers change whenever the game does.
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.