Join Discord Community
LoginorRegister

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 key

Getting started

Keys are scoped to one community, and you pick exactly what each one may do. Base URL and auth:

HTTP
https://api.linespolice-cad.com/api/v1/dev
Authorization: Bearer lpc_live_...
Send the key in 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.

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

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

The account we create cannot be logged into. It holds no email and no password, and that is deliberate: an email you send us would be an email you control, so taking one would hand you a way into your players' accounts. The player takes ownership themselves in step 4.

3. Create their character

SHELL
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

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

LUA
-- 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)
SERVER.CFG
set lpc_api_key "lpc_live_your_key_here"
set lpc_community_id "your_community_id"
Use set, not setr. A setr convar is replicated to every connected client, which would hand your key to anyone who joins.

Rate limits

PLANBURSTDAILY
Free60 / minute10,000 / day
Pro600 / minute250,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.

Do not loop over your players. A server ticking once a second with 64 players is 3,840 requests a minute, over even the paid limit. Send one bulk status call per tick instead: up to 100 units, which is 60 requests a minute for a full server. It is partial, so one unknown player does not discard the other sixty-three.

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.

SCOPEGRANTS
community:readThe community's profile, departments and ten-codes
units:read / units:writeWho is on duty; set status, ten-code and location
calls:read / calls:writeCalls; create, update and close
bolos:read / bolos:writeBOLOs
civilians:read / civilians:writeLook up by name; create for linked players
vehicles:read / vehicles:writeLook up by plate; register
firearms:read / firearms:writeLook up by serial; register
licenses:writeIssue licenses
reports:read / reports:writeRead back your own drafts; prepare new ones. Neither files a report
warrants:read / warrants:writeRead the community's warrants; ask for one. Asking never issues one
players:provisionCreate and link player accounts, issue claim codes
communities:provisionCreate communities owned by you
members:read / members:writeThe 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 a 409 draft_already_reviewed and their decision is left alone.
  • Facts stay apart from the narrative. facts is what you observed; narrative is prose about it. A narrative with no generated field is recorded as generated: send "generated": false only 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.
  • reportingOfficer is 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 a templateSlug and 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:

EVENTWHEN
call.created / call.updated / call.closedA call appeared, changed, or ended
call.assignedThe units on a call changed, separate, so you need not diff every update
unit.status_changedOne unit's ten-code, location or department changed
record.createdA civilian, vehicle, firearm or licence was created
report_draft.filed / report_draft.rejectedA person acted on a draft you wrote
warrant.requested / warrant.reviewedA 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.

HEADERWHAT IT IS
X-LPC-Signaturet=<unix>,v1=<hex>, verify as above
X-LPC-Event-IdStable across retries, so you can recognise a duplicate
X-LPC-Event-TypeThe same type as in the body
X-LPC-Delivery-IdThis attempt
X-LPC-AttemptWhich 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.

CODEWHAT TO DO
invalid_keyWrong, revoked, or sent in the wrong header
missing_scopeThe response names the scope; add it to the key
community_not_grantedThe key is not approved for that community
community_pending_deletionThe owner deleted it; read-only for 30 days
acting_player_lacks_permissionThat player cannot do it, so neither can your key
player_not_foundUpsert the player first
already_claimedThe player owns their account; they sign in normally
ten_code_not_foundRead ids from /ten-codes rather than hardcoding
charge_not_in_penal_codeRead the community's penal code and send the names it lists
reporting_officer_has_no_departmentPut the officer in a department first
template_not_permittedThe community's form gates exclude that officer
draft_already_reviewedA person has acted on it; write a new draft with a new externalId
confirm_requiredA cleanup needs confirm set to the community id
invalid_cursorsince must be a cursor from a previous response
invalid_event_typeRead the event list rather than guessing at it
invalid_attachmentUnknown kind, no url, credentials in the url, or kind: "evidence", which does not exist
attachment_limit_reached10 per record
rate_limitedHonour Retry-After
provision_cap_reachedDaily 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:file scope 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.

Get a key