Developers
The lation jobs server and client exports, the events it fires, its server hooks, Script places and custom place types, with signatures and Lua examples.
A job is addressed by its name: the job or gang name the framework knows it by. An employee is an identifier (the citizenid on Qbox and QBCore, the player identifier on ESX), online or not. A grade is a level: 0 is the lowest grade and the boss grade is the highest. Every export, event, payload key and hook parameter calls a job job.
The writes go through the same paths the panels use, so the hooks, the activity log, the bank membership, the logs and the live refresh of every open panel all hold whoever calls.
Server exports
Reads
| Export | Returns |
|---|---|
GetJob(name) | A job, or nil |
GetJobs() | Every managed job, in label order |
GetGrades(name) | The job's grades, lowest first |
GetEmployees(name) | The roster |
GetMemberships(identifier) | Every job and gang a player holds |
IsEmployee(name, identifier) | boolean |
GetEmployeeGrade(name, identifier) | A grade, or nil |
IsOnDuty(target) | boolean, and the job they are on duty at |
GetOnDuty(name) | Who is on duty at a job |
GetPlaces(name, type?) | The job's places, optionally of one kind |
GetConfig(section?) | A copy of the live configuration |
Reads for a job that is not managed here return nil, false or an empty table.
GetJob
Server export
exports.lation_jobs:GetJob(name)Parameters
namestring- The job or gang name
Returns
table | nil- The job (below), or
nil
The job:
{
name = 'police',
kind = 'job', -- 'job' or 'gang'
label = 'LSPD',
icon = nil, -- string or nil
color = nil, -- '#RRGGBB' or nil
blip = nil, -- { enabled, sprite, color, scale, label } or nil
settings = {
hiring = true, -- applications open
public = true, -- listed on the job board
description = '', -- the job board sentence
payroll = {}, -- { intervalMinutes?, shortfall?, source? }, this job's overrides
salaryMax = nil, -- this job's own salary cap
hiringMode = nil, -- 'direct', 'offer' or nil for the server default
application = { questions = {} },
webhook = { set = false, serverToo = false }, -- the URL never leaves the server
},
grades = {}, -- grades, lowest first (below)
places = {}, -- places (below)
createdBy = 'ABC12345',
createdAt = 1730000000,
}A grade:
{
id = 12,
level = 0,
name = 'officer', -- 'boss' for the boss grade
label = 'Officer',
salary = 120, -- per hour on duty
permissions = { hire = false, fire = false, --[[ every permission key ]] },
isboss = false,
outfit = nil,
}A place:
{
id = 7,
type = 'stash',
label = 'Evidence',
coords = { x = 0.0, y = 0.0, z = 0.0, w = 0.0 },
options = { slots = 50, weight = 100, personal = false }, -- the kind's own fields
minGrade = 0,
access = 'grade', -- 'grade' (minGrade and up) or 'grades' (only the levels in grades)
grades = nil,
onDutyOnly = true,
icon = nil,
pin = false,
createdBy = 'ABC12345',
createdAt = 1730000000,
}Times are unix seconds.
GetEmployees
Server export
exports.lation_jobs:GetEmployees(name)Parameters
namestring- The job or gang name
Returns
table- The roster, highest grade first (below)
The roster:
{
{
identifier = 'ABC12345',
name = 'Jane Doe',
avatar = nil, -- picture URL or nil
grade = 3,
gradeLabel = 'Chief',
isboss = true,
online = true,
onDuty = false,
onLeave = nil, -- the last day of approved leave covering today, or nil
hiredAt = 1730000000,
hiredBy = 'XYZ98765',
lastSeen = 1730500000,
},
}for _, employee in ipairs(exports.lation_jobs:GetEmployees('police')) do
if employee.onDuty then print(employee.name, employee.gradeLabel) end
endGetMemberships
Server export
exports.lation_jobs:GetMemberships(identifier)Parameters
identifierstring- The player's identifier
Returns
table{ name, label, kind, grade }for every job and gang the player holds, in label order
Only one of them is the framework's active job; read the framework for that.
IsEmployee and GetEmployeeGrade
Server export
exports.lation_jobs:IsEmployee(name, identifier) -- boolean
exports.lation_jobs:GetEmployeeGrade(name, identifier) -- grade or nilParameters
namestring- The job or gang name
identifierstring- The player's identifier
Returns
IsEmployeereturns aboolean;GetEmployeeGradereturns a grade, ornil
IsOnDuty
Server export
local onDuty, job = exports.lation_jobs:IsOnDuty(target)Parameters
targetnumber | string- A server id, or the identifier of a connected player
Returns
boolean, string- Whether the player is on duty and the name of the job they are clocked on at. A player who is not connected returns
false.
local onDuty, job = exports.lation_jobs:IsOnDuty(source)
if onDuty and job == 'ambulance' then
-- an EMS employee on shift
endGetOnDuty
Server export
exports.lation_jobs:GetOnDuty(name)Parameters
namestring- The job or gang name
Returns
table{ source, identifier, name, grade, startedAt }for everyone clocked on at the job, longest shift first
GetPlaces
Server export
exports.lation_jobs:GetPlaces(name, type)Parameters
namestring- The job or gang name
typestringoptionaldefaultnil- One kind (
'stash','garage', ...); all kinds when left out
Returns
table- The job's places, optionally of one kind
GetConfig
Server export
exports.lation_jobs:GetConfig(section)Parameters
sectionstringoptionaldefaultnilgeneral,businesses,duty,work,hiring,payroll,applications,forms,notices,leave,jobcenter,placesoractivity
Returns
table- A copy of that section, nested by the keys on the configuration page, or every section when left out. The
logssection is never returned.
local work = exports.lation_jobs:GetConfig('work')
print(work.maxJobs) -- 3Writes
The writes return true, or false and an error code; CreateJob returns the job, or nil and a code. The codes are the keys under errors in locales/en.json (not_employed, grade_denied, already_employed ...), where the text the panels show for each one lives. A job that is not managed here is job_unknown from Hire and DeleteJob, and invalid from the others. When a hook refuses, its own message is the code.
CreateJob
Server export
local job, err = exports.lation_jobs:CreateJob(data)Parameters
data.namestring- The framework name: lowercase letters, digits and underscores, starting with a letter, up to 40 characters
data.labelstring- What players see
data.kindstringoptionaldefault'job''job'or'gang'
Returns
table | nil, string- The new job, or
niland an error (invalid_name,job_exists,label_required,gangs_disabled,gangs_unsupported,too_many_grades,database)
A name the framework already has is adopted, as from the panel; a new one starts with a boss grade and an employee grade. Fires lation_jobs:jobCreated.
local job, err = exports.lation_jobs:CreateJob({ name = 'taxi', label = 'Downtown Cab Co.' })
if not job then print(('could not create the job: %s'):format(err)) endDeleteJob
Server export
local ok, err = exports.lation_jobs:DeleteJob(name)Parameters
namestring- The job or gang name
Returns
boolean, stringtrue, orfalseand an error code:job_unknownfor a job that is not managed here,databasewhen the delete could not be written
Deletes the job as the Danger zone does: employees to unemployed, shifts closed, the framework forgets the job, the bank keeps the account. Fires lation_jobs:jobDeleted.
local ok, err = exports.lation_jobs:DeleteJob('taxi')
if not ok then print(('could not delete the job: %s'):format(err)) endHire
Server export
local ok, err = exports.lation_jobs:Hire(name, target, grade, actor)Parameters
namestring- The job or gang name
targetnumber | string- A connected player's server id, or an identifier (online or not)
gradenumber- The grade level to hire at
actornumberoptionaldefaultnil- The server id of whoever is hiring, for the activity log, the hooks and the
hiredevent
Returns
boolean, stringtrue, orfalseand an error code
Seats the person at once, even when the job hires by offer, with no grade ceiling. canHire still runs, and the cap on jobs per player still holds (too_many_jobs). An offline hire takes effect on their next load.
local ok, err = exports.lation_jobs:Hire('police', source, 0)
if not ok then print(('hire refused: %s'):format(err)) endFire
Server export
local ok, err = exports.lation_jobs:Fire(name, identifier, actor)Parameters
namestring- The job or gang name
identifierstring- Who to fire
actornumberoptionaldefaultnil- The server id of whoever is firing
Returns
boolean, stringtrue, orfalseand an error code
Closes their shift there, removes their access to the society account at lation banking, and hands the framework their next job or unemployed. canFire runs first.
SetGrade
Server export
local ok, err = exports.lation_jobs:SetGrade(name, identifier, grade, actor)Parameters
namestring- The job or gang name
identifierstring- Whose grade to change
gradenumber- The new grade level
actornumberoptionaldefaultnil- The server id of whoever is changing it
Returns
boolean, stringtrue, orfalseand an error code
Fires lation_jobs:promoted or lation_jobs:demoted.
SetDuty
Server export
local ok, err = exports.lation_jobs:SetDuty(source, onDuty)Parameters
sourcenumber- The player's server id
onDutybooleantrueto clock on,falseto clock off
Returns
boolean, stringtrue, orfalseand an error code
Clocking on picks the job the way /duty does: the active job, then the gang, then the one job the player holds (pick_job when they hold several and none is active). canClockOn runs first. A player already in the state asked for returns true.
SwitchJob
Server export
local ok, err = exports.lation_jobs:SwitchJob(source, name)Parameters
sourcenumber- The player's server id
namestring- One of the jobs they hold
Returns
boolean, stringtrue, orfalseand an error code
Makes it the player's active job: what the framework and every other script see. Jobs only, and refused with on_duty_elsewhere while they are clocked on at another job.
RegisterPlaceType
See custom place types.
Client exports
| Export | Returns | Description |
|---|---|---|
openBoss(job?) | Opens the boss menu on that job, or on the first job the player runs when none is given | |
openAdmin() | Opens the admin panel; the server checks the permission | |
openJobs() | Opens the Work panel | |
isOnDuty(job?) | boolean | Whether the player is on duty, at that job when one is given |
getDutyJob() | string or nil | The job the player is clocked on at |
close() | Closes whichever panel is open | |
isOpen() | boolean | Whether a panel is open |
registerPlaceType(type, def) | boolean | The client half of a custom place type |
askPlace(place, action, data?) | table | Sends a prompt of a custom place type to the server |
if exports.lation_jobs:isOnDuty('mechanic') then
exports.lation_jobs:openBoss('mechanic')
endThe duty statebag
While a player is on duty, the replicated player statebag lation_duty holds the job name; off duty it is false. Read it as Player(source).state.lation_duty on the server or LocalPlayer.state.lation_duty on the client.
The garage statebag
A vehicle a garage place spawns from its list (not a jg garage's) carries the replicated entity statebag lation_jobs. Read it as Entity(vehicle).state.lation_jobs on the server or the client:
{
job = 'police', -- the job name
owner = 'ABC12345', -- the identifier of the employee it was handed to
place = 9, -- the id of the garage place it came from
}Events
Every change fires a server event once it is done. They are local server events, so listen with AddEventHandler.
| Event | Arguments |
|---|---|
lation_jobs:ready | None. Every job is loaded and registered with the framework. |
lation_jobs:jobCreated | name |
lation_jobs:jobUpdated | name |
lation_jobs:jobDeleted | name |
lation_jobs:hired | job, identifier, grade, actor |
lation_jobs:fired | job, identifier, actor |
lation_jobs:quit | job, identifier |
lation_jobs:promoted | job, identifier, grade, actor |
lation_jobs:demoted | job, identifier, grade, actor |
lation_jobs:dutyChanged | { source, identifier, job, onDuty, reason? } |
lation_jobs:payroll | { job, rows, shortfall, total } |
lation_jobs:bonus | { job, identifier, amount, note, actor } |
lation_jobs:deposit | { job, identifier, amount } |
lation_jobs:withdraw | { job, identifier, amount } |
lation_jobs:placeAdded | job, placeId, type |
lation_jobs:placeRemoved | job, placeId, type |
lation_jobs:placeUsed | source, job, placeId, type, action |
lation_jobs:applicationCreated | { job, identifier, id } |
lation_jobs:applicationAnswered | { job, identifier, id, status, grade?, actor } |
lation_jobs:formSubmitted | { job, identifier, form, response } |
lation_jobs:noticePosted | { job, id, author } |
lation_jobs:offerSent | { job, id, identifier, grade, actor, application } |
lation_jobs:offerAnswered | { job, id, identifier, status, grade, actor? } |
lation_jobs:leaveRequested | { job, id, identifier, from, to } |
lation_jobs:leaveAnswered | { job, id, identifier, status, actor } |
lation_jobs:configUpdated | section or nil |
Notes on the arguments:
actoris the server id of the player who made the change, ornil(another resource without an actor, a job another script handed out through the framework, an accepted offer).dutyChanged.reasonis set when a shift closes:command,place,panel,framework,boss,disconnect,idle,dead,firedordeleted.payroll.rowshas one entry per employee in the run:{ identifier, name, grade, gradeLabel, minutes, amount, paid, reason? }.placeUsed.actionis what the prompt asked:toggle(duty),open(stash, jg garage),list,spawnandreturn(locker and garage),store(jg garage),enterandexit(teleport),use(script), or a custom type's own.applicationAnswered.statusisaccepted,declinedoroffered;offerAnswered.statusisaccepted,declinedorwithdrawn.depositandwithdrawname the boss who moved the money.
AddEventHandler('lation_jobs:hired', function(job, identifier, grade, actor)
if job == 'police' then
print(('%s joined the police at grade %d'):format(identifier, grade))
end
end)
AddEventHandler('lation_jobs:dutyChanged', function(data)
if data.job == 'ambulance' and not data.onDuty then
print(('%s clocked off (%s)'):format(data.identifier, data.reason or 'manual'))
end
end)Client events
| Event | Arguments | Fires when |
|---|---|---|
lation_jobs:dutyChanged | job or nil | The player clocks on or off |
lation_jobs:membershipsChanged | None | The jobs the player holds, or their grades, change |
lation_jobs:configUpdated | None | The live configuration the client holds changed |
Script places
The quickest way to hook your own script needs no code on the lation jobs side. An admin puts down a Script place and names what it fires. lation jobs checks the grade, the duty and the distance as for every place, then fires it with the player and the job attached, so your script runs only for the people you chose, where you put it. Only an admin sets one, and the place data sent to clients leaves out what it fires.
| Fires | Name | Called as |
|---|---|---|
| A client event | mymechanic:repair | TriggerEvent(name, payload) on the player's client |
| A client export | myradio.open | exports[resource][function](payload) on the player's client |
| A server event | mycrafting:open | TriggerEvent(name, source, payload) on the server |
| A server export | myturf.claim | exports[resource][function](source, payload) on the server |
| A command | me checks the board | ExecuteCommand(name) on the player's client, as if typed |
An export's name is resource.function, and the resource must exist on the server when the place is saved; a server export's resource must also be running when the place is used. A command is the line without the slash, arguments included, and gets no payload. Names run up to 80 characters.
The place also takes a Prompt (what the row says, up to 40 characters, Use when empty), a Hold time in milliseconds, and Arguments: a JSON object or list, up to 2,000 characters, handed to your script as args.

The payload:
{
job = 'mechanic', -- the job name
label = 'Bennys', -- the job label
place = { id = 14, label = 'Lift' },
grade = 2, -- the player's grade level
gradeLabel = 'Mechanic',
onDuty = true, -- on duty at this job
args = { bench = 'engine' }, -- the JSON typed into the place, or nil
}-- Fires: a client event, name mymechanic:repair
AddEventHandler('mymechanic:repair', function(payload)
StartRepair(payload.job, payload.grade)
end)
-- Fires: a client export, name myradio.open (in the resource myradio)
exports('open', function(payload)
OpenRadio(payload.job)
end)-- Fires: a server event, name mycrafting:open
AddEventHandler('mycrafting:open', function(source, payload)
OpenBench(source, payload.args and payload.args.bench)
end)
-- Fires: a server export, name myturf.claim (in the resource myturf)
exports('claim', function(source, payload)
ClaimTurf(source, payload.job)
end)The server event is fired by the server itself, so a plain AddEventHandler is enough and clients cannot trigger it. Every use also fires lation_jobs:placeUsed.
Custom place types
A kind of place of your own is two halves: the server half cleans what the dialog sends and answers a prompt once the grade, duty and distance checks have passed; the client half builds the prompts. Admins and bosses put it down from the same dialog as the built-in kinds. The type belongs to the resource that registered it: when that resource stops, the type goes and its places stay dark until it is back. When lation_jobs restarts, register again (it fires lation_jobs:ready on the server once it has loaded).
RegisterPlaceType (server)
Server export
exports.lation_jobs:RegisterPlaceType(type, def) -- booleanParameters
typestring- Lowercase letters, digits and underscores, starting with a letter. A kind another resource owns is refused, built-in ones included.
def.labelstringoptionaldefaultnil- The name the panel shows for the kind
def.iconstringoptionaldefault'map-pin'- The kind's icon
def.fieldstableoptionaldefault{}- What the dialog asks for (below)
def.coercefunctionoptionalcoerce(options, job, existing)returns the cleaned options, orniland a message to refuse the save.existingis the place being edited,nilfor a new one. Left out, the options pass through as they are.def.onSelectfunctionoptionaldefaultnilonSelect(source, place, job, action, data, grade); without it the kind has nothing to answerdef.onCreatefunctionoptionaldefaultnilonCreate(place, job), after a place of the kind is saveddef.onRemovefunctionoptionaldefaultnilonRemove(place, job), when one is removed. An edit runsonRemoveon the old place, thenonCreateon the new one.
Returns
boolean
The callbacks get the shapes the reads return: job is the job as GetJob returns it, place is a place as it shows there plus place.job, the name of the job it belongs to, and grade is the player's grade as GetGrades returns it (level, label, isboss ...). onSelect returns true or a table (sent back to the client with ok = true), or false and a message to refuse.
A refusal from coerce (to whoever saves the place) or onSelect (to the player at it) is shown as written ("The lift is already up"), or, when it is a key under errors in locales/*.json (lift_up), in the server's language.
Each field is a table:
| Key | Description |
|---|---|
key | Where the value lands in options. The dialog labels a custom kind's field with its key. |
type | text, number, boolean, select, coords, vehicles, attach or json |
min, max, step, unit | For numbers |
max | For text, the most characters |
options | For a select, the values to pick from |
optional | The field may be left empty |
when | { key, value }: show the field only while another field holds that value |
registerPlaceType and askPlace (client)
Client export
exports.lation_jobs:registerPlaceType(type, def) -- boolean
exports.lation_jobs:askPlace(place, action, data) -- { ok, message?, ... }Parameters
def.optionsfunctionoptionaldefaultniloptions(place, view)returns the prompts:{ label, icon, onSelect, canInteract?, hold? }eachdef.setupfunctionoptionaldefaultnilsetup(place, view, record)registers what is not a promptactionstring- Passed to the server's
onSelect datatableoptionaldefaultnil- Passed to the server's
onSelect
view is the job as the client knows it (name, label, kind, grades, places). Every prompt is gated for you on the place's grades and, when the place is on duty only, on the player's duty. A prompt's onSelect that returns false and a message shows the message inline on lation interact, and as a notification on the other systems.
Example: a car lift
exports.lation_jobs:RegisterPlaceType('lift', {
label = 'Car lift',
icon = 'wrench',
fields = {
{ key = 'height', type = 'number', min = 1, max = 5, unit = 'm' }
},
coerce = function(options, job)
local height = tonumber(options.height)
if not height then return nil, 'invalid' end
return { height = math.floor(height) }
end,
onSelect = function(source, place, job, action, data, grade)
if action ~= 'raise' then return false, 'invalid' end
TriggerClientEvent('mylift:raise', source, place.id, place.options.height)
return true
end
})exports.lation_jobs:registerPlaceType('lift', {
options = function(place, view)
return {
{
label = 'Raise the lift',
icon = 'arrow-up',
onSelect = function()
local result = exports.lation_jobs:askPlace(place, 'raise')
if not result.ok then return false, result.message end
return true
end
}
}
end
})Hooks
server/utils/hooks.lua ships open. Edit the functions in place to plug in whitelists, Discord roles, pay rules or outfits.
| Hook | Called | Return |
|---|---|---|
Hooks.canHire(actor, job, identifier, grade) | Before a hire, from the panels or an export | false and an optional message to refuse |
Hooks.canFire(actor, job, identifier) | Before a firing | false and an optional message to refuse |
Hooks.canClockOn(source, job) | Before a player clocks on | false and an optional message to keep them off duty |
Hooks.payrollAmount(job, identifier, grade, minutes, amount) | When payroll has worked out what an employee is owed | The amount to pay, in whole units |
Hooks.onActivity(row) | After every activity row is written | Nothing |
Hooks.outfitFor(job, grade) | When a locker opens, per grade it offers | An outfit to use instead of the grade's, or nil |
Hooks.vehicleSpawned(job, identifier, vehicle) | After a garage spawns a vehicle, with the server entity | Nothing |
actoris the server id of the player making the change, ornil. A job another script hands out through the framework is taken in withoutcanHire.- A message from
canHire,canFireorcanClockOnis shown as written ("You need your badge to clock on"). A key undererrorsinlocales/*.json(no_badge) is shown in the server's language instead: return the key and add its text there. An export caller gets the message as the error code. payrollAmountreturning 0 or less pays nothing this run and carries the minutes to the next, where the hook is asked again with the new minutes added. A grade with no salary earns nothing: when the hook leaves its amount at 0, its minutes are settled.onActivitygets{ job, event, actorIdentifier?, actorName?, targetIdentifier?, targetName?, data?, createdAt }.jobis''for a server-wide row, whoseeventisconfig(a config edit),job_created,job_importedorjob_deleted.outfitForreturns the shape your appearance script applies:{ components, props }forillenium-appearanceandfivem-appearance.
-- Officers need a badge to clock on (ox_inventory)
function Hooks.canClockOn(source, job)
if job == 'police' and exports.ox_inventory:Search(source, 'count', 'police_badge') < 1 then
return false, 'no_badge'
end
return true
end
-- Keep 10% back as tax
function Hooks.payrollAmount(job, identifier, grade, minutes, amount)
return math.floor(amount * 0.9)
end
-- Fill the tank of every garage vehicle (ox_fuel reads this statebag)
function Hooks.vehicleSpawned(job, identifier, vehicle)
Entity(vehicle).state.fuel = 100
end{
"errors": {
"no_badge": "You need your badge to clock on"
}
}Add the key to the existing errors object rather than replacing the file.
Other open code
server/utils/perms.lua:IsAdmin(source)decides who opens/jobadmin.server/bank/providers.lua: one entry per bank (account,ensure,balance,credit,debit, andtransactionswhere the bank has a history to read). A bank of your own is one more entry.bridge/server.luaandbridge/client.lua: the framework, inventory, appearance and keys calls, each with a slot for a custom one.client/utils/interact.luaandclient/utils/notify.lua: the interaction and notification systems, each with a Custom slot that Config > General can select.
Still stuck? Ask in our Discord: the people who write the scripts answer.
Last updated