Docs / Community API
Community API
HTTP API for integrating your own scripts and tools with your LUX CAD community. Reads across the board, plus raising incidents in the dispatch queue and publishing to your community's newspaper.
Every request is scoped to a single community by your API key. You can only ever read your own community's data — there is no way to address another community, with any key.
- Base URL:
https://api.lux-cad.com/api/v1 - Format: JSON
- Methods:
GETalmost everywhere. Six endpoints also accept writes (POST, and on somePATCHorDELETE) — see Writing. Every other endpoint is read-only
Authentication
Send your API key in the x-lux-api-key header on every request.
curl https://api.lux-cad.com/api/v1/active-units \ -H "x-lux-api-key: 3f9a1c02-8d4b-4e17-9c33-5b7e2a610f88"
Keys are created and managed by a community admin under Admin → Integration → API Keys. Your community has one API key — share it between your scripts. Revoking or rotating it takes effect within a minute and does not affect your FiveM server.
Treat a key like a password. It grants read access to your whole community's operational data. Keep it server-side; never ship it in a client, a browser page, or a public repository. If a key leaks, rotate it in the admin panel — your scripts then need the new value.
The x-lux-api-key header is not the same thing as your FiveM Server Key. The FiveM Server Key
authenticates your game server against the CAD and does not work here.
Rate limiting
200 requests per minute per key, counted across all endpoints combined, on a sliding window. Since your community has a single key, that is your community's whole budget — you cannot widen it by issuing more keys.
Every response carries the current budget:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | Your ceiling (200) |
X-RateLimit-Remaining | Requests left in the current window |
Over the limit you get 429 and no data:
{ "error": "Rate limit exceeded.", "limit": 200, "window": "1 minute" }Failed requests count too — an invalid key does not get a free pass, so it's worth backing off rather than retrying in a tight loop.
How often should you poll?
For most background integrations — a Discord bot, a status page, a dashboard nobody is staring at — every 30 seconds is plenty, and it leaves the 200/min ceiling free as headroom for bursts and for a human hitting refresh. Poll faster if your use case genuinely needs it; that's what the headroom is for.
If you find yourself wanting near-instant updates, polling probably isn't the right tool — come talk to us about the realtime layer and we'll help you find a better fit.
Writes have a second, tighter budget: 60 per minute per key, on top of the 200/min above. Publishing a newspaper is not a high-frequency activity; if you are near that ceiling something is looping.
Writing
Six endpoints accept writes today: /incidents, /reports,
/vehicles, /characters,
/incident-logs and /news-articles. Three things work
differently from the read endpoints — read these before your first POST.
A write returns a single object, not an array. The array convention above is a read convention. A
POST returns the record it created, a PATCH the record as it now stands, a DELETE the article it
retracted — always the full object, in exactly the same field shape as the matching GET row. You never
need a follow-up call to see what you actually stored.
A 502 does not mean "not written". We are a gateway in front of the database. If the write commits
and the reply is lost on the way back to you — a timeout, a dropped connection — you will see a 502
while the record exists. Never blind-retry a failed POST. Look first:
GET /news-articles?since=<a moment just before your POST> GET /incidents-open GET /reports?character_id=<the subject you filed on> GET /vehicles?plate=<the plate you registered>
Your article, incident or report will be in there. Retry only if it isn't. The same applies to a client side timeout where you get no response at all.
Keep your own record of the id you get back. article_id is the only handle for PATCH and
DELETE; report_id is the only handle for PATCH /reports; vehicle_id is the only handle for
PATCH and DELETE on /vehicles; incident_id is the only handle for reading an incident back
later. We do not offer an
idempotency key, so two identical POSTs create two records — storing the id you got back is what keeps
you out of that situation. For incidents in particular, do not use call_number as that handle: it
is a display label and is not unique.
Writes have their own rate limits, stricter than the general one and counted separately per
resource. POST /incidents is 10 per minute, and POST and PATCH on /reports share one budget of
10 per minute, because each of those notifies every connected client in your community. POST, PATCH
and DELETE on /vehicles share one budget of 30 per minute. POST /incident-logs is 30 per minute,
and POST and PATCH on /characters share one budget of 10 per minute.
Conventions
Responses are always a JSON array, even when there is exactly one result or none at all. An empty
community returns [], not an error.
Ordering is fixed per endpoint (documented below). Don't depend on any ordering that isn't stated.
Nullable fields are documented as such and are returned as null, never omitted. Optional CAD
configuration (an agency name, a special-unit code) is simply not filled in by every community.
Timestamps are ISO 8601 with a UTC offset: 2026-07-20T09:14:02.317849+00:00.
Free filtering and field selection are not supported. Most endpoints take no parameters and return
the full, current set for your community. Some take a specific lookup parameter, documented on the
endpoint (for example discord_id on /characters); any other query-string
parameter is ignored. Filter the rest on your side. Payloads are deliberately small and denormalised so
a single call is usually enough.
Where an endpoint offers more than one lookup parameter — /characters, /vehicles,
/reports and /warrants — you must send exactly one of them. Sending none, or two at once, is a 400. Each one
is a different way into the same resource, not a filter you combine. Parameters that are not lookup
axes, such as limit and since_number, sit outside that rule and combine with any of them.
Responses are not cached (Cache-Control: no-store); every call reflects the live state of the CAD.
New fields may be added to a response object at any time without notice. Existing field names and their meaning will not change, and fields will not be removed, without us telling you first. So parse defensively: read the fields you need and ignore the rest.
Errors
Errors use standard HTTP status codes with a JSON body of the shape { "error": "..." }.
| Status | Body | What it means |
|---|---|---|
400 | Missing required query parameter: … / Invalid … | An endpoint that needs a lookup parameter didn't get a valid one |
400 | Provide exactly one of: … | An endpoint with several lookup parameters got none, or more than one |
400 | Unknown field: … / Field … must be a string. / Field … must be a finite number. / Field … is too long … | Something in a write body isn't accepted. Unknown fields are rejected rather than ignored, so a typo shows up immediately |
400 | Field … must be an array. / Field … must contain only UUIDs. / Field … must contain only objects. / Field … may hold at most N entries. | A list field (suspect_ids, charges) has the wrong shape |
400 | Missing required field: … / Field … cannot be blank. | A required write field was absent or empty |
400 | Provide both x and y, or neither. | Half a coordinate pair on POST /incidents. We refuse rather than silently create a call with no location |
400 | title, body and author_name are required … / image_aspect must be one of … / Unknown service_code … / Unknown character_id … | The database rejected the write. The message says exactly what to fix |
400 | Unknown incident_type for this community: … | That short code isn't configured here. Read /incident-types |
400 | incident_type … is reserved for the CAD … | PANIC and TRAFFIC_STOP are created by the CAD itself |
400 | call_prefix … is reserved by the CAD. / call_prefix must be 2-6 letters or digits. | Pick a different prefix for your integration |
400 | Unknown type for this community: … | That report type isn't configured here. Read /report-types |
400 | Report type … is turned off for this community. | The type exists but your server owner closed it for new reports. Existing ones still return from GET /reports |
400 | character_id is required for report type … | The type has requires_subject: true and you sent no subject |
400 | Report type … does not support charges. | The type has supports_charges: false |
400 | Unknown penal_code for this community: … | That code isn't in your penal code. Codes are case-sensitive |
400 | charge_count must be between 1 and 99. / charge_count must be a number. / Each entry in charges needs a penal_code. / Unknown field in charges entry … | Something in a charges entry isn't accepted |
400 | created_at cannot be in the future. | Reports can be back-dated, not post-dated |
401 | Missing x-lux-api-key header. | You didn't send the header |
401 | Invalid or revoked API key. | Unknown, malformed, or revoked key — or the community is not active |
404 | Unknown endpoint. | No such resource (check spelling; only the endpoints below exist) |
404 | Article not found. | No article with that id in your community. An article that belongs to another community is indistinguishable from one that doesn't exist |
404 | Report not found. | Same rule as above, for PATCH /reports |
409 | Only PUBLISHED articles can be edited; this one is ARCHIVED. | The article is out of the paper. You can still DELETE it |
413 | Request body too large. | A write body over 128 KB |
429 | Rate limit exceeded. | See Rate limiting |
502 | Upstream error. / Upstream unavailable. | Something failed on our side. Safe to retry a read; for a write, see the warning under Writing first |
503 | API temporarily unavailable. | Temporary service problem; retry with backoff |
Note that a valid key for a community with no data returns 200 with [] — that is not an error.
Endpoints
GET/unit-statusesThe status codes configured for your community — the lookup table behind statuscode on /active-units. This is configuration, not…expand_more
The status codes configured for your community — the lookup table behind status_code on
/active-units. This is configuration, not live data: it changes only when an admin edits it, so
fetch it once at startup and cache it.
Order: by sort_order, then code. This is the order the CAD itself displays them in, so you can
render a status picker straight from this response.
| Field | Type | Notes |
|---|---|---|
code | string | The 10-code, e.g. 10-8. Unique within your community |
label | string | Human-readable name, e.g. Available |
color | string | Hex colour, e.g. #22c55e |
sort_order | integer | Display position |
roles | string[] | Departments this status applies to. An empty array means "all departments", not "none" |
is_base | boolean | true for the built-in statuses every community has; false for ones your admins added |
Example
curl https://api.lux-cad.com/api/v1/unit-statuses \ -H "x-lux-api-key: $LUX_API_KEY"
[
{ "code": "10-8", "label": "Available", "color": "#22c55e", "sort_order": 0, "roles": [], "is_base": true },
{ "code": "10-7", "label": "Unavailable", "color": "#ef4444", "sort_order": 1, "roles": [], "is_base": true },
{ "code": "10-6", "label": "Busy", "color": "#f97316", "sort_order": 2, "roles": [], "is_base": true },
{ "code": "10-18", "label": "En Route", "color": "#1e40af", "sort_order": 3, "roles": [], "is_base": true },
{ "code": "10-97", "label": "On Scene", "color": "#581eae", "sort_order": 4, "roles": [], "is_base": true },
{ "code": "10-23", "label": "Air Support", "color": "#0ea5e9", "sort_order": 5, "roles": ["LEO"], "is_base": false }
]GET/active-unitsEvery unit currently on duty in your community, with its live status already resolved to a label and colour. This is the endpoint…expand_more
Every unit currently on duty in your community, with its live status already resolved to a label and colour. This is the endpoint most integrations start with.
Order: unspecified — sort on your side if it matters.
| Field | Type | Notes |
|---|---|---|
callsign | string | The unit's callsign, e.g. 1-ADAM-12 |
department | string | Broad bucket: LEO, SAFR (fire/EMS), or DISPATCH |
agency | string | null | The specific agency within the department, e.g. BCSO, LSPD. Not every community configures agencies |
special_unit_code | string | null | Special-unit designation (K9, air, SWAT, …) if the unit has one |
status_code | string | The raw 10-code the unit is on, e.g. 10-8 |
status_label | string | null | Human-readable label for that code, e.g. Available. null if the code isn't in the community's status list |
status_color | string | null | Hex colour configured for that status, e.g. #22c55e. null under the same condition as status_label |
current_incident_id | uuid | null | The incident the unit is assigned to, or null if unassigned |
since | timestamp | When the unit went on duty |
status_label and status_color come from your community's own status configuration — the same
list you get from /unit-statuses. If you're rendering many units, fetch that
list once and map locally rather than relying on these two fields.
Live GPS positions are deliberately not exposed. Unit coordinates are not part of this API and won't be added to it.
Example
curl https://api.lux-cad.com/api/v1/active-units \ -H "x-lux-api-key: $LUX_API_KEY"
[
{
"callsign": "1-ADAM-12",
"department": "LEO",
"agency": "LSPD",
"special_unit_code": null,
"status_code": "10-97",
"status_label": "On Scene",
"status_color": "#581eae",
"current_incident_id": "6b1f0e2a-9c74-4a51-b0d8-2f3c8e5a1d90",
"since": "2026-07-20T09:14:02.317849+00:00"
},
{
"callsign": "DISPATCH 110",
"department": "DISPATCH",
"agency": null,
"special_unit_code": null,
"status_code": "10-8",
"status_label": "Available",
"status_color": "#22c55e",
"current_incident_id": null,
"since": "2026-07-20T08:52:41.008204+00:00"
}
]GET/active-unit-membersEvery individual person currently on duty, with their Discord ID and the unit they are staffing. Where /active-units lists the…expand_more
Every individual person currently on duty, with their Discord ID and the unit they are staffing.
Where /active-units lists the units, this lists the people in them — one row per
on-duty member. A unit crewed by more than one person appears as multiple rows sharing the same
unit_callsign.
Order: by department, then unit_callsign, then member_name.
| Field | Type | Notes |
|---|---|---|
discord_id | string | Discord user ID of the on-duty member. This is the stable key to tie a unit member back to a Discord account — the reason this endpoint exists |
member_name | string | null | The member's display name in the CAD |
unit_callsign | string | Callsign of the unit this member is staffing, e.g. 1-ADAM-12 |
department | string | LEO, SAFR (fire/EMS), or DISPATCH |
agency | string | null | Specific agency within the department, e.g. BCSO. Not every community configures agencies |
special_unit_code | string | null | Special-unit designation (K9, air, SWAT, …) if the unit has one |
status_code | string | The unit's current 10-code, e.g. 10-8 |
status_label | string | null | Human-readable label for that code. null if the code isn't in the community's status list |
status_color | string | null | Hex colour for that status. null under the same condition as status_label |
current_incident_id | uuid | null | The incident the unit is assigned to, or null if unassigned |
joined_at | timestamp | When this member went on duty in this unit |
The unit-level fields (unit_callsign, department, status_*, …) are identical for every member of
the same unit. If you only need units, use /active-units instead.
Live GPS positions are deliberately not exposed here either — unit coordinates are not part of this API.
Example
curl https://api.lux-cad.com/api/v1/active-unit-members \ -H "x-lux-api-key: $LUX_API_KEY"
[
{
"discord_id": "285181941777694720",
"member_name": "J. Rivera",
"unit_callsign": "1-ADAM-12",
"department": "LEO",
"agency": "LSPD",
"special_unit_code": null,
"status_code": "10-97",
"status_label": "On Scene",
"status_color": "#581eae",
"current_incident_id": "6b1f0e2a-9c74-4a51-b0d8-2f3c8e5a1d90",
"joined_at": "2026-07-20T09:14:02.317849+00:00"
},
{
"discord_id": "399949667997319168",
"member_name": "K. Osei",
"unit_callsign": "1-ADAM-12",
"department": "LEO",
"agency": "LSPD",
"special_unit_code": null,
"status_code": "10-97",
"status_label": "On Scene",
"status_color": "#581eae",
"current_incident_id": "6b1f0e2a-9c74-4a51-b0d8-2f3c8e5a1d90",
"joined_at": "2026-07-20T09:15:40.113220+00:00"
}
]GET/pursuitsPursuits that are currently running. Finished pursuits are not returned — this is a live snapshot, not a history endpoint.expand_more
Pursuits that are currently running. Finished pursuits are not returned — this is a live snapshot, not a history endpoint.
Order: oldest first, by started_at.
| Field | Type | Notes |
|---|---|---|
pursuit_id | uuid | Stable identifier for this pursuit |
incident_id | uuid | null | The incident it belongs to |
status | string | Always ACTIVE for now — a paused pursuit is still active (see paused) |
primary_callsign | string | null | The primary unit. null if that unit has since gone off duty |
backup_callsigns | string[] | Callsigns of the units currently backing up, excluding the primary. Empty array if none |
paused | boolean | true if a dispatcher has paused the pursuit. Paused pursuits stay in this list |
started_at | timestamp | When the pursuit began |
active_seconds | integer | Running time in seconds, excluding paused time. Stops climbing while paused is true |
active_seconds is what you want for a duration display — it already subtracts every pause. Use
started_at only if you specifically need wall-clock time since the pursuit began.
Example
curl https://api.lux-cad.com/api/v1/pursuits \ -H "x-lux-api-key: $LUX_API_KEY"
[
{
"pursuit_id": "c4e8a1d7-3b52-49f6-8e10-7a9d2c4b6f31",
"incident_id": "6b1f0e2a-9c74-4a51-b0d8-2f3c8e5a1d90",
"status": "ACTIVE",
"primary_callsign": "1-ADAM-12",
"backup_callsigns": ["1-ADAM-14", "AIR-1"],
"paused": false,
"started_at": "2026-07-20T10:31:18.442071+00:00",
"active_seconds": 437
}
]When nothing is running:
[]
GET/incident-typesEvery incident type configured for your community. This is the list your POST /incidents calls pick a short code from — read it…expand_more
Every incident type configured for your community. This is the list your POST /incidents calls pick a
short code from — read it once at startup rather than hard-coding codes, because your admins can add,
rename and remove types at any time.
Order: by sort_order, then code.
| Field | Type | Notes |
|---|---|---|
code | string | The short code. This is what you send as incident_type |
label | string | Human-readable name, as dispatchers see it |
is_system | boolean | true for types the CAD manages itself. These cannot be used with POST /incidents — see below |
default_departments | string[] | Which services this type dispatches to (LEO, SAFR, DISPATCH). Empty if the community never configured any |
default_sub_departments | string[] | Community-defined subdivisions (e.g. FIRE, EMS). Often empty |
default_priority | integer | null | 1 (highest) to 3. null means the community left it unset, and a new incident of this type gets priority 3 |
sort_order | integer | Display order in the CAD |
PANIC and TRAFFIC_STOP appear in this list but are rejected by POST /incidents with a 400.
They are created by the CAD itself through flows that do more than write a row — a panic alarm also
flips the unit's status and raises the alert, a traffic stop attaches the unit and puts it on scene.
An incident that merely looks like one of those, without any of that having happened, is worse than
a refused request.
STANDARD is the special case in the other direction: it means "not typed yet" and is always accepted,
even if it isn't in this list. It is what every in-game call arrives as, and a dispatcher types it
properly afterwards.
Example
curl https://api.lux-cad.com/api/v1/incident-types \ -H "x-lux-api-key: $LUX_API_KEY"
[
{
"code": "STANDARD",
"label": "Standard",
"is_system": false,
"default_departments": [],
"default_sub_departments": [],
"default_priority": null,
"sort_order": 10
},
{
"code": "TRAFFIC_STOP",
"label": "Traffic Stop",
"is_system": true,
"default_departments": [],
"default_sub_departments": [],
"default_priority": null,
"sort_order": 30
},
{
"code": "VEHICLE_COLLISION",
"label": "Vehicle Collision - No Injury",
"is_system": false,
"default_departments": ["LEO"],
"default_sub_departments": [],
"default_priority": 3,
"sort_order": 840
}
]GET/incidents-openEvery incident in your community that is not closed — both PENDING (waiting for a unit) and ACTIVE (being worked). This is the…expand_more
Every incident in your community that is not closed — both PENDING (waiting for a unit) and ACTIVE
(being worked). This is the live call queue, the same set a dispatcher sees.
Closed incidents are never in here. To read one, fetch it by id with /incidents.
Order: newest first, by created_at.
Takes no parameters. The field shape is identical to /incidents — see the table
there.
This endpoint is the obvious one to poll for "has my call been picked up yet". Please don't poll it faster than every 30 seconds — see How often should you poll?. In practice a community has a handful of open calls at a time, so one call gives you the whole picture.
Example
curl https://api.lux-cad.com/api/v1/incidents-open \ -H "x-lux-api-key: $LUX_API_KEY"
[
{
"incident_id": "c4db0b05-d36a-4e7e-a03d-bfc85545921a",
"call_number": "#911-7287",
"incident_type": "ALARM_FIRE",
"incident_type_label": "Fire Alarm",
"title": "Fire Alarm",
"description": "LTD on fire",
"priority": 2,
"status": "PENDING",
"departments": ["SAFR"],
"sub_departments": ["FIRE"],
"location_text": "Postal 101",
"address": "Amador Road",
"created_at": "2026-08-10T01:22:34.673520+00:00",
"updated_at": "2026-08-10T01:22:34.673520+00:00"
}
]GET/incidentsOne incident by id, in any state — including closed. Use this to follow an incident you created, or to look up a call number a…expand_more
One incident by id, in any state — including closed. Use this to follow an incident you created, or to look up a call number a player mentioned.
| Parameter | Type | Notes |
|---|---|---|
incident_id | uuid (required) | The incident_id you got back from POST /incidents, or from /incidents-open |
Returns an array with one incident, or an empty array. An incident belonging to another community is indistinguishable from one that does not exist.
| Field | Type | Notes |
|---|---|---|
incident_id | uuid | Stable identifier. This is the key — see the note under call_number |
call_number | string | The reference dispatchers read out, e.g. #911-7287. A label, not a key: it is not unique and the CAD tolerates duplicates |
incident_type | string | Short code, matching /incident-types |
incident_type_label | string | null | Human-readable name. null if the type has since been removed from the community's configuration |
title | string | Short summary line |
description | string | null | The narrative. Dispatchers edit this as the call develops |
priority | integer | 1 (highest) to 3 |
status | string | PENDING, ACTIVE or CLOSED |
departments | string[] | Which services are on it (LEO, SAFR, DISPATCH). May be empty |
sub_departments | string[] | Community-defined subdivisions. Often empty |
location_text | string | Display location. Usually Postal <code>; falls back to GPS <x>, <y> or Location unknown |
address | string | null | Street name, when known |
created_at | timestamp | When the incident was created |
updated_at | timestamp | Last change of any kind |
Coordinates, caller identity, callback number, reporting party, suspect descriptions and linked vehicles are deliberately not part of this payload. If you have a concrete need for one of them, ask — we can add fields, but we cannot take them away again once published.
Example
curl "https://api.lux-cad.com/api/v1/incidents?incident_id=c4db0b05-d36a-4e7e-a03d-bfc85545921a" \ -H "x-lux-api-key: $LUX_API_KEY"
[
{
"incident_id": "c4db0b05-d36a-4e7e-a03d-bfc85545921a",
"call_number": "#911-7287",
"incident_type": "ALARM_FIRE",
"incident_type_label": "Fire Alarm",
"title": "Fire Alarm",
"description": "LTD on fire",
"priority": 2,
"status": "PENDING",
"departments": ["SAFR"],
"sub_departments": ["FIRE"],
"location_text": "Postal 101",
"address": "Amador Road",
"created_at": "2026-08-10T01:22:34.673520+00:00",
"updated_at": "2026-08-10T01:22:34.673520+00:00"
}
]POST/incidentsCreate an incident, pre-specified on an incident type. It lands in the dispatch queue immediately and appears on every…expand_more
Create an incident, pre-specified on an incident type. It lands in the dispatch queue immediately and appears on every dispatcher's console in your community, exactly like a call raised in the CAD.
Read Writing first — especially the part about 502.
Body: JSON object. Unknown fields are rejected.
| Field | Type | Notes |
|---|---|---|
incident_type | string (required) | A code from /incident-types. Case-insensitive. STANDARD = "not typed yet" and is always accepted |
title | string (required) | Short summary line. Max 200 characters. Cannot be blank |
description | string | The narrative. Max 10 000. Left off, it becomes No initial narrative logged. |
x | number | Game world coordinate. Send x and y together or not at all |
y | number | Game world coordinate |
postal | string | Postal code. Leave it off and we derive it from x/y — see below |
street | string | Street name; becomes address. Also derived from x/y when omitted |
reporting_party_name | string | Who called it in. Max 120 |
callback_number | string | Max 40 |
weapons_involved | string | Free text. Max 200 |
character_id | uuid | Attach a character as the caller. Must belong to your community and not be archived. They will see this incident in their own civilian portal |
call_prefix | string | 2–6 letters or digits. Defaults to API |
Priority and departments are not yours to set. They follow the incident type as your server owner
configured it — that is the whole point of pre-specifying on a short code. If a type has no departments
configured, the incident is created with none, and it will not show up in a department-filtered view
until a dispatcher types it. Check /incident-types if a call seems to vanish.
The response always tells you what was actually applied.
Send coordinates; we work out the rest. With x and y we look up the nearest postal in your
community's postal list (including a custom one your admins uploaded) and the nearest street, and fill
location_text and address for you. Anything you send yourself always wins. Without coordinates and
without a postal, the incident is created with Location unknown — it will still reach dispatch, but
there is no map pin and no automatic arrival detection.
The call number. You get #<PREFIX>-<six digits>; the # is part of the value, don't add your own.
Pick a call_prefix that identifies your integration. The prefixes the CAD issues itself — 911, 311,
TS, TIP, PUR, 10 — are reserved and rejected, so a script cannot pass itself off as a 911 call.
Note that call numbers are not unique: keep the returned incident_id, that is the handle.
Rate limit: 10 per minute, separate from and stricter than the general limit. Creating an incident notifies every connected client in your community, so this one is deliberately tight.
There is no PATCH or DELETE. Once a call is in the queue it belongs to dispatch; closing it is a
dispatcher's decision, made in the CAD.
Returns 201 with the created incident, in the same shape as a GET /incidents row.
Example
curl -X POST https://api.lux-cad.com/api/v1/incidents \
-H "x-lux-api-key: $LUX_API_KEY" \
-H "content-type: application/json" \
-d '{
"incident_type": "ROBBERY_ARMED",
"title": "Armed robbery in progress",
"description": "Two masked suspects fled on foot.",
"x": 1230.5,
"y": -1720.75,
"reporting_party_name": "Marcus Webb",
"callback_number": "555-0142",
"weapons_involved": "Handgun",
"call_prefix": "BOT"
}'{
"incident_id": "21446722-6f14-41a0-b6eb-1a20b5c26ea7",
"call_number": "#BOT-001272",
"incident_type": "ROBBERY_ARMED",
"incident_type_label": "Robbery - Armed",
"title": "Armed robbery in progress",
"description": "Two masked suspects fled on foot.",
"priority": 1,
"status": "PENDING",
"departments": ["LEO"],
"sub_departments": [],
"location_text": "Postal 181",
"address": "Innocence Blvd",
"created_at": "2026-08-11T16:41:06.130645+00:00",
"updated_at": "2026-08-11T16:41:06.130645+00:00"
}Note that priority, departments and location_text in that response were all filled in for you —
the first two from the incident type, the last from the coordinates.
POST/incident-logsAdd a line to an incident's operational log — the running timeline dispatchers and units read on the call. Useful for reporting…expand_more
Add a line to an incident's operational log — the running timeline dispatchers and units read on the call. Useful for reporting back what your own script did: a tow dispatched, a gate opened, a scene cleared.
Send exactly one of author or system. Sending both, or neither, is a 400.
| Field | Type | Required | Notes |
|---|---|---|---|
incident_id | uuid | yes | From POST /incidents or GET /incidents-open |
content | string | yes | 1-2000 characters |
author | string | one of | Up to 60 characters. Written as AUTHOR: your text — use a callsign or a service name |
system | boolean | one of | true writes a system line instead, shown in a quieter style |
Returns 201 and the created line: log_id, incident_id, content, is_system, created_at.
system: true is not cosmetic — it decides whether a notification sound plays. An authored line
chimes on the terminal of every unit attached to that incident; a system line does not. If your script
writes several lines per call, use system: true unless you genuinely want to get someone's
attention, or you will be beeping at officers a dozen times a minute.
There is no GET, PATCH or DELETE here. The log is an append-only timeline; the CAD has no way to
edit a line either.
Example
curl -X POST "https://api.lux-cad.com/api/v1/incident-logs" -H "x-lux-api-key: $LUX_API_KEY" -H "Content-Type: application/json" -d '{
"incident_id": "3f8c1a90-27d4-4b6e-9c15-8a0e4d7b2f31",
"content": "Vehicle recovered and released to owner",
"system": true
}'{
"log_id": "9e4b2c71-5a83-4f02-b6d9-1c7e83a5f204",
"incident_id": "3f8c1a90-27d4-4b6e-9c15-8a0e4d7b2f31",
"content": "[SYSTEM] Vehicle recovered and released to owner",
"is_system": true,
"created_at": "2026-08-20T14:52:07.118342+00:00"
}GET/report-typesEvery report type configured for your community, with the two rules that decide what a POST /reports may contain.expand_more
Every report type configured for your community, with the two rules that decide what a POST /reports
may contain.
Read this once at startup rather than hard-coding codes. The list is not fixed: your server owner can turn a type off, which takes it out of this response, and we add types to the CAD over time.
A type that is turned off is not listed here. That is deliberate — this endpoint answers "what can I
use", and a type you cannot file would only be a 400 waiting to happen. Two consequences worth
knowing:
POST /reportswith a turned-off type is a400, with a message that says so rather than "unknown type". The type still exists; it is just closed for new reports.GET /reportsis unaffected. Reports already filed under that type keep coming back, with theirtype_labelintact. Turning a type off closes the door; it does not rewrite history.
Order: by sort_order, then code.
| Field | Type | Notes |
|---|---|---|
code | string | The short code. This is what you send as type |
label | string | Human-readable name, as officers see it |
is_system | boolean | true for the types every community starts with. Unlike incident types, these can be used with POST /reports |
sort_order | integer | Display order in the CAD |
supports_charges | boolean | false means this type carries no charges at all. Sending charges for it is a 400 |
requires_subject | boolean | false means the report is about an event rather than a person, so character_id may be left off |
departments | string[] | Which services may write this type in the CAD (LEO, SAFR, DISPATCH). Empty means everyone. This does not restrict the API — your key writes on behalf of the community, not of a department |
read_departments | string[] | Which services may read this type in the CAD. Empty means everyone. Same rule as above: it does not restrict the API |
departments and read_departments are informational, and they always will be: your key authenticates
a community, not a department, so there is no LEO or SAFR on your side of the line for us to check
against. They are published because a community building its own panel needs to know who is supposed to
see what — and because publishing who may write, but not who may read, would be half an answer.
supports_charges is derived, not stored: it is true when the type applies a fine, a jail sentence, or
both. A warning is the usual example of false — it goes on someone's record without costing them
anything.
Which types carry a court date is decided here too, and is not currently in this response. Today
ARREST, NTA and CITATION carry one; WARNING and INVESTIGATION do not. Sending court_date for
a type that does not carry one is a 400, the same way charges is. We have deliberately not published
the underlying per-type field list yet: it would be a contract we cannot take back, and there is no way
to send arbitrary custom fields, so it would describe something you cannot use. If you need to know
whether a type takes a court date, treat the three codes above as the answer and
tell us if your community configured something else.
Example
curl https://api.lux-cad.com/api/v1/report-types \ -H "x-lux-api-key: $LUX_API_KEY"
[
{
"code": "ARREST",
"label": "Arrest",
"is_system": true,
"sort_order": 10,
"supports_charges": true,
"requires_subject": true,
"departments": ["LEO", "DISPATCH"],
"read_departments": ["LEO", "DISPATCH"]
},
{
"code": "NTA",
"label": "NTA",
"is_system": true,
"sort_order": 20,
"supports_charges": true,
"requires_subject": true,
"departments": ["LEO", "DISPATCH"],
"read_departments": ["LEO", "DISPATCH"]
},
{
"code": "CITATION",
"label": "Citation",
"is_system": true,
"sort_order": 30,
"supports_charges": true,
"requires_subject": true,
"departments": ["LEO", "DISPATCH"],
"read_departments": ["LEO", "DISPATCH"]
},
{
"code": "WARNING",
"label": "Warning",
"is_system": true,
"sort_order": 40,
"supports_charges": false,
"requires_subject": true,
"departments": ["LEO", "DISPATCH"],
"read_departments": ["LEO", "DISPATCH"]
},
{
"code": "INVESTIGATION",
"label": "Investigation",
"is_system": true,
"sort_order": 50,
"supports_charges": false,
"requires_subject": false,
"departments": ["LEO", "DISPATCH", "SAFR"],
"read_departments": ["LEO", "DISPATCH", "SAFR"]
}
]GET/reportsPublished reports — one by id, or every report a character or Discord user appears in, with charges and totals worked out.expand_more
Published reports — one by id, or every report a character or Discord user appears in, with charges and totals worked out.
Send exactly one of report_id, character_id or discord_id. since_number and limit are
optional and combine with any of them.
| Parameter | Type | Notes |
|---|---|---|
report_id | uuid | One report. The id you got back from POST /reports |
character_id | uuid | Every report this character is on, as main subject or as a co-suspect |
discord_id | string | Every report any character of that Discord user is on, within your community |
since_number | integer | Cursor: return only reports with a report_number greater than this. Exclusive |
limit | integer | Default 50, maximum 200. Larger values are clamped rather than refused |
Order: by report_number, ascending. Always — this is also what makes the cursor work.
Page on report_number, never on created_at. Reports are created in batches (one action in the
CAD writes one report per suspect), so it is normal for several reports to share a creation timestamp
to the microsecond. A time-based cursor silently skips them. report_number is unique within your
community and never empty.
Only PUBLISHED reports are returned. Drafts are an officer's unfinished work and stay out of every
read path, this one included.
| Field | Type | Notes |
|---|---|---|
report_id | uuid | Stable identifier. This is the handle for PATCH |
report_number | integer | Sequential within your community, from 1 up. The cursor for since_number |
type | string | Short code, matching /report-types |
type_label | string | Human-readable name. Falls back to the code itself if the type was removed from the community's configuration |
status | string | Always PUBLISHED on this endpoint |
title | string | null | Short summary line. Often absent on older reports |
narrative | string | null | The full narrative as it appears on the paper. Each contribution is headed by [author — timestamp]; several officers can add to one report |
issuing_officer | string | null | Free text — the callsign or name printed on the paper. Not a link to a CAD account |
location_text | string | null | Display location, e.g. Postal 181 |
address | string | null | Street name, when known |
court_date | date | null | The date the subject is due in court, as YYYY-MM-DD. null when the report type does not carry one, or when it was deliberately cleared. See below |
character_id | uuid | null | The main subject. null on a report about an event rather than a person |
suspect_ids | uuid[] | Everyone named on the report, main subject first. Empty only when the report names nobody at all — see the note below |
incident_ids | uuid[] | Every incident this report is linked to, ready for /incidents. May be empty; a report can carry more than one |
total_fine | integer | Sum over the charges of fine × charge_count |
total_jail_days | integer | Sum over the charges of jail_days × charge_count, in days |
charges | object[] | See below. Empty array when there are none — never null |
created_at | timestamp | When the report was filed. May be back-dated by an integration |
updated_at | timestamp | Last change of any kind |
suspect_ids changed on 2026-09-01 for reports filed before that date. Until then, one
write path — by far the busiest one, filing a citation or an arrest straight from an incident —
set only character_id and left suspect_ids empty. 262 reports were affected, and for those
this endpoint returned "suspect_ids": [] while character_id named a real person. Those rows
have been backfilled, so suspect_ids now contains the main subject as promised above.
Nothing about the contract changed and no report gained or lost a subject. But if your
integration treats an empty suspect_ids as "this report is about an event, not a person",
check it against character_id instead — that is the field that has always answered it.
Each entry in charges:
| Field | Type | Notes |
|---|---|---|
penal_code | string | The code as it appears in your community's penal code |
title | string | null | The offence name. null if the code has since been removed from the penal code — the charge itself stays on the record |
classification | string | null | FELONY, MISDEMEANOR, INFRACTION, or null. Same caveat as title |
charge_count | integer | 1–99. "3 counts of robbery" is one entry with charge_count: 3 |
fine | integer | Per count |
jail_days | integer | Per count, in days |
fine_total | integer | fine × charge_count |
jail_days_total | integer | jail_days × charge_count |
verdict | string | null | The current verdict code, e.g. CONVICTED. null means no verdict has been recorded yet |
verdict_label | string | null | Human-readable name. Falls back to the code itself if the verdict was removed from the community's configuration. null alongside a null verdict |
verdict_at | timestamp | null | When the current verdict was recorded |
Charges are ordered by penal_code.
verdict: null and verdict: "PENDING" are different answers. null means nobody has ruled on
this charge; PENDING means somebody deliberately put it back on the docket — an appeal was granted,
a mistrial was declared. We never collapse the first into the second, so do not treat them as the
same when you decide what still needs a hearing.
Verdicts are set per charge but usually as a whole. A court rules on a case, so both the citizen
portal and PATCH /reports set one verdict across every charge at once; only an
officer in the CAD can differ per charge. Expect them to agree, and handle the case where they don't.
fine and jail_days are per count, not per charge. A charge with charge_count: 3 and
fine: 500 costs 1500. We give you both the per-count value and the multiplied one so you never have
to decide which is which; if you sum it yourself, sum fine_total.
Jail time is always expressed in days. How long a day is in your roleplay is your community's own convention — we store and return the number your penal code carries, and never convert it.
court_date is a stored date, not a calculation. It is a calendar day with no time of day, written
when the report is filed and never recomputed afterwards — so a report you read today and read again next
year gives the same answer. It defaults to two weeks after the report was filed, worked out in your
community's own timezone, and an officer can change it before the report is published.
null genuinely means "no court date": either the type does not carry one (WARNING,
INVESTIGATION), or it was cleared on purpose. Do not substitute a date of your own for it.
Reports filed before 2026-08-20 only carry a court date if they are citations. Citations were filled in retroactively with the same two-week rule they were already being printed with, so nothing visibly changed. Arrest reports and notices to appear were deliberately left empty: they never showed a court date, and inventing one would put a date on a legal record that was never communicated to anyone.
Example
curl "https://api.lux-cad.com/api/v1/reports?character_id=b7d6bc59-8126-465f-9e67-8e1a353392ca&limit=1" \ -H "x-lux-api-key: $LUX_API_KEY"
[
{
"report_id": "02786fe2-7276-43cf-8b94-37db4421a5a6",
"report_number": 97,
"type": "ARREST",
"type_label": "Arrest",
"status": "PUBLISHED",
"title": "Armed robbery — Vespucci Beach",
"narrative": "[LSPD 402 — 2026-03-14 21:05]\nSuspect was arrested after a foot pursuit and booked at Mission Row.",
"issuing_officer": "LSPD 402",
"location_text": "Postal 181",
"address": "Innocence Blvd",
"court_date": "2026-03-28",
"character_id": "b7d6bc59-8126-465f-9e67-8e1a353392ca",
"suspect_ids": ["b7d6bc59-8126-465f-9e67-8e1a353392ca"],
"incident_ids": [],
"total_fine": 3000,
"total_jail_days": 480,
"charges": [
{
"penal_code": "HS.1.01",
"title": "Possession of a Controlled Substance",
"classification": "MISDEMEANOR",
"charge_count": 2,
"fine": 500,
"jail_days": 60,
"fine_total": 1000,
"jail_days_total": 120,
"verdict": "CONVICTED",
"verdict_label": "Convicted",
"verdict_at": "2026-03-28T19:12:44+00:00"
},
{
"penal_code": "HS.1.02",
"title": "Possession with Intent to Distribute",
"classification": "FELONY",
"charge_count": 1,
"fine": 2000,
"jail_days": 360,
"fine_total": 2000,
"jail_days_total": 360,
"verdict": null,
"verdict_label": null,
"verdict_at": null
}
],
"created_at": "2026-03-14T21:05:00+00:00",
"updated_at": "2026-03-14T21:05:00+00:00"
}
]Walking a character's whole record, 200 at a time:
GET /reports?character_id=<id>&limit=200 GET /reports?character_id=<id>&limit=200&since_number=<highest report_number you saw>
POST/reportsFile a report on a character — including a back-dated one, which is how you give a character a history that was never played out.expand_more
File a report on a character — including a back-dated one, which is how you give a character a history that was never played out.
Read Writing first — especially the part about 502.
Body: JSON object. Unknown fields are rejected.
| Field | Type | Notes |
|---|---|---|
type | string (required) | A code from /report-types. Case-insensitive |
character_id | uuid | The main subject. Required unless the type has requires_subject: false. Must belong to your community and not be archived |
suspect_ids | uuid[] | Co-suspects, in the order you want them on the paper. Max 50. character_id is always put first; duplicates are folded |
title | string | Short summary line. Max 200 |
narrative | string | The story. Max 100 000 |
issuing_officer | string | Free text: the callsign or name printed on the paper. Max 120 |
location_text | string | Display location, e.g. Postal 181. Max 200 |
address | string | Street name. Max 200 |
court_date | date | YYYY-MM-DD, no time. Leave it out to get the default. A 400 if the type carries no court date |
charges | object[] | Max 100. Each entry: penal_code (required) and optionally charge_count (1–99, default 1) |
created_at | timestamp | Back-date the report. Defaults to now. May not be in the future |
A type your server owner has turned off is a 400. The message says the type is turned off, not that
it is unknown — it still exists and its existing reports still come back from GET /reports; it is
closed for new ones. This is the same class as requires_subject and supports_charges: your server
owner's configuration, not a change to this API. It is also the reason to read
/report-types at startup instead of hard-coding a list of codes.
The report is created as PUBLISHED. There is no draft state on this endpoint: a draft is an
officer's unfinished work, visible only to its author, and a report filed by a script has no author to
come back to it.
You do not set the fine or the jail time. Send penal_code and, if it applies, charge_count; the
amounts come from your community's own penal code, exactly as they would if an officer had picked the
charge in the CAD. This is the same rule as priority on POST /incidents: the server owner's
configuration wins, and your integration keeps working when they retune it.
The same code twice is one charge with a higher count. [{"penal_code":"X"},{"penal_code":"X"}]
becomes one entry with charge_count: 2, not two entries — that is how counts work throughout the CAD.
Leave court_date out and you get the right one. For a type that carries a court date (ARREST,
NTA, CITATION) the default is two weeks after the report was filed, worked out in your community's
timezone — the same date an officer would have got in the CAD. On a back-dated report it counts from
created_at, not from today, so importing a year of history gives each report the court date it would
have had at the time.
Send it explicitly only when you know better. It must be a plain calendar date, YYYY-MM-DD: a
timestamp is rejected, because a court date is a day and the paper prints no time. 2026-02-30 is
rejected too. Sending it for a WARNING or an INVESTIGATION is a 400 — which types carry one is your
server owner's configuration, the same as charges.
issuing_officer is free text on purpose. The report is not attributed to a CAD user account, so it
will not turn up in any officer's "My Reports". Write the callsign or name you want printed on the paper.
Rate limit: 10 per minute, shared with PATCH /reports and separate from the general limit. Both
notify every connected client in your community, so this one is deliberately tight.
A report about a character can be deleted by that character's player from their own civilian portal.
That is long-standing CAD behaviour and applies to reports an officer wrote as well — it is not
specific to the API. If durability matters to your integration, keep your own copy and check with
GET /reports?report_id=… whether the report is still there.
Returns 201 with the created report, in the same shape as a GET /reports row.
Example
curl -X POST https://api.lux-cad.com/api/v1/reports \
-H "x-lux-api-key: $LUX_API_KEY" \
-H "content-type: application/json" \
-d '{
"type": "ARREST",
"character_id": "b7d6bc59-8126-465f-9e67-8e1a353392ca",
"title": "Armed robbery — Vespucci Beach",
"narrative": "Suspect was arrested after a foot pursuit and booked at Mission Row.",
"issuing_officer": "LSPD 402",
"location_text": "Postal 181",
"address": "Innocence Blvd",
"created_at": "2026-03-14T21:05:00+00:00",
"charges": [
{ "penal_code": "HS.1.01", "charge_count": 2 },
{ "penal_code": "HS.1.02" }
]
}'{
"report_id": "02786fe2-7276-43cf-8b94-37db4421a5a6",
"report_number": 97,
"type": "ARREST",
"type_label": "Arrest",
"status": "PUBLISHED",
"title": "Armed robbery — Vespucci Beach",
"narrative": "[LSPD 402 — 2026-03-14 21:05]\nSuspect was arrested after a foot pursuit and booked at Mission Row.",
"issuing_officer": "LSPD 402",
"location_text": "Postal 181",
"address": "Innocence Blvd",
"court_date": "2026-03-28",
"character_id": "b7d6bc59-8126-465f-9e67-8e1a353392ca",
"suspect_ids": ["b7d6bc59-8126-465f-9e67-8e1a353392ca"],
"incident_ids": [],
"total_fine": 3000,
"total_jail_days": 480,
"charges": [
{
"penal_code": "HS.1.01",
"title": "Possession of a Controlled Substance",
"classification": "MISDEMEANOR",
"charge_count": 2,
"fine": 500,
"jail_days": 60,
"fine_total": 1000,
"jail_days_total": 120
},
{
"penal_code": "HS.1.02",
"title": "Possession with Intent to Distribute",
"classification": "FELONY",
"charge_count": 1,
"fine": 2000,
"jail_days": 360,
"fine_total": 2000,
"jail_days_total": 360
}
],
"created_at": "2026-03-14T21:05:00+00:00",
"updated_at": "2026-03-14T21:05:00+00:00"
}Note that total_fine and total_jail_days were worked out for you from the community's penal code —
2 × 500 + 2000, and 2 × 60 + 360.
PATCH/reportsUpdate a report you filed earlier. Fields you leave out are untouched.expand_more
Update a report you filed earlier. Fields you leave out are untouched.
| Parameter | Type | Notes |
|---|---|---|
report_id | uuid (required) | The report to update |
Body: JSON object. Unknown fields are rejected.
| Field | Type | Notes |
|---|---|---|
title | string | "" clears it |
type | string | A code from /report-types. Cannot be blank |
narrative | string | Rewrites your contribution. See below. "" removes it |
issuing_officer | string | "" clears it |
location_text | string | "" clears it |
address | string | "" clears it |
court_date | date | YYYY-MM-DD, no time. "" clears it. A 400 if the type carries no court date |
verdict | string | A verdict code for your community, e.g. CONVICTED. Applies to every charge on the report. Case-insensitive. See below |
character_id | uuid | The main subject. Added to suspect_ids if it isn't there yet |
suspect_ids | uuid[] | Replaces the list. [] removes every subject — only allowed if the type has requires_subject: false |
narrative edits your own contribution, not the whole document. A report can hold contributions
from several officers, each headed by [author — timestamp] in the narrative you read back. This
endpoint writes and rewrites exactly one of them — the one it added itself — and never touches anyone
else's text. Sending "" removes yours and leaves the rest standing. The first time you patch the
narrative of a report written entirely in the CAD, that officer's existing text is preserved as a
contribution of its own before yours is appended.
Charges cannot be patched. Set them when you create the report. If they are wrong, the practical route today is to file a corrected report.
Any report of your community can be patched, in any state — including one an officer wrote, and including a draft. That is the same reach a signed-in staff member has. Note that this leaves no audit trail: nothing in the CAD records that the change came from an integration rather than from a person.
Changing type leaves existing charges alone, even if the new type has supports_charges: false.
They stay on the record but stop being shown in the CAD. A stored court_date survives a type change
the same way — it stops being printed, but it is not thrown away as a side effect of an unrelated edit.
court_date: "" really removes it, and that is the point of the field being editable: a court date
that was set in error has to be removable, and nothing puts it back. There is no recalculation to fall
back on — clear it and GET /reports returns null until someone sets a new one. Rescheduling a hearing
is a normal PATCH with the new date; this is a stored value, so the new one sticks.
verdict sets the outcome of the case. It applies to every charge on the report at once, which is
the shape a court result actually has: the bench rules on the case, not on line 3 of the charge sheet.
Read it back per charge in charges[].verdict.
Which verdicts exist is set by the server owner. Four are always present — PENDING, CONVICTED,
ACQUITTED, DISMISSED — and a community can add its own (NOLLE PROSEQUI, APPEAL PENDING, and so
on). An unknown code is a 400 naming the code you sent. A report with no charges is a 400 as well:
there is nothing to rule on, and returning 200 would tell you a verdict was stored when none was.
Verdicts are append-only, so there is no "" to clear one. Every verdict is kept, along with who
set it and in what capacity — an officer, a citizen on their own record, or an integration like yours.
Correcting a verdict means sending the new one; both moments stay on the record. To reopen a case, send
PENDING.
Rate limit: 10 per minute, shared with POST /reports.
Returns 200 with the report as it now stands, in the same shape as a GET /reports row.
Example
curl -X PATCH "https://api.lux-cad.com/api/v1/reports?report_id=02786fe2-7276-43cf-8b94-37db4421a5a6" \
-H "x-lux-api-key: $LUX_API_KEY" \
-H "content-type: application/json" \
-d '{ "title": "Armed robbery — Vespucci Beach (amended)" }'GET/court-datesEvery hearing scheduled in your community between two dates. This is the docket: one row per report that carries a court date…expand_more
Every hearing scheduled in your community between two dates. This is the docket: one row per report that carries a court date, oldest hearing first.
Where /reports answers "what is on this person's record", this answers "who is due in
court this week". Use it to build a schedule, to post a reminder in Discord, or to find the no-shows.
| Parameter | Type | Notes |
|---|---|---|
from | date (required) | YYYY-MM-DD. Hearings on or after this date |
to | date | YYYY-MM-DD. Hearings on or before this date. Leave it out for everything from from onwards |
limit | integer | Default 50, maximum 200. Larger values are clamped rather than refused |
Order: by court_date ascending, then report_number.
from is required on purpose: without a lower bound this is a sweep of your community's entire
archive, and that is not what this endpoint is for. A day range is the intended shape — from and to
equal for a single day.
| Field | Type | Notes |
|---|---|---|
report_id | uuid | The report the hearing belongs to. Feed it to /reports for the full record |
report_number | integer | Sequential within your community |
type | string | Short code, matching /report-types |
type_label | string | Human-readable name. Falls back to the code if the type was removed from the configuration |
title | string | null | Short summary line. Often absent |
court_date | date | The hearing, as YYYY-MM-DD |
character_id | uuid | null | The main subject |
suspect_ids | uuid[] | Everyone named on the report, main subject first. May be empty |
issuing_officer | string | null | Free text — the callsign or name printed on the paper |
open_charges | integer | How many charges still have no final verdict. See below |
created_at | timestamp | When the report was filed |
Only PUBLISHED reports appear here, same as everywhere else in this API.
Which report types carry a court date is your community's configuration, not a fixed list. Arrest
reports, notices to appear and citations do by default, but a server owner can add a type of their own
that carries one, and it will show up here without any change on your side. Do not filter on type
yourself to decide what counts as a hearing — carrying a court_date is what counts.
open_charges tells you whether the hearing still needs to happen. A charge counts as open when no
verdict has been recorded, or when the recorded verdict is one your community marks as an unfinished
case (PENDING out of the box, and whatever else the owner flags that way). So open_charges: 0 means
the case has been decided and the entry is history; anything above zero is still live. A report with no
charges at all reports 0 — there is nothing to rule on, but the summons is real, so read this field
together with the report itself rather than as a filter on its own.
Example
curl "https://api.lux-cad.com/api/v1/court-dates?from=2026-08-24&to=2026-08-30" \ -H "x-lux-api-key: $LUX_API_KEY"
[
{
"report_id": "871fb442-a981-4be9-80ab-bf44dad78195",
"report_number": 84,
"type": "CITATION",
"type_label": "Citation",
"title": null,
"court_date": "2026-08-24",
"character_id": "2ee16f72-8716-4543-9b63-f7a475e6ee0e",
"suspect_ids": ["2ee16f72-8716-4543-9b63-f7a475e6ee0e"],
"issuing_officer": "SASP 190 A.Calloway",
"open_charges": 1,
"created_at": "2026-08-07T18:24:29.484775+00:00"
},
{
"report_id": "c1b02318-2b32-43f8-8781-277e6179fc7e",
"report_number": 85,
"type": "CITATION",
"type_label": "Citation",
"title": null,
"court_date": "2026-08-24",
"character_id": "2ee16f72-8716-4543-9b63-f7a475e6ee0e",
"suspect_ids": ["2ee16f72-8716-4543-9b63-f7a475e6ee0e"],
"issuing_officer": "SASP 847 Jake S.",
"open_charges": 2,
"created_at": "2026-08-07T18:25:35.500140+00:00"
}
]Yesterday's no-shows, in one call:
GET /court-dates?from=2026-08-23&to=2026-08-23
GET/warrants-activeEvery warrant in your community that is open right now — arrest, search and bench warrants alike.expand_more
Every warrant in your community that is open right now — arrest, search and bench warrants alike.
No parameters. This is the list a "most wanted" board or a Discord bot wants: a warrant appears here
while its status is ACTIVE, and drops out the moment it is served, recalled or passes its expiry date.
Order: by warrant_number, ascending.
The fields are exactly those of /warrants, so one parser handles both. On this endpoint
status is always ACTIVE.
Example
curl "https://api.lux-cad.com/api/v1/warrants-active" \ -H "x-lux-api-key: $LUX_API_KEY"
GET/warrantsWarrants — one by id, or every warrant on a character or on any character of a Discord user, in any status, with charges and…expand_more
Warrants — one by id, or every warrant on a character or on any character of a Discord user, in any status, with charges and totals worked out.
Send exactly one of warrant_id, character_id or discord_id. since_number and limit are
optional and combine with any of them.
| Parameter | Type | Notes |
|---|---|---|
warrant_id | uuid | One warrant |
character_id | uuid | Every warrant on this character |
discord_id | string | Every warrant on any character of that Discord user, within your community |
since_number | integer | Cursor: return only warrants with a warrant_number greater than this. Exclusive |
limit | integer | Default 50, maximum 200. Larger values are clamped rather than refused |
Order: by warrant_number, ascending. Page on warrant_number, the same way you page
/reports on report_number.
Warrants on an archived character are still returned: a warrant is part of the record, and archiving a character does not erase it.
| Field | Type | Notes |
|---|---|---|
warrant_id | uuid | Stable identifier |
warrant_number | integer | Sequential within your community, from 1 up. The CAD shows it as WRT-00042. The cursor for since_number |
warrant_type | string | ARREST, SEARCH or BENCH |
status | string | ACTIVE, SERVED, RECALLED or EXPIRED. See below |
reason | string | The grounds for the warrant, as written by the issuer. Never empty |
issued_by | string | Free text — the callsign printed on the warrant, e.g. BCSO 904. Not a link to a CAD account |
self_reported | boolean | true when the player put the warrant on their own character from the citizen portal, rather than a unit issuing it. issued_by then reads SELF-REPORTED |
issued_by_discord_id | string | null | Discord user ID of the officer who issued it. See the note below — null for every warrant issued before 2026-10-29 |
character_id | uuid | The person the warrant is for, ready for /characters |
discord_id | string | null | Discord user ID of the player who owns that character — the same key /characters uses |
first_name | string | The character's first name |
middle_name | string | null | Optional; most characters have none |
last_name | string | The character's last name |
issued_at | timestamp | When the warrant took effect. Can lie before created_at when the issuer back-dated it |
expires_at | timestamp | null | When it lapses. null means it does not expire |
created_at | timestamp | When the warrant was entered into the CAD |
incident_id | uuid | null | The incident it was issued from, ready for /incidents. null for a warrant issued on its own |
call_number | string | null | That incident's call number, as printed on the warrant. null without an incident |
source_report_id | uuid | null | The report the warrant was issued from, ready for /reports |
report_ids | uuid[] | Reports filed because of this warrant — typically the arrest report written when it was served. Empty array when there are none, never null |
total_fine | integer | Sum over the charges of fine × charge_count |
total_jail_days | integer | Sum over the charges of jail_days × charge_count, in days |
charges | object[] | See below. Empty array when there are none — never null |
status is worked out, not copied. A warrant whose expiry date has passed reads EXPIRED even though
nobody touched it, exactly as the CAD shows it. SERVED and RECALLED are set by an officer. There is no
timestamp for when a warrant was served or recalled; the CAD does not record one.
issued_by_discord_id only exists from 2026-10-29 on. Before that date the CAD recorded the
issuer's callsign but not their account, so older warrants return null here — that is the honest
answer, not a gap we will fill in later. It is also null on a self-reported warrant (no officer issued
it). Use issued_by for display; use issued_by_discord_id only to tie a new warrant to a staff member.
source_report_id and report_ids only point at published reports. The arrest report the CAD
creates when a warrant is served starts out as a draft, and drafts stay out of every read path of this
API. It appears in report_ids once the officer publishes it.
Each entry in charges:
| Field | Type | Notes |
|---|---|---|
penal_code | string | The code as it appears in your community's penal code |
title | string | null | The offence name, as it stands on the warrant. In practice always filled; null only if neither the warrant nor your penal code carries a name for the code |
classification | string | null | FELONY, MISDEMEANOR, INFRACTION, or null when none was recorded |
charge_count | integer | 1–99. "3 counts of robbery" is one entry with charge_count: 3 |
fine | integer | Per count |
jail_days | integer | Per count, in days |
fine_total | integer | fine × charge_count |
jail_days_total | integer | jail_days × charge_count |
Charges are ordered by penal_code. Unlike report charges they carry no verdict: a warrant is an order
to bring someone in, and the ruling happens on the report that follows.
fine and jail_days are per count, not per charge — the same rule as on
/reports. If you sum it yourself, sum fine_total.
Example
curl "https://api.lux-cad.com/api/v1/warrants?discord_id=285181941777694720&limit=1" \ -H "x-lux-api-key: $LUX_API_KEY"
[
{
"warrant_id": "4b0f2c1e-8d7a-4e61-9c3b-2f6a1d9e7c55",
"warrant_number": 42,
"warrant_type": "ARREST",
"status": "ACTIVE",
"reason": "Fled a traffic stop on Route 68 and rammed a patrol vehicle.",
"issued_by": "BCSO 904",
"self_reported": false,
"issued_by_discord_id": "399949667997319168",
"character_id": "b7d6bc59-8126-465f-9e67-8e1a353392ca",
"discord_id": "285181941777694720",
"first_name": "Robert",
"middle_name": "James",
"last_name": "Kane",
"issued_at": "2026-10-29T19:02:11.204512+00:00",
"expires_at": "2026-11-12T19:02:11+00:00",
"created_at": "2026-10-29T19:02:11.204512+00:00",
"incident_id": "e3a91c07-5d2b-4f88-a1c6-7b0d2e94f310",
"call_number": "1187",
"source_report_id": null,
"report_ids": [],
"total_fine": 3500,
"total_jail_days": 40,
"charges": [
{
"penal_code": "P.C. 2004",
"title": "Evading a Peace Officer",
"classification": "MISDEMEANOR",
"charge_count": 1,
"fine": 1500,
"jail_days": 10,
"fine_total": 1500,
"jail_days_total": 10
},
{
"penal_code": "P.C. 3009",
"title": "Assault with a Deadly Weapon",
"classification": "FELONY",
"charge_count": 2,
"fine": 1000,
"jail_days": 15,
"fine_total": 2000,
"jail_days_total": 30
}
]
}
]GET/charactersThe person-lookup endpoint. Two ways in — send exactly one:expand_more
The person-lookup endpoint. Two ways in — send exactly one:
| Parameter | Type | Notes |
|---|---|---|
discord_id | string | A Discord user ID (numeric). Returns every character that user has in your community |
character_id | uuid | One specific character, by the id you got from another endpoint — owner_character_id on /vehicles. Returns 0 or 1 row |
Sending neither, both, or a malformed value is a 400.
A Discord user who has no characters in your community — including one the CAD has never seen — returns
[]. You cannot read a character from another community with your key: an id that belongs to someone
else's community simply returns [], exactly like an id that doesn't exist.
Order: oldest first, by created_at.
| Field | Type | Notes |
|---|---|---|
character_id | uuid | Stable identifier for this character. This is what owner_character_id on /vehicles points at |
discord_id | string | Discord user ID of the player who owns this character — the same key /active-unit-members uses |
first_name | string | Never null |
middle_name | string | null | Optional. null means the character has no middle name recorded — it is not a required field in the CAD. Do not treat null as "unknown" |
last_name | string | Never null |
dob | date | Date of birth, YYYY-MM-DD. Never null |
gender | string | Free text as entered in the CAD. Never null |
status | string | null | CLEAN, WARRANT, or DECEASED. The column is nullable (it defaults to CLEAN), so handle null as "no status recorded" rather than assuming one of the three values |
job | string | null | The character's occupation, if set |
phone | string | null | The character's phone number. null if the character has hidden it, or has none |
address | string | null | |
birth_place | string | null | |
avatar_url | string | null | URL of the character's photo, if set |
created_at | timestamp | null | When the character was created. Nullable in the schema for the same reason as status (it has a default, so in practice it is always set) |
Building a full name. Join the parts you get and skip the empty ones — do not assume all three are present:
const fullName = [c.first_name, c.middle_name, c.last_name].filter(Boolean).join(" ");Concatenating unconditionally (`${c.first_name} ${c.middle_name} ${c.last_name}`) yields a double
space for the majority of characters, who have no middle name. If you display names in a compact list
or a single scrolling line, first_name + last_name is the form the CAD itself uses there.
Medical information is deliberately not exposed by this endpoint (blood type, allergies,
conditions, DNR, emergency contact). Archived characters are not returned — by either parameter, so a
character_id that has since been archived returns [].
Example
curl "https://api.lux-cad.com/api/v1/characters?discord_id=285181941777694720" \ -H "x-lux-api-key: $LUX_API_KEY"
[
{
"character_id": "7f2a19c4-6d83-4b0e-9a51-c3e8f04b7d22",
"discord_id": "285181941777694720",
"first_name": "Marcus",
"middle_name": "Elliot",
"last_name": "Vance",
"dob": "1994-02-17",
"gender": "Male",
"status": "CLEAN",
"job": "Mechanic",
"phone": "555-0199",
"address": "128 Vespucci Blvd",
"birth_place": "Los Santos",
"avatar_url": "https://…/avatars/abc.png",
"created_at": "2026-07-05T17:15:30.289075+00:00"
},
{
"character_id": "b81c40de-2f77-4c95-8e3a-1d6b90f5a4c8",
"discord_id": "285181941777694720",
"first_name": "Sky",
"middle_name": null,
"last_name": "Blue",
"dob": "1998-11-03",
"gender": "Female",
"status": "CLEAN",
"job": null,
"phone": null,
"address": null,
"birth_place": null,
"avatar_url": "https://…/avatars/def.png",
"created_at": "2026-07-11T08:02:11.447901+00:00"
}
]POST/charactersCreate a character for a player who already has a LUX CAD account.expand_more
Create a character for a player who already has a LUX CAD account.
The player must have signed in to LUX CAD at least once. We look the account up by Discord ID and
we cannot create it for you — an unknown discord_id is a 400. There is no way around this: the
account is what a character hangs from.
We also cannot check whether that player is a member of your Discord server; membership is read live at sign-in and is not stored. A character you create for someone who is not in your community will simply sit there until they join and select it.
| Field | Type | Required | Notes |
|---|---|---|---|
discord_id | string | yes | Numeric Discord user ID |
first_name | string | yes | 1-50 characters. Letters, spaces, apostrophes, periods and hyphens; accents are fine, digits are not |
last_name | string | yes | Same rules as first_name |
dob | date | yes | YYYY-MM-DD. The character must be between 16 and 100 years old |
gender | string | yes | Free text, 1-30 characters |
middle_name | string | no | Same rules as the other names. Often just an initial like P. |
job | string | no | Up to 100 characters |
address | string | no | Up to 120 characters |
birth_place | string | no | Up to 80 characters |
avatar_url | string | no | Up to 500 characters. Leave it out and we fill in a neutral placeholder |
Returns 201 and the character as a single object, in the same shape as
GET /characters. Keep the character_id — it is the handle for PATCH.
A character created this way has no phone number yet. Numbers are handed out by the CAD's own phone system, which runs on its own schedule; the character picks one up at the next round. Nothing else about the character is affected.
Example
curl -X POST "https://api.lux-cad.com/api/v1/characters" -H "x-lux-api-key: $LUX_API_KEY" -H "Content-Type: application/json" -d '{
"discord_id": "291273248543604737",
"first_name": "Robert",
"middle_name": "James",
"last_name": "Kane",
"dob": "1991-04-17",
"gender": "Male",
"job": "Mechanic"
}'PATCH/charactersUpdate a character. Takes ?characterid=<uuid> and a JSON body.expand_more
Update a character. Takes ?character_id=<uuid> and a JSON body.
A real partial update: a field you leave out stays as it is. An empty string clears an optional
field (middle_name, job, address, birth_place).
| Field | Type | Notes |
|---|---|---|
first_name | string | Same rules as on create |
middle_name | string | Empty string clears it |
last_name | string | |
dob | date | YYYY-MM-DD, still 16-100 years old |
gender | string | |
job | string | Empty string clears it |
address | string | Empty string clears it |
birth_place | string | Empty string clears it |
avatar_url | string | Cannot be cleared — every character keeps a photo |
archived | boolean | true hides the character from all default lookups; false restores it |
deceased | boolean | true marks the character deceased. Cannot be set back to false |
Returns 200 and the updated character, plus an archived boolean.
There is no DELETE, and there will not be one. Twenty-one tables reference a character, and three
of those cascade into records that belong to other people — reports naming them as a suspect,
warrants, medical treatments. Deleting a character would destroy those without a trace. archived: true
is the equivalent: the character disappears from every lookup and picker, and everything written about
them stays intact.
Archiving also ends any open service shift for that character. Someone cannot staff a business while archived, so we sign them off rather than leaving them listed as an online provider.
deceased is one-way on purpose. It is the only meaning that the character's status field still
carries, and letting it be written in both directions is what made that field unreliable before. If
you marked the wrong character, fix it in the CAD.
Example
curl -X PATCH "https://api.lux-cad.com/api/v1/characters?character_id=7f2a19c4-6d83-4b0e-9a51-c3e8f04b7d22" -H "x-lux-api-key: $LUX_API_KEY" -H "Content-Type: application/json" -d '{ "job": "Paramedic", "address": "1 Elgin Ave" }'GET/vehiclesVehicle lookup. Two ways in — send exactly one:expand_more
Vehicle lookup. Two ways in — send exactly one:
| Parameter | Type | Notes |
|---|---|---|
plate | string | A licence plate. Matching ignores case, spaces and dashes, so 1ABC-234, 1abc234 and 1 ABC 234 all find the same vehicle |
discord_id | string | A Discord user ID (numeric). Returns every vehicle owned by any of that user's characters in your community |
Sending neither, both, or a malformed value is a 400.
Plates are not unique. Two players can register the same plate, so a plate lookup can return more
than one row. Handle a list, not a single object.
Order: by plate, then created_at.
| Field | Type | Notes |
|---|---|---|
vehicle_id | uuid | The handle for PATCH and DELETE. Use this, never the plate — plates are not unique |
plate | string | The plate exactly as it is stored, including any spaces and original casing. Your search term is normalised; this value is not |
make | string | null | Free text as entered by the player, so casing varies |
model | string | Free text. Never null |
year | integer | null | |
color | string | Free text, e.g. Wine Red. Never null |
type | string | null | Body type, e.g. SEDAN, SUV, COUPE |
status | string | null | ACTIVE, STOLEN or IMPOUNDED. null means no status recorded — treat it as ACTIVE at your own discretion, don't assume |
insurance_status | string | null | VALID, EXPIRED or NONE. See the warning below |
insurance_company | string | null | |
insurance_expires | date | null | YYYY-MM-DD |
owner_kind | string | PLAYER or UNLINKED. Branch on this, never on whether owner_character_id is null — see the note below |
owner_character_id | uuid | null | The character that owns this vehicle. Feed it to /characters as character_id to get the owner and their Discord ID. null when owner_kind is UNLINKED |
owner_name | string | null | The owner's name as a plain string. For PLAYER this is the character's name (Last, First Middle); for UNLINKED it is the free-text name that was registered. null only if the character has no name on file |
expires_at | timestamp | null | When a temporary registration is removed. null means permanent — which is every vehicle owned by a player, and every vehicle registered before this field existed |
created_at | timestamp | null | When the vehicle was registered |
Always branch on owner_kind, not on owner_character_id === null. A vehicle can be registered
without a citizen record behind it — see POST /vehicles — and more owner kinds may
be added later. Code that tests owner_kind === "PLAYER" keeps working when that happens; code that
tests for a null id does not.
insurance_status alone is not the whole answer. It is a stored value that nothing keeps in sync
with the calendar, so a policy can read VALID while insurance_expires is already in the past. The
CAD's own flagging treats expired by date as uninsured. If insurance matters to you, check both:
insurance_status === "VALID" && new Date(insurance_expires) >= new Date().
Owner details are deliberately not on this endpoint — you get the owner's id, not their name. One
extra call to /characters?character_id=… gets you the person, and it keeps personal data on the one
endpoint that is about people.
Example
curl "https://api.lux-cad.com/api/v1/vehicles?plate=bert-xl" \ -H "x-lux-api-key: $LUX_API_KEY"
[
{
"vehicle_id": "1c4d8e02-9b77-4a35-8f60-2ad1e5c93b48",
"plate": "BERT XL",
"make": "Albany",
"model": "Cavalcade XL",
"year": 2015,
"color": "Red/Black",
"type": "SUV",
"status": "ACTIVE",
"insurance_status": "EXPIRED",
"insurance_company": "MORS MUTUAL",
"insurance_expires": "2026-06-30",
"owner_kind": "PLAYER",
"owner_character_id": "7f2a19c4-6d83-4b0e-9a51-c3e8f04b7d22",
"owner_name": "Kane, Robert James",
"expires_at": null,
"created_at": "2026-06-14T20:41:09.882115+00:00"
}
]Plate to owner, in two calls (get() is the small fetch helper from the
worked example at the bottom of this page):
const [vehicle] = await get("vehicles?plate=BERT-XL");
if (vehicle.owner_kind !== "PLAYER") {
// No citizen record behind this registration — owner_name is all there is.
console.log(`${vehicle.plate} → ${vehicle.owner_name} (not a citizen record)`);
} else {
const [owner] = await get(`characters?character_id=${vehicle.owner_character_id}`);
console.log(`${vehicle.plate} → ${owner.first_name} ${owner.last_name} (${owner.discord_id})`);
}POST/vehiclesRegister a vehicle. Either on one of your community's characters, or on a free-text name for a vehicle that has no citizen record…expand_more
Register a vehicle. Either on one of your community's characters, or on a free-text name for a vehicle that has no citizen record behind it.
Send exactly one of character_id or owner_name. Sending both, or neither, is a 400.
| Field | Type | Required | Notes |
|---|---|---|---|
plate | string | yes | Up to 8 characters after trimming. Letters, digits and spaces |
model | string | yes | 1-60 characters |
color | string | yes | 1-30 characters |
character_id | uuid | one of | A character in your community. Get one from /characters |
owner_name | string | one of | Up to 120 characters. Registers the vehicle without a citizen record |
make | string | no | Up to 60 characters |
type | string | no | Up to 30 characters |
year | integer | no | A JSON number, not a string |
status | string | no | ACTIVE (default), STOLEN or IMPOUNDED |
insurance_status | string | no | VALID, EXPIRED or NONE (default) |
insurance_company | string | no | Up to 60 characters |
insurance_expires | date | no | YYYY-MM-DD |
expires_in_hours | integer | no | Only with owner_name. Default 24, maximum 720 (30 days) |
Returns 201 and the created vehicle as a single object, in the same shape as
GET /vehicles. Keep the vehicle_id — it is the handle for PATCH and DELETE.
Registering a vehicle with no owner
This exists for the case where a vehicle needs to be in the CAD but no player owns it: a stolen NPC car
that a script reports with /stolen, for example. Officers can then look the plate up and see the name
it was registered under, and the plate alarms on ALPR when status is STOLEN.
These registrations expire, and expiry is a deletion. expires_in_hours defaults to 24; once it
passes, a cleanup job removes the row within the hour. The vehicle, and anything that depended on it,
is gone — this is not an archive. Extend it with PATCH if the vehicle is still in play, or leave it
to disappear.
The name is a label, not a person. It creates no citizen record, it is not searchable as a person,
and the CAD shows it as "not linked to a citizen record". Do not use it to represent a player who
does have a character — use character_id for that, or the vehicle will not show up under their name.
Two limits apply, both returning 409:
- A plate must be unique within your community. Registering a plate that already exists is refused —
use
PATCHon the existing vehicle instead. This is deliberate: the in-game plate lookup picks one row, and a duplicate makes which one non-deterministic. - 200 live registrations without an owner, per community. If you hit this, your script is not cleaning up. Delete what you no longer need.
Example
curl -X POST "https://api.lux-cad.com/api/v1/vehicles" -H "x-lux-api-key: $LUX_API_KEY" -H "Content-Type: application/json" -d '{
"plate": "46NXV129",
"model": "Sultan",
"color": "Black",
"owner_name": "Marcus Webb",
"status": "STOLEN",
"expires_in_hours": 12
}'{
"vehicle_id": "b58c1e77-3f92-4d10-a6c4-8e02b7d5194a",
"plate": "46NXV129",
"make": null,
"model": "Sultan",
"year": null,
"color": "Black",
"type": null,
"status": "STOLEN",
"insurance_status": "NONE",
"insurance_company": null,
"insurance_expires": null,
"owner_kind": "UNLINKED",
"owner_character_id": null,
"owner_name": "Marcus Webb",
"expires_at": "2026-08-20T02:14:31.552108+00:00",
"created_at": "2026-08-19T14:14:31.552108+00:00"
}PATCH/vehiclesUpdate a vehicle. Takes ?vehicleid=<uuid> and a JSON body.expand_more
Update a vehicle. Takes ?vehicle_id=<uuid> and a JSON body.
A real partial update: a field you leave out stays as it is. An empty string clears an optional
field (make, type, insurance_company).
| Field | Type | Notes |
|---|---|---|
status | string | ACTIVE, STOLEN or IMPOUNDED |
color | string | |
model | string | |
make | string | Empty string clears it |
type | string | Empty string clears it |
year | integer | |
owner_name | string | Only on a vehicle that has no character_id. 409 otherwise |
insurance_status | string | VALID, EXPIRED or NONE |
insurance_company | string | Empty string clears it |
insurance_expires | date | YYYY-MM-DD |
expires_in_hours | integer | Only on a vehicle that has no character_id. Resets the clock from now |
Returns 200 and the updated vehicle as a single object.
plate cannot be changed. A different plate is a different vehicle, and there is no unique index to
catch the collision. Delete and re-register instead.
Transferring ownership is not supported. You cannot turn a player's vehicle into an unlinked one, or the other way round.
insurance_status may not come back the way you sent it. Whenever a vehicle has an
insurance_expires date on file, saving it derives the status from that date: a date in the past
forces EXPIRED, a date in the future forces VALID. NONE means "no cover at all" and is never
derived, and a vehicle without a date keeps whatever status you send. That is the CAD's own rule, not
a quirk of this endpoint, and it applies to every save — including one that does not mention insurance
at all. Read the response rather than assuming your value stuck.
Example
curl -X PATCH "https://api.lux-cad.com/api/v1/vehicles?vehicle_id=b58c1e77-3f92-4d10-a6c4-8e02b7d5194a" -H "x-lux-api-key: $LUX_API_KEY" -H "Content-Type: application/json" -d '{ "status": "ACTIVE", "expires_in_hours": 48 }'DEL/vehiclesRemove a vehicle. Takes ?vehicleid=<uuid>.expand_more
Remove a vehicle. Takes ?vehicle_id=<uuid>.
This is a real deletion, not an archive. The row is gone and does not come back.
Returns 200 and { "vehicle_id": …, "plate": …, "deleted": true }.
Refused with a 409 when something else still points at the vehicle:
| Situation | Why |
|---|---|
| A BOLO references it | Deleting would silently empty that BOLO's vehicle link. Remove the BOLO first |
| A report references it | The report would lose its vehicle without a trace |
| It has impound history | The impound record would be destroyed along with it |
Unlike DELETE /news-articles this one is not idempotent: deleting a
vehicle that is already gone returns 404.
Example
curl -X DELETE "https://api.lux-cad.com/api/v1/vehicles?vehicle_id=b58c1e77-3f92-4d10-a6c4-8e02b7d5194a" -H "x-lux-api-key: $LUX_API_KEY"
GET/civilian-service-typesEvery civilian service your community has set up, whether or not anyone is on duty for it right now.expand_more
Every civilian service your community has set up, whether or not anyone is on duty for it right now.
Use it to prepare your interface: the right name, icon and colour for each service, so that when a
provider comes on duty in /civilian-services you can show them properly. The
two are matched on code here and service_code there.
This is configuration, not live state. Read it once at startup and cache it — your admins rarely
change it. If you later meet a service_code you don't have, that's your cue to fetch the list again.
Services your admins have switched off are not listed.
Order: by the display order your admins configured, then code — the same order the CAD uses.
| Field | Type | Notes |
|---|---|---|
code | string | Stable code of the service, e.g. TOW, TAXI, MECHANIC, NEWS, LAWYER. Uppercase, unique within your community, and never changes once created. Appears as service_code on /civilian-services |
label | string | Display name, e.g. Tow / Impound |
icon | string | Name of the Material Symbols icon the CAD shows, e.g. auto_towing |
color | string | Hex colour, e.g. #f59e0b |
short_code | string | null | Abbreviation used in the CAD's on-duty counters, e.g. TOW, MECH. null if not configured |
audiences | string[] | Who sees this service's presence in the CAD: any of civilians, dispatch, leo_safr. Empty array means it is not shown as a presence counter anywhere |
sort_order | integer | Display position set by your admins. Lower comes first; several services can share a value |
modules | string[] | The modules your admins switched on for this service, alphabetical. Any of the values in the table below. Only the names: the settings inside a module are not published |
Modules. These names are stable. A new module may be added to this list later, so ignore a name you don't recognise rather than failing on it.
| Module | What it means in the CAD |
|---|---|
presence | Providers can go on and off duty. Every service has it |
tasks | A job queue: requests that a provider accepts, works and completes |
task_intake | Civilians can send a request directly to an on-duty provider |
map | Requests carry a pickup (and optional destination) on the map |
live_location | On-duty providers are shown live on the map |
road_status | Providers can mark road closures, roadworks, accidents and hazards on the map |
routes | Providers can build and drive routes with stops, such as a bus line or a tour |
radio | The service takes part in the CAD radio |
news_publish | Providers publish articles to the public news feed (see /news-articles) |
impound | Officers can impound a vehicle, which queues a tow task for this service |
legal_linkage | Civilians can give a lawyer read access to their legal file during the shift |
Example
curl https://api.lux-cad.com/api/v1/civilian-service-types \ -H "x-lux-api-key: $LUX_API_KEY"
[
{
"code": "TOW",
"label": "Tow / Impound",
"icon": "auto_towing",
"color": "#f59e0b",
"short_code": "TOW",
"audiences": ["civilians", "dispatch", "leo_safr"],
"sort_order": 10,
"modules": ["impound", "map", "presence", "radio", "road_status", "tasks"]
},
{
"code": "TAXI",
"label": "Taxi / Rideshare",
"icon": "local_taxi",
"color": "#eab308",
"short_code": "TAXI",
"audiences": ["civilians", "leo_safr"],
"sort_order": 20,
"modules": ["live_location", "map", "presence", "task_intake", "tasks"]
},
{
"code": "MECHANIC",
"label": "Mechanic",
"icon": "build",
"color": "#3b82f6",
"short_code": "MECH",
"audiences": ["civilians"],
"sort_order": 30,
"modules": ["presence", "tasks"]
}
]Showing who is on duty with each service's own name and icon, including services nobody is working:
let services = await get("civilian-service-types");
async function board() {
const onDuty = await get("civilian-services");
// A service added since startup: refresh the list once.
if (onDuty.some((p) => !services.some((s) => s.code === p.service_code))) {
services = await get("civilian-service-types");
}
for (const s of services) {
const providers = onDuty.filter((p) => p.service_code === s.code);
console.log(`[${s.icon}] ${s.label}: ${providers.length} on duty`, s.color);
}
}GET/civilian-servicesEveryone currently on duty as a civilian service provider — tow operators, taxi drivers, mechanics, lawyers, press, and whatever…expand_more
Everyone currently on duty as a civilian service provider — tow operators, taxi drivers, mechanics,
lawyers, press, and whatever else your admins have configured. One row per open shift, so a service with
three drivers on duty appears three times. For the services themselves, including the ones nobody is
working right now, see /civilian-service-types.
This is the same set the CAD shows civilians on its own services page, which means it is also the set that would answer "is there a tow truck online right now?".
A provider is on this list until they go off duty. Shifts that go quiet are closed automatically after a couple of hours, so a stale row cannot linger indefinitely.
Order: by the display order your admins configured for the service, then service code, then
started_at.
| Field | Type | Notes |
|---|---|---|
service_code | string | Stable code of the service, e.g. TOW, TAXI, MECHANIC, NEWS, LAWYER. Unique within your community. Admins can add their own |
service_label | string | Display name, e.g. Tow / Impound |
service_icon | string | Material Symbols icon name the CAD uses, e.g. local_shipping |
service_color | string | Hex colour, e.g. #f59e0b |
short_code | string | null | Abbreviation used in the CAD's on-duty counters, e.g. TOW, MECH. null if not configured |
audiences | string[] | Who sees this service's presence in the CAD: any of civilians, dispatch, leo_safr. Empty array means it is not shown as a presence counter anywhere |
character_id | uuid | The character running this shift. Feed it straight into /characters?character_id=… for the full profile |
discord_id | string | null | Discord user ID of the player behind that character — the same key /active-unit-members and /characters use. null only if the character has no linked account, which should not happen in practice |
business_name | string | null | The business the provider is trading under, if they entered one |
contact | string | null | Contact number the provider published for this shift, if any |
started_at | timestamp | When this provider went on duty |
This endpoint identifies people. A row ties a Discord account to being on duty for a given service
at a given time. character_id and discord_id are here so you can reach the provider outside the
CAD — ping them in Discord, or look the character up. Nothing beyond those two keys is exposed: for
names, phone or address, make the follow-up call to /characters.
One player can have several characters, so discord_id alone does not tell you which character is
working. Use character_id when that matters.
Example
curl https://api.lux-cad.com/api/v1/civilian-services \ -H "x-lux-api-key: $LUX_API_KEY"
[
{
"service_code": "TOW",
"service_label": "Tow / Impound",
"service_icon": "local_shipping",
"service_color": "#f59e0b",
"short_code": "TOW",
"audiences": ["civilians", "dispatch", "leo_safr"],
"character_id": "ecb7aed9-1eb8-4891-be45-fcd6e38fd11e",
"discord_id": "302607279759425536",
"business_name": "Benny's Towing",
"contact": "555-0101",
"started_at": "2026-07-30T16:23:04.967916+00:00"
},
{
"service_code": "MECHANIC",
"service_label": "Mechanic",
"service_icon": "build",
"service_color": "#3b82f6",
"short_code": "MECH",
"audiences": ["civilians"],
"character_id": "387c03fd-3ecc-46f6-ae17-6109c63e6cc8",
"discord_id": "961118379614285844",
"business_name": null,
"contact": "555-0142",
"started_at": "2026-07-30T16:38:04.967916+00:00"
}
]Counting who is online per service:
const online = {};
for (const s of await get("civilian-services")) {
online[s.service_code] = (online[s.service_code] ?? 0) + 1;
}
// { TOW: 1, MECHANIC: 1 }Pinging the on-duty tow operators in Discord, and naming them:
const tows = (await get("civilian-services")).filter((s) => s.service_code === "TOW");
for (const s of tows) {
const [who] = await get(`characters?character_id=${s.character_id}`);
console.log(`${s.business_name ?? who.first_name + " " + who.last_name} — <@${s.discord_id}>`);
}GET/news-articlesArticles published in your community's newspaper. Built for syncing: ask for everything published since the last article you saw…expand_more
Articles published in your community's newspaper. Built for syncing: ask for everything published since the last article you saw, and page forward until you're up to date.
| Parameter | Type | Notes |
|---|---|---|
since | timestamp (optional) | Return only articles published strictly after this moment. Leave it off to start from the very first article |
limit | integer (optional) | Page size. Default 50, maximum 200. Values above the maximum are capped, not rejected |
Order: oldest first, by published_at. This is the opposite of how the CAD displays the paper, and
it is deliberate — ascending order is what makes since work as a resume point.
| Field | Type | Notes |
|---|---|---|
article_id | uuid | Stable identifier. Use it to de-duplicate on your side |
service_code | string | Code of the service that published it, e.g. NEWS. A community can have more than one press outlet |
service_label | string | Display name of that service, e.g. News / Media |
author_name | string | Byline as written by the author. Never null |
title | string | Never null |
subtitle | string | null | |
body | string | The full article text. Never null |
image_url | string | null | Header image, if one was uploaded |
image_aspect | string | null | The shape the author cropped the image to: panorama (16:9), landscape (4:3), square (1:1) or portrait (3:4). The image file already has that ratio. null on articles without an image, but also on an article whose image was published without an aspect (older articles, or a POST that left it off) — treat that as landscape |
vote_count | integer | Reader upvotes at the time of the call. Never null |
status | string | PUBLISHED or ARCHIVED — see below |
published_at | timestamp | When it went live. This is the field since compares against, and the field to feed back in |
archived_at | timestamp | null | When it dropped out of the in-CAD paper. null while status is PUBLISHED |
ARCHIVED does not mean deleted. Articles have a limited run in the CAD's paper: an article ages
out after a while, and upvotes buy it extra time. When that runs out the CAD stops showing it and its
status becomes ARCHIVED — the article itself is untouched.
This endpoint returns both, on purpose. An article's whole life can be shorter than your polling
interval, so filtering to PUBLISHED here would silently drop articles you never had a chance to see.
If you want to mirror the CAD's front page exactly, filter on status === "PUBLISHED" yourself; if you
are building an archive or a Discord feed, take everything.
Articles the author has retracted are never returned, in any status.
Paging. Keep calling until you get back fewer rows than your limit; each time, pass the
published_at of the last row you received as the next since. Because since is exclusive you will
never see the same article twice, and because timestamps carry microsecond precision you will not skip
one either.
Example
curl "https://api.lux-cad.com/api/v1/news-articles?since=2026-07-25T03:26:08.595569Z&limit=2" \ -H "x-lux-api-key: $LUX_API_KEY"
Watch the + in a timestamp: in a URL it has to be written as %2B, or it arrives as a space. The
simplest way to avoid that is the Z form above (UTC), which needs no escaping at all.
[
{
"article_id": "8b2d4304-890a-4683-a1a4-ef1878a9e781",
"service_code": "NEWS",
"service_label": "News / Media",
"author_name": "R. Beland",
"title": "Harbour Road Closed After Multi-Vehicle Collision",
"subtitle": "Traffic diverted through Elgin Avenue until further notice",
"body": "Emergency services responded shortly after dawn …",
"image_url": "https://…/news/harbour.webp",
"image_aspect": "panorama",
"vote_count": 4,
"status": "ARCHIVED",
"published_at": "2026-07-25T03:27:43.532015+00:00",
"archived_at": "2026-07-28T03:30:00.101090+00:00"
},
{
"article_id": "182c5611-3ef6-4a50-97c1-fe38a6525f65",
"service_code": "NEWS",
"service_label": "News / Media",
"author_name": "R. Beland",
"title": "City Council Approves Overnight Parking Ban",
"subtitle": null,
"body": "The measure passed six votes to three …",
"image_url": null,
"image_aspect": null,
"vote_count": 1,
"status": "PUBLISHED",
"published_at": "2026-07-25T03:28:21.188770+00:00",
"archived_at": null
}
]A complete sync loop:
async function syncNews(since) {
const seen = [];
for (;;) {
const query = since ? `news-articles?limit=100&since=${encodeURIComponent(since)}`
: "news-articles?limit=100";
const page = await get(query);
seen.push(...page);
if (page.length < 100) break; // short page = caught up
since = page[page.length - 1].published_at;
}
return seen; // keep the last published_at as the `since` for your next run
}POST/news-articlesPublish an article. It appears in the CAD's paper immediately, for everyone in your community, and it ages out on the same timer…expand_more
Publish an article. It appears in the CAD's paper immediately, for everyone in your community, and it ages out on the same timer as an article written in the CAD itself.
Read Writing first — especially the part about 502.
Body: JSON object. Unknown fields are rejected.
| Field | Type | Notes |
|---|---|---|
title | string (required) | Max 300 characters. Cannot be blank |
body | string (required) | The article text. Max 100 000 characters |
author_name | string (required) | The byline, as readers will see it. Max 120 characters. Free text — it does not have to match a character |
subtitle | string | Max 300 |
image_url | string | Header image. Max 2048 |
image_aspect | string | One of panorama, landscape, square, portrait |
service_code | string | Which news outlet publishes it. Leave it off unless your community has more than one — see below |
character_id | uuid | Attribute the article to one of your community's characters. Optional — see below |
service_code is almost always unnecessary. A community has exactly one paper. news_publish is a
module your admins can switch on for more than one service, though, so if that ever happens we pick the
active one with the lowest display order and keep going — your script does not break. Pass service_code
only if you want to choose explicitly.
character_id decides who can maintain the article afterwards. This matters, so pick deliberately:
| Result | |
|---|---|
With character_id | The article belongs to that player. It shows up under their own articles in the CAD, and they can edit or retract it there — as well as you, through this API |
| Without | The byline is just author_name; there is no owner. Nobody can touch the article from inside the CAD. This API is the only way to change or retract it |
Neither is wrong — a bot-written column wants no owner, a player's piece filed through your own tooling does. Just know that the second one is a one-way street away from the CAD's own editing tools.
If you pass character_id, it must belong to your community and not be archived. Get one from
/characters.
Unlike the CAD, this endpoint does not require anyone to be on duty as a news provider. That rule exists in the CAD's own interface only.
Returns 201 with the created article, in the same shape as a GET /news-articles row.
Example
curl -X POST https://api.lux-cad.com/api/v1/news-articles \
-H "x-lux-api-key: $LUX_API_KEY" \
-H "content-type: application/json" \
-d '{
"title": "City Council Approves Overnight Parking Ban",
"subtitle": "Measure passes six votes to three",
"body": "The council chamber ran late into the evening …",
"author_name": "R. Beland",
"image_aspect": "landscape"
}'{
"article_id": "182c5611-3ef6-4a50-97c1-fe38a6525f65",
"service_code": "NEWS",
"service_label": "News / Media",
"author_name": "R. Beland",
"title": "City Council Approves Overnight Parking Ban",
"subtitle": "Measure passes six votes to three",
"body": "The council chamber ran late into the evening …",
"image_url": null,
"image_aspect": "landscape",
"vote_count": 0,
"status": "PUBLISHED",
"published_at": "2026-07-31T09:14:02.317849+00:00",
"archived_at": null
}PATCH/news-articlesEdit an article you already published. Partial update: send only the fields you want to change; the rest keeps its current value.expand_more
Edit an article you already published. Partial update: send only the fields you want to change; the rest keeps its current value.
| Parameter | Type | Notes |
|---|---|---|
article_id | uuid (required) | Query parameter, not a body field. From the POST response or a GET |
Body: the same fields as POST, all optional, except service_code and character_id — those are
fixed at publication and cannot be changed.
Clearing an optional field: send an empty string. {"subtitle": ""} removes the subtitle; leaving
subtitle out entirely keeps it. title, body and author_name cannot be emptied — they are what
makes an article an article.
Only PUBLISHED articles can be edited. Once an article ages out into ARCHIVED it is out of the
paper and stays as it was; editing it would rewrite history. You get a 409 telling you the actual
status. You can still DELETE it.
Scope: your key can edit any article in your community, including ones written by players in the CAD. The key is a community-level credential; treat it accordingly.
Returns 200 with the article as it now stands.
Example
curl -X PATCH "https://api.lux-cad.com/api/v1/news-articles?article_id=182c5611-3ef6-4a50-97c1-fe38a6525f65" \
-H "x-lux-api-key: $LUX_API_KEY" \
-H "content-type: application/json" \
-d '{ "title": "Council Approves Overnight Parking Ban — Effective Monday", "subtitle": "" }'DEL/news-articlesRetract an article. It disappears from the CAD's paper and from GET /news-articles immediately.expand_more
Retract an article. It disappears from the CAD's paper and from GET /news-articles immediately.
| Parameter | Type | Notes |
|---|---|---|
article_id | uuid (required) | Query parameter |
This is a retraction, not an erasure. The row stays in the database with status RETRACTED, which is
how the CAD has always handled a pulled article — and it means reader votes are not silently destroyed
along with it. From your side there is no difference: no read endpoint will ever return it again.
It is idempotent. Retracting an already-retracted article returns 200 with the article, not an
error, so a retry after a timeout is safe.
Works on PUBLISHED and ARCHIVED articles alike, and — like PATCH — on any article in your
community.
Returns 200 with the article, status now RETRACTED.
Example
curl -X DELETE "https://api.lux-cad.com/api/v1/news-articles?article_id=182c5611-3ef6-4a50-97c1-fe38a6525f65" \ -H "x-lux-api-key: $LUX_API_KEY"
{
"article_id": "182c5611-3ef6-4a50-97c1-fe38a6525f65",
"service_code": "NEWS",
"service_label": "News / Media",
"author_name": "R. Beland",
"title": "Council Approves Overnight Parking Ban — Effective Monday",
"subtitle": null,
"body": "The council chamber ran late into the evening …",
"image_url": null,
"image_aspect": "landscape",
"vote_count": 0,
"status": "RETRACTED",
"published_at": "2026-07-31T09:14:02.317849+00:00",
"archived_at": null
}Worked example
A small dashboard: fetch the status list once, then poll units every 30 seconds and render them with the right colour.
const BASE = "https://api.lux-cad.com/api/v1";
const headers = { "x-lux-api-key": process.env.LUX_API_KEY };
async function get(resource) {
const res = await fetch(`${BASE}/${resource}`, { headers });
if (res.status === 429) {
throw new Error("Rate limited — slow down your polling.");
}
if (!res.ok) {
const { error } = await res.json().catch(() => ({}));
throw new Error(`${resource} failed (${res.status}): ${error ?? "unknown error"}`);
}
return res.json();
}
// Configuration: fetch once, cache for the lifetime of the process.
const statuses = new Map((await get("unit-statuses")).map((s) => [s.code, s]));
async function refresh() {
const units = await get("active-units");
for (const unit of units) {
const status = statuses.get(unit.status_code);
console.log(
`${unit.callsign.padEnd(14)} ${status?.label ?? unit.status_code}`,
status?.color ?? "#888888",
unit.current_incident_id ? "(assigned)" : ""
);
}
}
setInterval(refresh, 30_000); // 2 req/min — the recommended intervalSupport
Questions, a field you need that isn't here, or a write operation you'd like to see: get in touch through the LUX CAD Discord. Requests for new read fields are usually easy — we'd rather extend an existing endpoint than have you make five calls to assemble one screen.