Skip to content
menu_bookDocumentation menu
search

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: GET almost everywhere. Six endpoints also accept writes (POST, and on some PATCH or DELETE) — see Writing. Every other endpoint is read-only

Authentication

Send your API key in the x-lux-api-key header on every request.

cURL
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.

warning

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:

HeaderMeaning
X-RateLimit-LimitYour ceiling (200)
X-RateLimit-RemainingRequests left in the current window

Over the limit you get 429 and no data:

JSON
{ "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:

Text
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": "..." }.

StatusBodyWhat it means
400Missing required query parameter: … / Invalid …An endpoint that needs a lookup parameter didn't get a valid one
400Provide exactly one of: …An endpoint with several lookup parameters got none, or more than one
400Unknown 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
400Field … 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
400Missing required field: … / Field … cannot be blank.A required write field was absent or empty
400Provide both x and y, or neither.Half a coordinate pair on POST /incidents. We refuse rather than silently create a call with no location
400title, 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
400Unknown incident_type for this community: …That short code isn't configured here. Read /incident-types
400incident_type … is reserved for the CAD …PANIC and TRAFFIC_STOP are created by the CAD itself
400call_prefix … is reserved by the CAD. / call_prefix must be 2-6 letters or digits.Pick a different prefix for your integration
400Unknown type for this community: …That report type isn't configured here. Read /report-types
400Report 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
400character_id is required for report type …The type has requires_subject: true and you sent no subject
400Report type … does not support charges.The type has supports_charges: false
400Unknown penal_code for this community: …That code isn't in your penal code. Codes are case-sensitive
400charge_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
400created_at cannot be in the future.Reports can be back-dated, not post-dated
401Missing x-lux-api-key header.You didn't send the header
401Invalid or revoked API key.Unknown, malformed, or revoked key — or the community is not active
404Unknown endpoint.No such resource (check spelling; only the endpoints below exist)
404Article 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
404Report not found.Same rule as above, for PATCH /reports
409Only PUBLISHED articles can be edited; this one is ARCHIVED.The article is out of the paper. You can still DELETE it
413Request body too large.A write body over 128 KB
429Rate limit exceeded.See Rate limiting
502Upstream error. / Upstream unavailable.Something failed on our side. Safe to retry a read; for a write, see the warning under Writing first
503API 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

Pick an endpoint to see its fields, ordering and a worked example.
GET/unit-statusesexpand_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.

FieldTypeNotes
codestringThe 10-code, e.g. 10-8. Unique within your community
labelstringHuman-readable name, e.g. Available
colorstringHex colour, e.g. #22c55e
sort_orderintegerDisplay position
rolesstring[]Departments this status applies to. An empty array means "all departments", not "none"
is_basebooleantrue for the built-in statuses every community has; false for ones your admins added

Example

cURL
curl https://api.lux-cad.com/api/v1/unit-statuses \
  -H "x-lux-api-key: $LUX_API_KEY"
JSON
[
  { "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-unitsexpand_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.

FieldTypeNotes
callsignstringThe unit's callsign, e.g. 1-ADAM-12
departmentstringBroad bucket: LEO, SAFR (fire/EMS), or DISPATCH
agencystring | nullThe specific agency within the department, e.g. BCSO, LSPD. Not every community configures agencies
special_unit_codestring | nullSpecial-unit designation (K9, air, SWAT, …) if the unit has one
status_codestringThe raw 10-code the unit is on, e.g. 10-8
status_labelstring | nullHuman-readable label for that code, e.g. Available. null if the code isn't in the community's status list
status_colorstring | nullHex colour configured for that status, e.g. #22c55e. null under the same condition as status_label
current_incident_iduuid | nullThe incident the unit is assigned to, or null if unassigned
sincetimestampWhen the unit went on duty
info

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.

warning

Live GPS positions are deliberately not exposed. Unit coordinates are not part of this API and won't be added to it.

Example

cURL
curl https://api.lux-cad.com/api/v1/active-units \
  -H "x-lux-api-key: $LUX_API_KEY"
JSON
[
  {
    "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-membersexpand_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.

FieldTypeNotes
discord_idstringDiscord 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_namestring | nullThe member's display name in the CAD
unit_callsignstringCallsign of the unit this member is staffing, e.g. 1-ADAM-12
departmentstringLEO, SAFR (fire/EMS), or DISPATCH
agencystring | nullSpecific agency within the department, e.g. BCSO. Not every community configures agencies
special_unit_codestring | nullSpecial-unit designation (K9, air, SWAT, …) if the unit has one
status_codestringThe unit's current 10-code, e.g. 10-8
status_labelstring | nullHuman-readable label for that code. null if the code isn't in the community's status list
status_colorstring | nullHex colour for that status. null under the same condition as status_label
current_incident_iduuid | nullThe incident the unit is assigned to, or null if unassigned
joined_attimestampWhen this member went on duty in this unit
info

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.

warning

Live GPS positions are deliberately not exposed here either — unit coordinates are not part of this API.

Example

cURL
curl https://api.lux-cad.com/api/v1/active-unit-members \
  -H "x-lux-api-key: $LUX_API_KEY"
JSON
[
  {
    "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/pursuitsexpand_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.

FieldTypeNotes
pursuit_iduuidStable identifier for this pursuit
incident_iduuid | nullThe incident it belongs to
statusstringAlways ACTIVE for now — a paused pursuit is still active (see paused)
primary_callsignstring | nullThe primary unit. null if that unit has since gone off duty
backup_callsignsstring[]Callsigns of the units currently backing up, excluding the primary. Empty array if none
pausedbooleantrue if a dispatcher has paused the pursuit. Paused pursuits stay in this list
started_attimestampWhen the pursuit began
active_secondsintegerRunning time in seconds, excluding paused time. Stops climbing while paused is true
lightbulb

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
curl https://api.lux-cad.com/api/v1/pursuits \
  -H "x-lux-api-key: $LUX_API_KEY"
JSON
[
  {
    "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:

JSON
[]

GET/incident-typesexpand_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.

FieldTypeNotes
codestringThe short code. This is what you send as incident_type
labelstringHuman-readable name, as dispatchers see it
is_systembooleantrue for types the CAD manages itself. These cannot be used with POST /incidents — see below
default_departmentsstring[]Which services this type dispatches to (LEO, SAFR, DISPATCH). Empty if the community never configured any
default_sub_departmentsstring[]Community-defined subdivisions (e.g. FIRE, EMS). Often empty
default_priorityinteger | null1 (highest) to 3. null means the community left it unset, and a new incident of this type gets priority 3
sort_orderintegerDisplay order in the CAD
warning

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
curl https://api.lux-cad.com/api/v1/incident-types \
  -H "x-lux-api-key: $LUX_API_KEY"
JSON
[
  {
    "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-openexpand_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.

lightbulb

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
curl https://api.lux-cad.com/api/v1/incidents-open \
  -H "x-lux-api-key: $LUX_API_KEY"
JSON
[
  {
    "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/incidentsexpand_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.

ParameterTypeNotes
incident_iduuid (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.

FieldTypeNotes
incident_iduuidStable identifier. This is the key — see the note under call_number
call_numberstringThe reference dispatchers read out, e.g. #911-7287. A label, not a key: it is not unique and the CAD tolerates duplicates
incident_typestringShort code, matching /incident-types
incident_type_labelstring | nullHuman-readable name. null if the type has since been removed from the community's configuration
titlestringShort summary line
descriptionstring | nullThe narrative. Dispatchers edit this as the call develops
priorityinteger1 (highest) to 3
statusstringPENDING, ACTIVE or CLOSED
departmentsstring[]Which services are on it (LEO, SAFR, DISPATCH). May be empty
sub_departmentsstring[]Community-defined subdivisions. Often empty
location_textstringDisplay location. Usually Postal <code>; falls back to GPS <x>, <y> or Location unknown
addressstring | nullStreet name, when known
created_attimestampWhen the incident was created
updated_attimestampLast change of any kind
info

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
curl "https://api.lux-cad.com/api/v1/incidents?incident_id=c4db0b05-d36a-4e7e-a03d-bfc85545921a" \
  -H "x-lux-api-key: $LUX_API_KEY"
JSON
[
  {
    "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/incidentsexpand_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.

FieldTypeNotes
incident_typestring (required)A code from /incident-types. Case-insensitive. STANDARD = "not typed yet" and is always accepted
titlestring (required)Short summary line. Max 200 characters. Cannot be blank
descriptionstringThe narrative. Max 10 000. Left off, it becomes No initial narrative logged.
xnumberGame world coordinate. Send x and y together or not at all
ynumberGame world coordinate
postalstringPostal code. Leave it off and we derive it from x/y — see below
streetstringStreet name; becomes address. Also derived from x/y when omitted
reporting_party_namestringWho called it in. Max 120
callback_numberstringMax 40
weapons_involvedstringFree text. Max 200
character_iduuidAttach 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_prefixstring2–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
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"
      }'
JSON
{
  "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-logsexpand_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.

FieldTypeRequiredNotes
incident_iduuidyesFrom POST /incidents or GET /incidents-open
contentstringyes1-2000 characters
authorstringone ofUp to 60 characters. Written as AUTHOR: your text — use a callsign or a service name
systembooleanone oftrue 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.

priority_high

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
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
  }'
JSON
{
  "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-typesexpand_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 /reports with a turned-off type is a 400, with a message that says so rather than "unknown type". The type still exists; it is just closed for new reports.
  • GET /reports is unaffected. Reports already filed under that type keep coming back, with their type_label intact. Turning a type off closes the door; it does not rewrite history.

Order: by sort_order, then code.

FieldTypeNotes
codestringThe short code. This is what you send as type
labelstringHuman-readable name, as officers see it
is_systembooleantrue for the types every community starts with. Unlike incident types, these can be used with POST /reports
sort_orderintegerDisplay order in the CAD
supports_chargesbooleanfalse means this type carries no charges at all. Sending charges for it is a 400
requires_subjectbooleanfalse means the report is about an event rather than a person, so character_id may be left off
departmentsstring[]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_departmentsstring[]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
curl https://api.lux-cad.com/api/v1/report-types \
  -H "x-lux-api-key: $LUX_API_KEY"
JSON
[
  {
    "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/reportsexpand_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.

ParameterTypeNotes
report_iduuidOne report. The id you got back from POST /reports
character_iduuidEvery report this character is on, as main subject or as a co-suspect
discord_idstringEvery report any character of that Discord user is on, within your community
since_numberintegerCursor: return only reports with a report_number greater than this. Exclusive
limitintegerDefault 50, maximum 200. Larger values are clamped rather than refused

Order: by report_number, ascending. Always — this is also what makes the cursor work.

warning

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.

FieldTypeNotes
report_iduuidStable identifier. This is the handle for PATCH
report_numberintegerSequential within your community, from 1 up. The cursor for since_number
typestringShort code, matching /report-types
type_labelstringHuman-readable name. Falls back to the code itself if the type was removed from the community's configuration
statusstringAlways PUBLISHED on this endpoint
titlestring | nullShort summary line. Often absent on older reports
narrativestring | nullThe full narrative as it appears on the paper. Each contribution is headed by [author — timestamp]; several officers can add to one report
issuing_officerstring | nullFree text — the callsign or name printed on the paper. Not a link to a CAD account
location_textstring | nullDisplay location, e.g. Postal 181
addressstring | nullStreet name, when known
court_datedate | nullThe 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_iduuid | nullThe main subject. null on a report about an event rather than a person
suspect_idsuuid[]Everyone named on the report, main subject first. Empty only when the report names nobody at all — see the note below
incident_idsuuid[]Every incident this report is linked to, ready for /incidents. May be empty; a report can carry more than one
total_fineintegerSum over the charges of fine × charge_count
total_jail_daysintegerSum over the charges of jail_days × charge_count, in days
chargesobject[]See below. Empty array when there are none — never null
created_attimestampWhen the report was filed. May be back-dated by an integration
updated_attimestampLast change of any kind
info

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:

FieldTypeNotes
penal_codestringThe code as it appears in your community's penal code
titlestring | nullThe offence name. null if the code has since been removed from the penal code — the charge itself stays on the record
classificationstring | nullFELONY, MISDEMEANOR, INFRACTION, or null. Same caveat as title
charge_countinteger1–99. "3 counts of robbery" is one entry with charge_count: 3
fineintegerPer count
jail_daysintegerPer count, in days
fine_totalintegerfine × charge_count
jail_days_totalintegerjail_days × charge_count
verdictstring | nullThe current verdict code, e.g. CONVICTED. null means no verdict has been recorded yet
verdict_labelstring | nullHuman-readable name. Falls back to the code itself if the verdict was removed from the community's configuration. null alongside a null verdict
verdict_attimestamp | nullWhen the current verdict was recorded

Charges are ordered by penal_code.

priority_high

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.

warning

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.

info

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
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"
JSON
[
  {
    "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:

Shell
GET /reports?character_id=<id>&limit=200
GET /reports?character_id=<id>&limit=200&since_number=<highest report_number you saw>

POST/reportsexpand_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.

FieldTypeNotes
typestring (required)A code from /report-types. Case-insensitive
character_iduuidThe main subject. Required unless the type has requires_subject: false. Must belong to your community and not be archived
suspect_idsuuid[]Co-suspects, in the order you want them on the paper. Max 50. character_id is always put first; duplicates are folded
titlestringShort summary line. Max 200
narrativestringThe story. Max 100 000
issuing_officerstringFree text: the callsign or name printed on the paper. Max 120
location_textstringDisplay location, e.g. Postal 181. Max 200
addressstringStreet name. Max 200
court_datedateYYYY-MM-DD, no time. Leave it out to get the default. A 400 if the type carries no court date
chargesobject[]Max 100. Each entry: penal_code (required) and optionally charge_count (1–99, default 1)
created_attimestampBack-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.

info

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
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" }
        ]
      }'
JSON
{
  "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/reportsexpand_more

Update a report you filed earlier. Fields you leave out are untouched.

ParameterTypeNotes
report_iduuid (required)The report to update

Body: JSON object. Unknown fields are rejected.

FieldTypeNotes
titlestring"" clears it
typestringA code from /report-types. Cannot be blank
narrativestringRewrites your contribution. See below. "" removes it
issuing_officerstring"" clears it
location_textstring"" clears it
addressstring"" clears it
court_datedateYYYY-MM-DD, no time. "" clears it. A 400 if the type carries no court date
verdictstringA verdict code for your community, e.g. CONVICTED. Applies to every charge on the report. Case-insensitive. See below
character_iduuidThe main subject. Added to suspect_ids if it isn't there yet
suspect_idsuuid[]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
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-datesexpand_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.

ParameterTypeNotes
fromdate (required)YYYY-MM-DD. Hearings on or after this date
todateYYYY-MM-DD. Hearings on or before this date. Leave it out for everything from from onwards
limitintegerDefault 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.

FieldTypeNotes
report_iduuidThe report the hearing belongs to. Feed it to /reports for the full record
report_numberintegerSequential within your community
typestringShort code, matching /report-types
type_labelstringHuman-readable name. Falls back to the code if the type was removed from the configuration
titlestring | nullShort summary line. Often absent
court_datedateThe hearing, as YYYY-MM-DD
character_iduuid | nullThe main subject
suspect_idsuuid[]Everyone named on the report, main subject first. May be empty
issuing_officerstring | nullFree text — the callsign or name printed on the paper
open_chargesintegerHow many charges still have no final verdict. See below
created_attimestampWhen 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
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"
JSON
[
  {
    "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:

Shell
GET /court-dates?from=2026-08-23&to=2026-08-23

GET/warrants-activeexpand_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
curl "https://api.lux-cad.com/api/v1/warrants-active" \
  -H "x-lux-api-key: $LUX_API_KEY"

GET/warrantsexpand_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.

ParameterTypeNotes
warrant_iduuidOne warrant
character_iduuidEvery warrant on this character
discord_idstringEvery warrant on any character of that Discord user, within your community
since_numberintegerCursor: return only warrants with a warrant_number greater than this. Exclusive
limitintegerDefault 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.

FieldTypeNotes
warrant_iduuidStable identifier
warrant_numberintegerSequential within your community, from 1 up. The CAD shows it as WRT-00042. The cursor for since_number
warrant_typestringARREST, SEARCH or BENCH
statusstringACTIVE, SERVED, RECALLED or EXPIRED. See below
reasonstringThe grounds for the warrant, as written by the issuer. Never empty
issued_bystringFree text — the callsign printed on the warrant, e.g. BCSO 904. Not a link to a CAD account
self_reportedbooleantrue 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_idstring | nullDiscord user ID of the officer who issued it. See the note below — null for every warrant issued before 2026-10-29
character_iduuidThe person the warrant is for, ready for /characters
discord_idstring | nullDiscord user ID of the player who owns that character — the same key /characters uses
first_namestringThe character's first name
middle_namestring | nullOptional; most characters have none
last_namestringThe character's last name
issued_attimestampWhen the warrant took effect. Can lie before created_at when the issuer back-dated it
expires_attimestamp | nullWhen it lapses. null means it does not expire
created_attimestampWhen the warrant was entered into the CAD
incident_iduuid | nullThe incident it was issued from, ready for /incidents. null for a warrant issued on its own
call_numberstring | nullThat incident's call number, as printed on the warrant. null without an incident
source_report_iduuid | nullThe report the warrant was issued from, ready for /reports
report_idsuuid[]Reports filed because of this warrant — typically the arrest report written when it was served. Empty array when there are none, never null
total_fineintegerSum over the charges of fine × charge_count
total_jail_daysintegerSum over the charges of jail_days × charge_count, in days
chargesobject[]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.

info

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.

info

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:

FieldTypeNotes
penal_codestringThe code as it appears in your community's penal code
titlestring | nullThe 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
classificationstring | nullFELONY, MISDEMEANOR, INFRACTION, or null when none was recorded
charge_countinteger1–99. "3 counts of robbery" is one entry with charge_count: 3
fineintegerPer count
jail_daysintegerPer count, in days
fine_totalintegerfine × charge_count
jail_days_totalintegerjail_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.

warning

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
curl "https://api.lux-cad.com/api/v1/warrants?discord_id=285181941777694720&limit=1" \
  -H "x-lux-api-key: $LUX_API_KEY"
JSON
[
  {
    "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/charactersexpand_more

The person-lookup endpoint. Two ways in — send exactly one:

ParameterTypeNotes
discord_idstringA Discord user ID (numeric). Returns every character that user has in your community
character_iduuidOne 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.

FieldTypeNotes
character_iduuidStable identifier for this character. This is what owner_character_id on /vehicles points at
discord_idstringDiscord user ID of the player who owns this character — the same key /active-unit-members uses
first_namestringNever null
middle_namestring | nullOptional. 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_namestringNever null
dobdateDate of birth, YYYY-MM-DD. Never null
genderstringFree text as entered in the CAD. Never null
statusstring | nullCLEAN, 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
jobstring | nullThe character's occupation, if set
phonestring | nullThe character's phone number. null if the character has hidden it, or has none
addressstring | null
birth_placestring | null
avatar_urlstring | nullURL of the character's photo, if set
created_attimestamp | nullWhen 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:

JavaScript
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.

warning

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
curl "https://api.lux-cad.com/api/v1/characters?discord_id=285181941777694720" \
  -H "x-lux-api-key: $LUX_API_KEY"
JSON
[
  {
    "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/charactersexpand_more

Create a character for a player who already has a LUX CAD account.

priority_high

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.

FieldTypeRequiredNotes
discord_idstringyesNumeric Discord user ID
first_namestringyes1-50 characters. Letters, spaces, apostrophes, periods and hyphens; accents are fine, digits are not
last_namestringyesSame rules as first_name
dobdateyesYYYY-MM-DD. The character must be between 16 and 100 years old
genderstringyesFree text, 1-30 characters
middle_namestringnoSame rules as the other names. Often just an initial like P.
jobstringnoUp to 100 characters
addressstringnoUp to 120 characters
birth_placestringnoUp to 80 characters
avatar_urlstringnoUp 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.

info

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
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/charactersexpand_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).

FieldTypeNotes
first_namestringSame rules as on create
middle_namestringEmpty string clears it
last_namestring
dobdateYYYY-MM-DD, still 16-100 years old
genderstring
jobstringEmpty string clears it
addressstringEmpty string clears it
birth_placestringEmpty string clears it
avatar_urlstringCannot be cleared — every character keeps a photo
archivedbooleantrue hides the character from all default lookups; false restores it
deceasedbooleantrue 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.

info

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.

warning

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
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/vehiclesexpand_more

Vehicle lookup. Two ways in — send exactly one:

ParameterTypeNotes
platestringA licence plate. Matching ignores case, spaces and dashes, so 1ABC-234, 1abc234 and 1 ABC 234 all find the same vehicle
discord_idstringA 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.

FieldTypeNotes
vehicle_iduuidThe handle for PATCH and DELETE. Use this, never the plate — plates are not unique
platestringThe plate exactly as it is stored, including any spaces and original casing. Your search term is normalised; this value is not
makestring | nullFree text as entered by the player, so casing varies
modelstringFree text. Never null
yearinteger | null
colorstringFree text, e.g. Wine Red. Never null
typestring | nullBody type, e.g. SEDAN, SUV, COUPE
statusstring | nullACTIVE, STOLEN or IMPOUNDED. null means no status recorded — treat it as ACTIVE at your own discretion, don't assume
insurance_statusstring | nullVALID, EXPIRED or NONE. See the warning below
insurance_companystring | null
insurance_expiresdate | nullYYYY-MM-DD
owner_kindstringPLAYER or UNLINKED. Branch on this, never on whether owner_character_id is null — see the note below
owner_character_iduuid | nullThe 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_namestring | nullThe 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_attimestamp | nullWhen 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_attimestamp | nullWhen the vehicle was registered
priority_high

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.

warning

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().

info

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
curl "https://api.lux-cad.com/api/v1/vehicles?plate=bert-xl" \
  -H "x-lux-api-key: $LUX_API_KEY"
JSON
[
  {
    "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):

JavaScript
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/vehiclesexpand_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.

FieldTypeRequiredNotes
platestringyesUp to 8 characters after trimming. Letters, digits and spaces
modelstringyes1-60 characters
colorstringyes1-30 characters
character_iduuidone ofA character in your community. Get one from /characters
owner_namestringone ofUp to 120 characters. Registers the vehicle without a citizen record
makestringnoUp to 60 characters
typestringnoUp to 30 characters
yearintegernoA JSON number, not a string
statusstringnoACTIVE (default), STOLEN or IMPOUNDED
insurance_statusstringnoVALID, EXPIRED or NONE (default)
insurance_companystringnoUp to 60 characters
insurance_expiresdatenoYYYY-MM-DD
expires_in_hoursintegernoOnly 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.

priority_high

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.

info

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 PATCH on 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
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
  }'
JSON
{
  "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/vehiclesexpand_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).

FieldTypeNotes
statusstringACTIVE, STOLEN or IMPOUNDED
colorstring
modelstring
makestringEmpty string clears it
typestringEmpty string clears it
yearinteger
owner_namestringOnly on a vehicle that has no character_id. 409 otherwise
insurance_statusstringVALID, EXPIRED or NONE
insurance_companystringEmpty string clears it
insurance_expiresdateYYYY-MM-DD
expires_in_hoursintegerOnly 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.

warning

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
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/vehiclesexpand_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:

SituationWhy
A BOLO references itDeleting would silently empty that BOLO's vehicle link. Remove the BOLO first
A report references itThe report would lose its vehicle without a trace
It has impound historyThe 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
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-typesexpand_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.

FieldTypeNotes
codestringStable 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
labelstringDisplay name, e.g. Tow / Impound
iconstringName of the Material Symbols icon the CAD shows, e.g. auto_towing
colorstringHex colour, e.g. #f59e0b
short_codestring | nullAbbreviation used in the CAD's on-duty counters, e.g. TOW, MECH. null if not configured
audiencesstring[]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_orderintegerDisplay position set by your admins. Lower comes first; several services can share a value
modulesstring[]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.

ModuleWhat it means in the CAD
presenceProviders can go on and off duty. Every service has it
tasksA job queue: requests that a provider accepts, works and completes
task_intakeCivilians can send a request directly to an on-duty provider
mapRequests carry a pickup (and optional destination) on the map
live_locationOn-duty providers are shown live on the map
road_statusProviders can mark road closures, roadworks, accidents and hazards on the map
routesProviders can build and drive routes with stops, such as a bus line or a tour
radioThe service takes part in the CAD radio
news_publishProviders publish articles to the public news feed (see /news-articles)
impoundOfficers can impound a vehicle, which queues a tow task for this service
legal_linkageCivilians can give a lawyer read access to their legal file during the shift

Example

cURL
curl https://api.lux-cad.com/api/v1/civilian-service-types \
  -H "x-lux-api-key: $LUX_API_KEY"
JSON
[
  {
    "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:

JavaScript
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-servicesexpand_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.

FieldTypeNotes
service_codestringStable code of the service, e.g. TOW, TAXI, MECHANIC, NEWS, LAWYER. Unique within your community. Admins can add their own
service_labelstringDisplay name, e.g. Tow / Impound
service_iconstringMaterial Symbols icon name the CAD uses, e.g. local_shipping
service_colorstringHex colour, e.g. #f59e0b
short_codestring | nullAbbreviation used in the CAD's on-duty counters, e.g. TOW, MECH. null if not configured
audiencesstring[]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_iduuidThe character running this shift. Feed it straight into /characters?character_id=… for the full profile
discord_idstring | nullDiscord 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_namestring | nullThe business the provider is trading under, if they entered one
contactstring | nullContact number the provider published for this shift, if any
started_attimestampWhen this provider went on duty
info

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
curl https://api.lux-cad.com/api/v1/civilian-services \
  -H "x-lux-api-key: $LUX_API_KEY"
JSON
[
  {
    "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:

JavaScript
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:

JavaScript
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-articlesexpand_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.

ParameterTypeNotes
sincetimestamp (optional)Return only articles published strictly after this moment. Leave it off to start from the very first article
limitinteger (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.

FieldTypeNotes
article_iduuidStable identifier. Use it to de-duplicate on your side
service_codestringCode of the service that published it, e.g. NEWS. A community can have more than one press outlet
service_labelstringDisplay name of that service, e.g. News / Media
author_namestringByline as written by the author. Never null
titlestringNever null
subtitlestring | null
bodystringThe full article text. Never null
image_urlstring | nullHeader image, if one was uploaded
image_aspectstring | nullThe 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_countintegerReader upvotes at the time of the call. Never null
statusstringPUBLISHED or ARCHIVED — see below
published_attimestampWhen it went live. This is the field since compares against, and the field to feed back in
archived_attimestamp | nullWhen it dropped out of the in-CAD paper. null while status is PUBLISHED
info

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
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"
warning

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.

JSON
[
  {
    "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:

JavaScript
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-articlesexpand_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.

FieldTypeNotes
titlestring (required)Max 300 characters. Cannot be blank
bodystring (required)The article text. Max 100 000 characters
author_namestring (required)The byline, as readers will see it. Max 120 characters. Free text — it does not have to match a character
subtitlestringMax 300
image_urlstringHeader image. Max 2048
image_aspectstringOne of panorama, landscape, square, portrait
service_codestringWhich news outlet publishes it. Leave it off unless your community has more than one — see below
character_iduuidAttribute 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_idThe 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
WithoutThe 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
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"
      }'
JSON
{
  "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-articlesexpand_more

Edit an article you already published. Partial update: send only the fields you want to change; the rest keeps its current value.

ParameterTypeNotes
article_iduuid (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
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-articlesexpand_more

Retract an article. It disappears from the CAD's paper and from GET /news-articles immediately.

ParameterTypeNotes
article_iduuid (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
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"
JSON
{
  "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.

JavaScript
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 interval

Support

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.