Skip to main content

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 -X POST "$BASE_URL/integrations/fivem/token" \
-H "Content-Type: application/json" \
-d '{"clientId": "'"$CLIENT_ID"'", "clientSecret": "'"$CLIENT_SECRET"'"}'

This is the shape of almost any bridge integration: link once, then act.

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);
}
}

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.

# 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"

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.