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.
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
endThe 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.
{ ok = true, … } -- the answer
{ ok = false, code = "…", error = "…" } -- a documented failureThe code is the API — branch on it. The error string is for a human reading a log and may be reworded between releases.
code | Means | What to do |
|---|---|---|
starting | the storage backend has not finished binding | Retry. This is not "there is nothing" — measured, a database resource reads as started about 2.5 s before it can answer |
unknown_player | that server id is not a connected player | The id is stale; re-resolve it |
no_identity | the player has no licence identifier | Nothing can be attributed to them. Treat as a hard refusal |
denied | the acting player lacks tcad.use or the operation's own permission. error names the ACE key | A grant is missing in server.cfg. The key is not a secret |
refused | the CAD's own domain rule said no. The message is forwarded verbatim | Show it; it is written to be read by a person |
ambiguous | the unit is on duty in more than one department and none was named | Pass a departmentId |
notfound | the thing does not exist | — |
bad_request | an argument was the wrong type or empty | Fix the call |
rate_limited | report911 only | Back off; error says for how long |
disabled | report911 only, switched off | — |
internal | a bug in tCAD. The real fault is in the server console | Report 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()
-> { 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()
-> { ok = true, ready, backend, version, allow911 }backend is "json" or "sql", or nil while starting.
isOnDuty(playerId)
-> { ok = true, onDuty = boolean }The cheapest question. onDuty = false is a successful answer; an unknown server id is unknown_player, not false.
getUnit(playerId)
-> { 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.
playerIdis given instead, because a consumer resource speaks server ids. playerIdis nil for a unit whose player disconnected without going off duty. The row is kept, not filtered — a dispatcher is still looking at it.- ⛔
latis the GTA world Y andlngthe 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)
-> { 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?)
-> { ok = true, units = { unit, … } }Everyone on duty, optionally scoped to one department. Same projection as getUnit.
getOpenCalls()
-> { 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)
-> { ok = true, call = { … } } -- with its units and timelinenotfound if it does not exist; bad_request if callId is not a number — the caller made a different mistake and gets told which.
getDepartments()
-> { ok = true, departments = { { id, agencyId, name, color, agencyType }, … } }Needed because every mutation takes a departmentId.
getStatusCodes(departmentId?)
-- 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.
| Export | Permission 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)
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
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
callTypeforced to911. ⛔ 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 offin the server console, orConfig.exports.allow911 = false.
Wiring it to a radio or phone resource's own no-dispatcher fallback:
-- 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
})
endtcad_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
readyfrom its first call rather thanstartingto 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?
TIMMYG Studios