Developer API
Push live game activity into Lines Police CAD and read the CAD back out. Your players create characters, vehicles and licenses inside your server, and later find all of it on our website and in our app. Built for FiveM first.
Get a keyGetting started
Keys are scoped to one community, and you pick exactly what each one may do. Base URL and auth:
https://api.linespolice-cad.com/api/v1/dev
Authorization: Bearer lpc_live_...Authorization, not X-API-Key. That header is our own internal secret and your key will never satisfy it.You see a key once, when you create it. We store only a hash, so if you lose it you rotate it rather than recovering it. Start every debugging session with GET /whoami: it needs no scopes, so it works even when the thing you are debugging is a missing one.
Quickstart
1. Create a community
Owned by your LPC account and counted against your plan, exactly like one made on the website. It starts private.
curl -X POST https://api.linespolice-cad.com/api/v1/dev/communities \
-H "Authorization: Bearer $LPC_KEY" \
-H "Content-Type: application/json" \
-d '{"name":"Grapeseed RP"}'Already have a community? Skip this and ask its owner to approve your key from Community settings → API keys.
2. Upsert a player
You name players by their game identifier: a FiveM license or a Discord ID. Never by email, and never by an LPC user id.
curl -X POST .../dev/players \
-H "Authorization: Bearer $LPC_KEY" \
-d '{"provider":"fivem","identifier":"license:1a2b3c4d","username":"Dave"}'This is idempotent. Call it on every connect. The same identifier always returns the same account, even if two of your servers call at the same instant.
3. Create their character
curl -X POST .../dev/communities/$COMMUNITY/civilians \
-H "Authorization: Bearer $LPC_KEY" \
-d '{
"player": "fivem:1a2b3c4d",
"firstName": "John", "lastName": "Smith",
"birthday": "1998-04-10", "address": "Grapeseed Farmhouse"
}'Vehicles, firearms and licenses hang off that character by civilianId. If the response says pendingApproval, the community vets its characters and a person has to approve it, your key does not skip that, and nor does it skip the community's per-player character cap.
4. Show the player their claim code
curl -X POST .../dev/players/fivem/1a2b3c4d/claim-code \
-H "Authorization: Bearer $LPC_KEY"
{ "code": "K7M2-QX9P-4T", "url": "https://www.linespolice-cad.com/developer/claim" }Show both in game. The player opens the URL, signs in or signs up, enters the code, and everything you made for them is on their account. Codes last 30 minutes, work once, and asking for a new one kills the old one. A player who has linked Discord to LPC, and whom you provisioned by Discord ID, can claim with no code at all.
A working FiveM resource
Server side. Covers connect, the claim code, and a chunked status tick.
-- server.lua
local LPC_BASE = "https://api.linespolice-cad.com/api/v1/dev"
local LPC_KEY = GetConvar("lpc_api_key", "") -- set (not setr) in server.cfg
local LPC_COMMUNITY = GetConvar("lpc_community_id", "")
local function lpc(method, path, body, cb)
PerformHttpRequest(LPC_BASE .. path, function(status, text, _)
local ok, decoded = pcall(json.decode, text or "")
if not ok then decoded = nil end
if status == 429 then
print(("[lpc] rate limited: %s"):format(text or ""))
elseif status >= 400 then
print(("[lpc] %s %s -> %d %s"):format(method, path, status, text or ""))
end
if cb then cb(status, decoded) end
end, method, body and json.encode(body) or "", {
["Content-Type"] = "application/json",
["Authorization"] = "Bearer " .. LPC_KEY,
})
end
local function licenseOf(src)
for _, id in ipairs(GetPlayerIdentifiers(src)) do
if id:sub(1, 8) == "license:" then return id end
end
return nil
end
local players = {}
AddEventHandler("playerJoining", function()
local src, license = source, licenseOf(source)
if not license then return end
-- Idempotent: safe on every connect.
lpc("POST", "/players", {
provider = "fivem", identifier = license, username = GetPlayerName(src),
}, function(status, body)
if status ~= 200 and status ~= 201 then return end
players[src] = { ref = body.player.playerRef, claimed = body.player.claimed }
if not body.player.claimed then
lpc("POST", ("/players/fivem/%s/claim-code"):format(license:sub(9)), nil,
function(codeStatus, codeBody)
if codeStatus ~= 201 then return end
TriggerClientEvent("chat:addMessage", src, {
color = { 255, 200, 0 },
args = { "LPC", ("Link your characters at %s with code %s")
:format(codeBody.url, codeBody.code) },
})
end)
end
end)
end)
AddEventHandler("playerDropped", function() players[source] = nil end)
-- One bulk call per tick, never one call per player.
CreateThread(function()
while true do
Wait(15000)
local units = {}
for src, player in pairs(players) do
local ped = GetPlayerPed(src)
if ped and ped ~= 0 then
units[#units + 1] = {
player = player.ref,
location = GetNameOfZone(table.unpack(GetEntityCoords(ped))),
}
end
end
for i = 1, #units, 100 do
local chunk = {}
for j = i, math.min(i + 99, #units) do chunk[#chunk + 1] = units[j] end
lpc("PUT", ("/communities/%s/units/status/bulk"):format(LPC_COMMUNITY), { units = chunk })
end
end
end)set lpc_api_key "lpc_live_your_key_here"
set lpc_community_id "your_community_id"set, not setr. A setr convar is replicated to every connected client, which would hand your key to anyone who joins.Rate limits
| PLAN | BURST | DAILY |
|---|---|---|
| Free | 60 / minute | 10,000 / day |
| Pro | 600 / minute | 250,000 / day |
Every response carries X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset and X-RateLimit-Scope. A 429 adds Retry-After and a body saying which limit you hit.
There are separate, lower daily caps on creating player accounts and communities, because those make rows that are awkward to undo. Free is 25 accounts and 2 communities a day. Both reset at midnight UTC, and hitting the account cap does not stop you looking up players you already know.
Scopes
Pick the narrowest set that does the job. A scope you did not pick is a 403 that names it.
| SCOPE | GRANTS |
|---|---|
community:read | The community's profile, departments and ten-codes |
units:read / units:write | Who is on duty; set status, ten-code and location |
calls:read / calls:write | Calls; create, update and close |
bolos:read / bolos:write | BOLOs |
civilians:read / civilians:write | Look up by name; create for linked players |
vehicles:read / vehicles:write | Look up by plate; register |
firearms:read / firearms:write | Look up by serial; register |
licenses:write | Issue licenses |
reports:read / reports:write | Read back your own drafts; prepare new ones. Neither files a report |
warrants:read / warrants:write | Read the community's warrants; ask for one. Asking never issues one |
players:provision | Create and link player accounts, issue claim codes |
communities:provision | Create communities owned by you |
members:read / members:write | The roster; invite, approve, promote, remove |
Every membership write names an acting player, and we check that player's permissions the same way the website would. A key never grants more than the player it acts for.
Report drafts
POST /communities/{communityId}/reports/drafts writes a draft incident, arrest or citation report from what your server saw. It does not file a report. There is no endpoint here that does: a person with the community's manage records permission reads the draft on the website and files it there.
That is deliberate. An arrest report in a community carries jail time, a fine and a court case. Your server can see a scuffle; it cannot know whether that was an arrest. So treat a 201 as “the draft is in the queue”, never as “the report exists”, and poll GET …/reports/drafts/{externalId} to find out what they decided.
- Idempotent on your own
externalId, like a dispatch call. A retry updates the draft rather than adding a second one to somebody's queue. Once a person has filed or rejected it you get a409 draft_already_reviewedand their decision is left alone. - Facts stay apart from the narrative.
factsis what you observed;narrativeis prose about it. A narrative with nogeneratedfield is recorded as generated: send"generated": falseonly when a person typed the text. There is no way to mark a narrative as evidence, and there will not be. - You name charges; the community prices them. Send the violation names from the community's own penal code and we fill in the fine and the jail time from it. A name they do not have is refused, so you cannot invent an amount.
reportingOfficeris what gets checked. Your scope decides whether you may propose a draft at all; whether this draft is allowed is decided by that player's access, an approved member who is in a department. Name atemplateSlugand the community's own form gates apply to them too.
Warrants
Read them with GET /communities/{communityId}/warrants, filterable by accused and status, so “is there a warrant out for this person?” is one request. approved and awaitingReview are spelled out on each one so you do not have to decide whether executed counts.
POST …/warrants/requests asks for one, and a request always lands pending: whatever the community's approval mode says.
That is deliberately not what the website does. A community can set auto-approve, and most have, so an officer clicking the button in the CAD gets an approved warrant at once. That is a convenience for a person who is already in the CAD and can see the result. A game server fires on a trigger, retries, and nobody is watching, so a key gets the pending queue the community already has, a person reviews it with the tools they already use, and you read the outcome back. judgeNotes comes back on a denial, which is the only thing that explains one to your officer.
Simulated characters
Mark an NPC once, when you provision it: {"provider":"fivem","identifier":"…","synthetic":true}. Everything that character owns inherits the mark, so you do not have to repeat it on each civilian, vehicle and firearm.
Synthetic records are left out of the searches and rosters players see, including the website's own name database, unless a caller passes includeSynthetic=true. So you can populate a city without burying the twelve real players behind four hundred NPCs.
They count against your key's daily provisioning cap, so a runaway loop is still bounded. They do not count against a player's own per-community character cap: an NPC is nobody's character, and counting it both ways would mean your scenery consuming your players' allowances.
When a test run is done, clean up with DELETE /communities/{communityId}/synthetic-records. It only ever touches synthetic records your own key created, in the community in the path, and confirm has to be that community id, so a cleanup aimed at your test community cannot run against a live one because a config value was stale. A community owner can also remove everything your key made, from their own settings; that path exists for an integration that has stopped answering, so it does not need your cooperation.
Polling cheaply
GET /communities/{communityId}/changes?since=<cursor> returns only what has changed. Without it, finding out whether a call was closed means re-reading every call, on a busy community that is a scan per tick, and it is why polling has a reputation for being expensive.
Call it with no since first. You get no events and a cursor pointing at now, because replaying a week of history to a client that is about to read current state anyway is not a service. Then poll with that cursor; each response hands you the next one, also in X-LPC-Cursor so a Lua callback need not parse the body.
Pass excludeSelf=true unless you want your own writes back. Close a call, receive your own call.closed, close it again: that is the loop this parameter exists to prevent.
The website's own handlers emit too, so a call a dispatcher closed in the CAD reaches you like one you closed yourself. Events are named after what happened in the product rather than after the endpoint that caused it:
| EVENT | WHEN |
|---|---|
call.created / call.updated / call.closed | A call appeared, changed, or ended |
call.assigned | The units on a call changed, separate, so you need not diff every update |
unit.status_changed | One unit's ten-code, location or department changed |
record.created | A civilian, vehicle, firearm or licence was created |
report_draft.filed / report_draft.rejected | A person acted on a draft you wrote |
warrant.requested / warrant.reviewed | A warrant was asked for, or decided |
Events are kept 7 days, shorter than the activity feed on purpose. A cursor staler than that means you should re-read current state rather than replay a week, so persist it somewhere that survives a restart and treat a long outage as a resync. hasMore tells you to call again; there is no total, because you cannot act on one.
Webhooks
Register an endpoint per key in the console. You get a signing secret once, and deliveries carry the same event shape the cursor endpoint returns, so moving from polling to webhooks does not mean rewriting your handler, and you can run both while you migrate because the event id is stable and the duplicate is recognisable.
Verify every delivery. Recompute HMAC-SHA256(secret, "<t>." + rawBody) and compare in constant time against the v1 value in X-LPC-Signature, then reject anything whose t is more than 300 seconds old. That last step is what stops a captured delivery being replayable; the signature alone proves nothing about when the request was made.
Use the raw body, before any JSON parsing. A re-serialised body will not match, and that is the single most common reason a first webhook integration rejects everything.
| HEADER | WHAT IT IS |
|---|---|
X-LPC-Signature | t=<unix>,v1=<hex>, verify as above |
X-LPC-Event-Id | Stable across retries, so you can recognise a duplicate |
X-LPC-Event-Type | The same type as in the body |
X-LPC-Delivery-Id | This attempt |
X-LPC-Attempt | Which attempt this is |
Answer 2xx quickly and do your work afterwards. You have 10 seconds. A non-2xx is retried after 10s, 30s, 2m, 5m and 15m; an endpoint that fails 20 deliveries in a row is switched off, with the reason in the console.
Nothing is lost when a delivery fails. The event is still readable from the cursor endpoint for its 7 days, which is why webhooks are a transport over the event log rather than a second source of truth.
A few rules on the endpoint itself, so there are no surprises when you save it:
- https only. An http endpoint would ship the signature and the body in clear
- A public host. We refuse loopback, private ranges, link-local and the cloud metadata address, checked again immediately before every request
- No redirects. We will not follow one. If your URL needs to change, change it in the console
- No credentials in the URL. The signature is how you authenticate us
Rotating the secret keeps the endpoint and its subscription, so it is not an outage, but the next delivery is signed with the new secret, so update your receiver first.
Attachments
POST …/reports/drafts/{externalId}/attachments and POST …/dispatch/calls/{externalId}/attachments record metadata and a link. We host no bytes, there is no upload endpoint, and we never fetch the URL, it is a link a person clicks in their own browser.
That is the answer rather than a first step. Accepting third-party binaries would mean scanning them, storing them, quotas, a takedown path and a legal position on content we host, and none of that is needed for a reviewer clicking through to the clip your server already recorded.
kind is never evidence. A link you supply points at something we have not seen, on a server we do not control; calling it evidence inside a system that produces jail time would give it a standing it has not earned. Use clip, screenshot, log, document or other, and let a person decide what it is. The same applies to a generated narrative.
Limits: 10 per record, a 1024-character http or https URL with no credentials in it, and a bounded declared size. Idempotent on the URL, so a retry does not give a reviewer two of the same thing. Nothing can be attached once a person has reviewed the draft. Every attach and detach is audited.
When something is not working
Every response carries X-Request-Id. Send X-Correlation-Id with your own id and we echo it back and record it beside ours, so your logs line up with ours.
GET /activity is this key's own request history, and it needs no scope: a key that is being refused has to be able to find out why, and gating the diagnostic would withhold it from exactly the keys that need it. Each row says which route, which community, what we answered, and on a missing_scope refusal which scope was missing, which is the thing a bare 403 in your own logs does not tell you.
Rows are kept 30 days. They hold no request body, no response body and no header values, only the shape of each exchange, so if you need the contents of a call you made, log it your end. Quote a request id in a ticket and we can find exactly that request.
Errors
Every error has a stable error code to branch on and a message written for a person to read.
| CODE | WHAT TO DO |
|---|---|
invalid_key | Wrong, revoked, or sent in the wrong header |
missing_scope | The response names the scope; add it to the key |
community_not_granted | The key is not approved for that community |
community_pending_deletion | The owner deleted it; read-only for 30 days |
acting_player_lacks_permission | That player cannot do it, so neither can your key |
player_not_found | Upsert the player first |
already_claimed | The player owns their account; they sign in normally |
ten_code_not_found | Read ids from /ten-codes rather than hardcoding |
charge_not_in_penal_code | Read the community's penal code and send the names it lists |
reporting_officer_has_no_department | Put the officer in a department first |
template_not_permitted | The community's form gates exclude that officer |
draft_already_reviewed | A person has acted on it; write a new draft with a new externalId |
confirm_required | A cleanup needs confirm set to the community id |
invalid_cursor | since must be a cursor from a previous response |
invalid_event_type | Read the event list rather than guessing at it |
invalid_attachment | Unknown kind, no url, credentials in the url, or kind: "evidence", which does not exist |
attachment_limit_reached | 10 per record |
rate_limited | Honour Retry-After |
provision_cap_reached | Daily account or community cap; resets at midnight UTC |
What a key cannot do
Not a matter of picking scopes. These are not on the developer surface at all, so no key can reach them:
- change a password, an email, or anything else on a claimed account
- touch money: wallets, balances, transfers, economy payouts
- billing, subscriptions or boosts
- community settings, roles, permissions or deletion
- assign a role to a member, roles carry permissions; departments and ranks are fine
- read any community your key is not tied to
- file a report. A draft is a proposal; the verb that turns one into an arrest, citation or incident record is on the website, behind the community's own permission. There is no
reports:filescope and there will not be one - issue a warrant. A request lands pending for the community's own reviewer, whatever its approval mode says
A community owner can withdraw your key's access at any time from their own settings, and it stops working on your next request.
Getting help
Open a ticket in our Discord. Include your key's display prefix (lpc_live_a1b2c3d4), never the key itself, and the error code you got.