Skip to content

API reference

tCAD's seventeen server-side exports: the return shape, the eleven failure codes, every read and mutation, report911, and why there is no export over civilian, warrant or NCIC data.

Seventeen server-side exports. There are no client-side exports, deliberately — see the end of this page.

These names are a compatibility promise. A renamed export breaks a consumer silently, because FiveM's export namespace has no deprecation mechanism, so a name here changes only with a major version.

lua
if not exports.tCAD:isReady().ready then return end

local board = exports.tCAD:getOpenCalls()
if board.ok then
    for _, call in ipairs(board.calls) do
        -- …
    end
end

The three rules this surface is built on

An export called from another resource has no source. Every authority decision in tCAD is a function of source, so the permission model as built does not apply to an export at all. Three rules take its place.

Rule 1 — a read returns at most what an open method returns

Every read export maps onto a method that needs nothing beyond tcad.use, or is derived from one. So an export discloses nothing a holder of tcad.use could not already read.

Rule 2 — a mutation takes a player, and goes through the real gate

Every mutation takes the acting player's server id as its first argument. That player is the actor: the surface resolves them, checks tcad.use, then checks the permission the registry declares for the operation it is about to reach, read live. So a permission you tighten tightens here on the next boot, with no change to the export surface — and the action is attributable: the call's creator and its timeline entry name a person.

Rule 3 — there is no generic passthrough

exports.tCAD:rpc(method, payload) was considered and rejected. It would re-expose every registry operation with no authority at all, including the catalogue writes — the highest-value target in the resource, because an agency's agencyType decides which view every member of it gets. A passthrough also publishes every internal operation name as API, so renaming one breaks a consumer; the fixed map breaks at boot, by name, instead.

Return shape

Every export except isReady returns a table. Never nil, never a bare boolean, and it never raises.

lua
{ ok = true,  … }                        -- the answer
{ ok = false, code = "…", error = "…" }  -- a documented failure

The code is the API — branch on it. The error string is for a human reading a log and may be reworded between releases.

codeMeansWhat to do
startingthe storage backend has not finished bindingRetry. This is not "there is nothing" — measured, a database resource reads as started about 2.5 s before it can answer
unknown_playerthat server id is not a connected playerThe id is stale; re-resolve it
no_identitythe player has no licence identifierNothing can be attributed to them. Treat as a hard refusal
deniedthe acting player lacks tcad.use or the operation's own permission. error names the ACE keyA grant is missing in server.cfg. The key is not a secret
refusedthe CAD's own domain rule said no. The message is forwarded verbatimShow it; it is written to be read by a person
ambiguousthe unit is on duty in more than one department and none was namedPass a departmentId
notfoundthe thing does not exist—
bad_requestan argument was the wrong type or emptyFix the call
rate_limitedreport911 onlyBack off; error says for how long
disabledreport911 only, switched off—
internala bug in tCAD. The real fault is in the server consoleReport it

Why a table rather than table|nil: a bare empty result from getOpenCalls during the async bind window says "no calls", which a consumer cannot tell apart from "not ready" — and those are different answers. Multiple return values would also not survive every export boundary, losing the error exactly where it is needed.


Reads

isReady()

lua
-> { ok = true, ready = boolean }

The one export that cannot fail. ready is the question every other export's starting code exists to answer. A consumer's first line.

getCadStatus()

lua
-> { ok = true, ready, backend, version, allow911 }

backend is "json" or "sql", or nil while starting.

isOnDuty(playerId)

lua
-> { ok = true, onDuty = boolean }

The cheapest question. onDuty = false is a successful answer; an unknown server id is unknown_player, not false.

getUnit(playerId)

lua
-> { ok = true, onDuty, units = { unit, … } }

Plural by construction. A unit is a player and a department, so a player on duty in two departments is two units with two callsigns and two statuses.

A unit is an allowlist, built field by field:

playerId  departmentId  departmentName  agencyType  name  callsign
statusCode  statusLabel  statusColor  lat  lng  isLive  lastSeenAt
  • No licence identifier. playerId is given instead, because a consumer resource speaks server ids.
  • playerId is nil for a unit whose player disconnected without going off duty. The row is kept, not filtered — a dispatcher is still looking at it.
  • ⛔ lat is the GTA world Y and lng the world X, in world units (metres), not degrees. A value in the wrong unit is silent at every layer: it is a marker in the sea and nothing raises.

getUnitNote(playerId)

lua
-> { ok = true, note = string|nil }

One line of English: callsign, status label, and the number of the call the unit is on. No description, no address, no caller phone, nothing civilian.

This is Rule 1's narrowing in action. A consumer that needs a sentence to read out on the radio gets a sentence — satisfying it by exporting records and letting the consumer format them would put call content, and eventually civilian content, into another resource for the sake of one line.

getOnDutyUnits(departmentId?)

lua
-> { ok = true, units = { unit, … } }

Everyone on duty, optionally scoped to one department. Same projection as getUnit.

getOpenCalls()

lua
-> { ok = true, calls = { call, … } }

The open board, exactly as the CAD's own list operation returns it — which is open, so every holder of tcad.use already has precisely this. Forwarded rather than re-projected on purpose: a second call shape would drift from the first the moment the board gains a field.

getCall(callId)

lua
-> { ok = true, call = { … } }   -- with its units and timeline

notfound if it does not exist; bad_request if callId is not a number — the caller made a different mistake and gets told which.

getDepartments()

lua
-> { ok = true, departments = { { id, agencyId, name, color, agencyType }, … } }

Needed because every mutation takes a departmentId.

getStatusCodes(departmentId?)

lua
-- with an id
-> { ok = true, departmentId, codes = { … } }
-- without
-> { ok = true, byDepartment = { ["3"] = { … }, … } }

Needed to use setUnitStatus at all: it takes a status code id validated against the live catalogue.


Mutations

Each takes the acting player's server id first and reaches one hardcoded operation. None takes an operation name, a permission, or an actor key.

ExportPermission required of the actor
createCall(playerId, draft)tcad.calls.create
closeCall(playerId, callId, dispositionId?)tcad.calls.close
addCallComment(playerId, callId, text)open beyond tcad.use
attachUnit(playerId, callId, targetPlayerId?, departmentId?)tcad.dispatch.self for self, tcad.dispatch.unit for another unit
detachUnit(playerId, callId, targetPlayerId?, departmentId?)open for self, tcad.dispatch.unit for another unit
setUnitStatus(playerId, statusCodeId, departmentId?)open; authority is by state — the actor must be on duty in that department

All return { ok = true, data = … }, where data is the operation's own success payload, unchanged. Every one also fans out to every open board, exactly as the in-game path does — so a call created through an export appears on every dispatcher's screen.

createCall(playerId, draft)

lua
draft = { callType, description, address, callerPhone,
          priorityId, lat, lng, departmentId }

callType is required. Text is sanitised and capped.

attachUnit / detachUnit

targetPlayerId nil, or equal to playerId, is the self form, which passes no target at all. That is the whole difference between the two, and it is what stops a holder of tcad.dispatch.self naming somebody else.

departmentId is required by both backends, so the surface resolves it from the target's own duty row when it is omitted.

setUnitStatus


⛔ report911 — the one system-level action

lua
exports.tCAD:report911(callerPlayerId, {
    message, address?, callerNumber?, lat?, lng?
})
-> { ok = true, data = { id, callNumber, … } }

The only export that does not check a permission, and the reason narrow-and-named is a design rather than a loophole.

Why it cannot go through the gate. A civilian dialling 911 holds neither tcad.use nor tcad.calls.create. Granting either to group.user to make 911 work would hand every player on the server the whole CAD and the ability to raise arbitrary calls. So the permission model here is not merely inapplicable — satisfying it would be strictly worse than bypassing it. A real CAD accepts calls from anyone; that is what 911 is.

What bounds it instead:

  • one hardcoded operation. It cannot name another.
  • one fixed payload, built from four sanitised inputs, with callType forced to 911. ⛔ A priority is not accepted — a caller does not triage their own call, so the least-severe default applies.
  • still attributable. The actor is the caller: a connected player with a licence identifier, so the call names a person. An unidentifiable or disconnected caller is refused, not substituted — a server id is a reused connection slot, and a call keyed by one would be attributed to whoever connects next.
  • rate limited per player (10 s) and globally (30 per minute). The global cap is the one that matters: any resource on the box can reach this, and a per-player cooldown does nothing against a loop over every slot.
  • killable — tcad_exports 911 off in the server console, or Config.exports.allow911 = false.

Wiring it to a radio or phone resource's own no-dispatcher fallback:

lua
-- in the other resource's config
Config.call911NoDispatcherFallback = function(call)
    exports.tCAD:report911(call.source, {
        message      = call.message,
        address      = call.location,
        callerNumber = call.caller,
        lat = call.coords and call.coords.y,   -- lat is world Y
        lng = call.coords and call.coords.x,   -- lng is world X
    })
end

tcad_exports — the console diagnostic

tcad_exports            # what is published, the backend, and the 911 counters
tcad_exports 911 off    # kill the 911 path at runtime
tcad_exports 911 on

The counters exist because the question an operator actually has about an export surface is not "does it work" but "is the thing calling it being refused, and why". A consumer whose 911 integration is silently rate limited looks identical to one that was never wired up.

Load order

The export surface registers last, after everything else in the resource.

  • After the operation registry is a requirement. The structural check reads the registry at file scope. Loaded earlier, every operation name resolves to nil and the surface registers nothing.
  • After the main server file is a preference. On a server with no database resource the backend binds synchronously during load, so registering afterwards means the surface answers ready from its first call rather than starting to a consumer that asked during startup.

Why there is no client surface

A client export was considered and is not written. Everything a sibling resource wants to know about the CAD is server state: duty, calls, units, status codes. tCAD's client half owns NUI focus and the keybind and holds no authoritative data — so a client export could only answer "is the panel open", which nobody has asked for and which would be a second published surface to keep compatible for ever. A resource that genuinely needs that can ask the server, which is the transport tCAD already has.

Was this page helpful?