Code Examples
Working examples for the flows you'll actually build, in curl (for
poking at the API by hand), Lua (PerformHttpRequest, for a FiveM
resource calling Kestrel directly), JavaScript (fetch, for a Node.js
service or bot), and Python (requests). Swap in your own base URL,
credential, and identifiers.
All examples assume BASE_URL = https://<your-deployment>/api/v1.
Getting a token
The token exchange is identical for both credential kinds — only the
path differs (/integrations/fivem/token for a fivem-kind credential,
/integrations/token for an API credential). See
Getting a Token.
- curl
- Lua (FiveM)
- JavaScript
- Python
curl -X POST "$BASE_URL/integrations/fivem/token" \
-H "Content-Type: application/json" \
-d '{"clientId": "'"$CLIENT_ID"'", "clientSecret": "'"$CLIENT_SECRET"'"}'
local function getAccessToken(callback)
PerformHttpRequest(
('%s/integrations/fivem/token'):format(Config.Kestrel.baseUrl),
function(statusCode, response)
if statusCode ~= 200 then
print(('[kestrel] token exchange failed: %s'):format(statusCode))
return callback(nil)
end
local body = json.decode(response)
callback(body.accessToken, body.expiresIn)
end,
'POST',
json.encode({ clientId = Config.Kestrel.clientId, clientSecret = Config.Kestrel.clientSecret }),
{ ['Content-Type'] = 'application/json' }
)
end
Re-run this a little before the token's 12-minute expiresIn runs out —
there's no refresh token, just re-exchange. A simple approach is a
SetTimeout that renews at the ~10-minute mark and swaps a module-level
variable other functions read from.
async function getAccessToken(baseUrl, clientId, clientSecret) {
const res = await fetch(`${baseUrl}/integrations/fivem/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ clientId, clientSecret }),
});
if (!res.ok) throw new Error(`token exchange failed: ${res.status}`);
const { accessToken, expiresIn } = await res.json();
return { accessToken, expiresIn };
}
import requests
def get_access_token(base_url, client_id, client_secret):
res = requests.post(
f"{base_url}/integrations/fivem/token",
json={"clientId": client_id, "clientSecret": client_secret},
timeout=10,
)
res.raise_for_status()
body = res.json()
return body["accessToken"], body["expiresIn"]
A full FiveM Bridge flow: link, sync duty, run a plate
This is the shape of almost any bridge integration: link once, then act.
- JavaScript
- Python
const BASE = 'https://your-deployment/api/v1/integrations/fivem';
async function call(path, token, body) {
const res = await fetch(`${BASE}${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
if (!res.ok) {
const err = await res.json().catch(() => null);
throw new Error(`${path} -> ${res.status}: ${err?.error ?? 'unknown'}`);
}
return res.json();
}
async function main() {
const { accessToken } = await getAccessToken(BASE.replace('/integrations/fivem', ''), CLIENT_ID, CLIENT_SECRET);
const identifiers = { license: 'license:abc123...' };
// One-time per player, using a code they generated in the web app.
const link = await call('/identity/link', accessToken, { code: '482913', identifiers });
if (!link.linked) return console.log('link failed:', link.reason);
// Sync duty on shift start (auto-provisions the unit on first call).
const duty = await call('/duty', accessToken, {
identifiers,
departmentSlug: 'police-department',
onDuty: true,
jobLabel: 'police',
gradeLabel: 'officer',
characterName: 'Alex Rivera',
});
if (!duty.synced) return console.log('duty sync failed:', duty.reason);
// Run a plate.
const plate = await call('/lookup/vehicle', accessToken, { identifiers, plate: '6ABC123' });
if (plate.found) {
console.log(`Plate registered to ${plate.vehicle.ownerDisplayName ?? 'unknown owner'}`);
} else {
console.log('No match:', plate.reason);
}
}
import requests
BASE = "https://your-deployment/api/v1/integrations/fivem"
def call(path, token, body):
res = requests.post(
f"{BASE}{path}",
json=body,
headers={"Authorization": f"Bearer {token}"},
timeout=10,
)
if not res.ok:
raise RuntimeError(f"{path} -> {res.status_code}: {res.json().get('error')}")
return res.json()
identifiers = {"license": "license:abc123..."}
link = call("/identity/link", token, {"code": "482913", "identifiers": identifiers})
if not link["linked"]:
raise SystemExit(f"link failed: {link['reason']}")
duty = call("/duty", token, {
"identifiers": identifiers,
"departmentSlug": "police-department",
"onDuty": True,
"jobLabel": "police",
"gradeLabel": "officer",
"characterName": "Alex Rivera",
})
if not duty["synced"]:
raise SystemExit(f"duty sync failed: {duty['reason']}")
plate = call("/lookup/vehicle", token, {"identifiers": identifiers, "plate": "6ABC123"})
if plate["found"]:
print(f"Plate registered to {plate['vehicle'].get('ownerDisplayName') or 'unknown owner'}")
else:
print("No match:", plate["reason"])
Every FiveM Bridge call follows this same pattern — same headers, same
identifiers object, same discriminated response. Once you have call()
written once, every other endpoint in
FiveM Bridge Endpoints is a
one-line addition.
API credential: listing and paginating
The API credential surface has no identifiers field, and three of its
list endpoints (/records/vehicles, /records/civilians,
/records/warrants) are cursor-paginated — see
API Credential.
- curl
- JavaScript
- Python
# First page
curl "$BASE_URL/records/civilians?q=rivera&limit=50" \
-H "Authorization: Bearer $ACCESS_TOKEN"
# Next page, using the previous response's nextCursor verbatim
curl "$BASE_URL/records/civilians?q=rivera&limit=50&cursor=$NEXT_CURSOR" \
-H "Authorization: Bearer $ACCESS_TOKEN"
async function fetchAllCivilians(baseUrl, token, query) {
const results = [];
let cursor;
do {
const url = new URL(`${baseUrl}/records/civilians`);
url.searchParams.set('q', query);
url.searchParams.set('limit', '100');
if (cursor) url.searchParams.set('cursor', cursor);
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) throw new Error(`list failed: ${res.status}`);
const page = await res.json();
results.push(...page.items);
cursor = page.hasMore ? page.nextCursor : undefined;
} while (cursor);
return results;
}
def fetch_all_civilians(base_url, token, query):
results = []
cursor = None
while True:
params = {"q": query, "limit": 100}
if cursor:
params["cursor"] = cursor
res = requests.get(
f"{base_url}/records/civilians",
params=params,
headers={"Authorization": f"Bearer {token}"},
timeout=10,
)
res.raise_for_status()
page = res.json()
results.extend(page["items"])
if not page["hasMore"]:
break
cursor = page["nextCursor"]
return results
Handling errors generically
Every non-2xx response shares one shape (see Errors & Status Codes), so a single wrapper handles all of them:
async function kestrelRequest(url, options) {
const res = await fetch(url, options);
if (res.status === 429) {
// back off and retry — no per-response detail on how long
throw new RateLimitedError();
}
if (!res.ok) {
const body = await res.json().catch(() => ({ error: 'UNKNOWN', message: res.statusText }));
throw new KestrelApiError(body.error, body.message, body.details);
}
return res.json();
}
Listening for waypoint updates over WebSocket
Optional — only needed if your integration wants instant waypoint pins
instead of polling POST /waypoint. Uses
socket.io-client; see
FiveM Bridge Endpoints
for the payload shapes.
import { io } from 'socket.io-client';
const socket = io('https://your-deployment/integrations/fivem', {
auth: { token: accessToken },
});
socket.on('waypoint', (event) => {
switch (event.type) {
case 'assignment.created':
case 'assignment.updated':
console.log(`Unit ${event.unitLabel} -> ${event.callNumber} at (${event.mapX}, ${event.mapY})`);
break;
case 'assignment.removed':
console.log(`Unit ${event.unitLabel} cleared from ${event.callId}`);
break;
}
});
Re-authenticate the socket (a fresh connect with a new auth.token)
whenever you rotate your access token — a socket connected with an
expired token gets disconnected, it isn't kept alive past the token's TTL.