Lifetime updates included on every script: buy once, keep it running through framework bumps. Browse scripts →

ESX Custom Jobs: Building a Job From Scratch With Grades, Payslips and Society Accounts

ESX Custom Jobs: Building a Job From Scratch With Grades, Payslips and Society Accounts

Your first ESX custom job goes live on a Friday. Three players take it in the first hour, which feels great, and then twenty minutes later two of them are parked in a field outside Sandy Shores, alt-tabbed, collecting a bank deposit every seven minutes for the crime of existing. What you shipped is a pension scheme with a map blip on it.

That is the standard first attempt, and the causes repeat: grades copied without being understood, no duty state, a payout with no cost attached, and nothing else on the server that cares the job exists. So here is one job built end to end, from two SQL rows to a boss menu that pays real wages. You have ESX Legacy running already, so I am skipping install and starting at the database.

The example is a towing company. Swap in whatever you are building.

Where an ESX custom job actually lives

Two database tables, and no Lua file anywhere declares your job exists.

jobs holds three columns:

job_grades is where the real design happens:

The whole registration:

INSERT INTO jobs (name, label, whitelisted) VALUES
    ('towing', 'Towing Company', 1);

INSERT INTO job_grades (job_name, grade, name, label, salary, skin_male, skin_female) VALUES
    ('towing', 0, 'driver',   'Driver',    45,  '{}', '{}'),
    ('towing', 1, 'operator', 'Operator',  75,  '{}', '{}'),
    ('towing', 2, 'boss',     'Owner',     120, '{}', '{}');

QBCore keeps jobs in a shared Lua table you edit and restart, which is why building custom jobs in QBCore is a file edit rather than a query. ESX keeps them in MySQL, so a typo in a grade row stays invisible until someone logs in with it.

How many grades should a job have

Three. Almost always three.

The instinct is to build a ladder: Trainee, Junior Driver, Driver, Senior Driver, Supervisor, Assistant to the Regional Manager, Manager. Seven rows, seven salary numbers to balance, and six of them are titles with no mechanical difference behind them. Players cannot tell them apart and bosses promote on vibes.

A grade earns its row when it unlocks something a lower grade cannot do. Three gives most jobs the shape they need: a base grade that runs the core loop, a middle grade with the better vehicle or higher paying contracts, and a top grade named boss that opens the management menu. Adding a grade later is one INSERT. Removing one after players have held it a month is a support ticket.

Building the resource: manifest, client and server

An ESX custom job resource is a manifest, a config, a client file and a server file. Nothing clever.

fx_version 'cerulean'
game 'gta5'
lua54 'yes'

shared_scripts {
    '@es_extended/imports.lua',
    'config.lua'
}

client_scripts {
    'client/main.lua'
}

server_scripts {
    '@oxmysql/lib/MySQL.lua',
    'server/main.lua'
}

dependencies {
    'es_extended'
}

The @es_extended/imports.lua line gives you the ESX global on both sides, replacing the old TriggerEvent('esx:getSharedObject', ...) dance you still see in five year old tutorials. The @ prefix means "load that resource's file before mine", which is also why the oxmysql line sits above your server file. If that syntax is fuzzy, this walkthrough of what each fxmanifest field does covers the ordering rules properly.

Keep the split honest: the client asks, the server decides.

Getting the player object right on both sides

On the server, one line, every time:

local xPlayer = ESX.GetPlayerFromId(source)

Capture source into a local at the top of the handler, before any Wait or callback, because it is only reliable on the first tick.

On the client you want the job cached and kept fresh:

RegisterNetEvent('esx:playerLoaded', function(xPlayer)
    ESX.PlayerData = xPlayer
end)

RegisterNetEvent('esx:setJob', function(job)
    ESX.PlayerData.job = job
end)

Recent ESX Legacy keeps ESX.PlayerData populated through imports, but wiring these two handlers yourself costs three lines and removes the "which version am I on" question. The esx:setJob one is what people forget, and the symptom is memorable: a player gets hired and their blips do not appear until they relog. The job table itself gives you name, grade, grade_name, grade_label and grade_salary. Compare name and grade in logic, show the labels to humans.

Checking job and grade without trusting the client

Check on both sides, for two completely different reasons.

The client check is a UI convenience: it keeps the depot marker off a civilian's screen. It has zero security value, because the client is a suggestion box anybody can rewrite.

The server check is the actual door. One guard function, used by every event in the resource:

local function getEmployee(src, minGrade)
    local xPlayer = ESX.GetPlayerFromId(src)
    if not xPlayer then return nil end
    if xPlayer.job.name ~= 'towing' then return nil end
    if xPlayer.job.grade < (minGrade or 0) then return nil end
    return xPlayer
end

RegisterNetEvent('towing:completeContract', function()
    local src = source
    local xPlayer = getEmployee(src, 0)
    if not xPlayer then return end
    if not Player(src).state.duty then return end

    local coords = GetEntityCoords(GetPlayerPed(src))
    if #(coords - Config.Depot) > 15.0 then return end

    xPlayer.addAccountMoney('bank', Config.ContractPay, 'Towing contract')
end)

Four gates: employed, senior enough, on duty, and standing where the work happens. That last one gets skipped constantly, and it is what stops a scripter looping your payout event from a beach in Paleto. Add a per player cooldown too, because "on duty and at the depot" still allows firing the event two hundred times a second.

Never pass the reward amount from client to server. The client says "I finished", the server looks up what that is worth.

Duty, and why a job should not pay someone stood in a field

ESX has no built-in duty flag, which is why so many ESX servers pay AFK employees. You add one, and a replicated state bag is the tidiest route:

RegisterNetEvent('towing:toggleDuty', function()
    local src = source
    local xPlayer = getEmployee(src, 0)
    if not xPlayer then return end

    local state = Player(src).state
    state:set('duty', not state.duty, true)
end)

The true replicates it, so the client reads its own status from LocalPlayer.state.duty and other scripts can check anyone's duty without you exporting anything. Clock in at a physical point in the depot rather than through a command, and the roleplay comes free: people have to arrive somewhere before they start earning.

How ESX actually pays a salary

The salary column is not a per hour figure, and nothing in your job resource pays it. ESX Legacy runs a paycheck loop inside es_extended on a timer set by Config.PaycheckInterval, which ships at seven minutes. Every tick it walks the online players and banks each one's current grade_salary.

Where that money comes from is what catches people. If the job has a registered society, ESX pulls the wage out of the society's account, and if that account is empty nobody gets paid, they just get told the company cannot afford them. With no society, the money is created out of nothing.

That is your whole economy question. A job without a society is an inflation faucet running every seven minutes for every player holding it. A job with a society has to earn its own wage bill, which is what makes a boss menu interesting.

The core paycheck also knows nothing about the duty flag you just built. Either keep the salary tiny so it reads as a retainer with the real money in the job loop, or set it to 0 and pay wages yourself, only to players on duty.

Society accounts, the boss menu, and where wage money comes from

A society needs three rows before it exists: money, stash and datastore.

INSERT INTO addon_account (name, label, shared) VALUES ('society_towing', 'Towing Company', 1);
INSERT INTO addon_inventory (name, label, shared) VALUES ('society_towing', 'Towing Company', 1);
INSERT INTO datastore (name, label, shared) VALUES ('society_towing', 'Towing Company', 1);

The shared flag makes it a company pot rather than one player's account, and the balance lives in addon_account_data with a null owner. Then register the society when your resource starts:

TriggerEvent('esx_society:registerSociety', 'towing', 'Towing Company',
    'society_towing', 'society_towing', 'society_towing', { type = 'private' })

The three repeated names are the account, the datastore and the inventory, which is why everyone uses one string for all three. Copy the trailing options table from a stock ESX job resource rather than guessing.

Opening the boss menu from your client is one event:

TriggerEvent('esx_society:openBossMenu', 'towing', function(data, menu)
    menu.close()
end)

Gate it on the client for tidiness, but esx_society re-checks server side that the caller's job matches the society and their grade name is literally boss. That one string is the entire permission model, which is why I told you to name the grade that way.

The menu covers hiring the nearest player, firing, promotion and demotion, and moving money in and out of the society account. Hiring sets the target to your job at grade 0, firing sets them to unemployed grade 0.

Now close the loop, because this is what separates a job from a money button. If your contract payout calls xPlayer.addAccountMoney directly, you minted that cash, the society balance never moves, salaries never pay and the boss menu is decoration. Route income into the society account and let wages come out of it. Now the boss cares whether people work, and employees care whether the boss is any good.

Blips, markers and the job vehicle

Blips are cheap and should be conditional: create them when the job matches, remove them on esx:setJob when it stops.

local blip = AddBlipForCoord(Config.Depot.x, Config.Depot.y, Config.Depot.z)
SetBlipSprite(blip, 477)
SetBlipColour(blip, 5)
SetBlipScale(blip, 0.8)
SetBlipAsShortRange(blip, true)
BeginTextCommandSetBlipName('STRING')
AddTextComponentSubstringPlayerName('Towing Depot')
EndTextCommandSetBlipName(blip)

Markers need a draw loop, and the loop needs to sleep. Draw every frame only when the player is close, Wait(1000) when they are not. Running DrawMarker at 60fps across ten locations for every player is how a job script ends up in someone's resource monitor screenshot.

For the vehicle, spawn from a config coordinate, check the spot is clear with ESX.Game.IsSpawnPointClear, and plate it so you can identify it:

ESX.Game.SpawnVehicle(Config.Vehicle, Config.VehicleSpawn.xyz, Config.VehicleSpawn.w, function(vehicle)
    SetVehicleNumberPlateText(vehicle, 'TOW' .. math.random(1000, 9999))
    TaskWarpPedIntoVehicle(PlayerPedId(), vehicle, -1)
end)

The garage return should check the plate prefix before deleting anything, or players will discover they can store a stolen Kuruma in the tow depot. Funny exactly once.

A testing checklist before you call it done

  1. Hire yourself and check the users table shows the right job and grade.
  2. Relog, and confirm blips, markers and duty state come back rather than only working on first load.
  3. Fire yourself from the boss menu and watch every blip vanish without a relog. That is esx:setJob earning its keep.
  4. On a second account at grade 0, trigger a grade 2 event by hand. It should silently do nothing.
  5. Empty the society account, wait a paycheck tick, and confirm staff are told the company is broke rather than paid anyway.
  6. Run a full contract, then check the society balance moved the way you expected and that the payout refuses you when off duty.
  7. Restart the resource with a player mid-contract, because someone will do this at 2am.

The mistakes that make a custom job feel cheap

Three, and they are all design rather than code.

The first is no reason to be there. The marker sits at a random warehouse because you liked the coordinates. Put the job somewhere with a shape: a yard with a gate, a depot with vehicles in it, a building people recognise. Location does a surprising amount of the work.

The second is instant payouts. Press E, receive money, press E again. Money should cost time, travel or a decision. A payout with no delay in front of it is a slot machine with a guaranteed win, and players stop noticing it by hour two.

The third is a job nobody else needs. If your tow company only earns from an invisible dispatch system, it is a solo grind that happens to have a uniform. Make it produce something another job consumes, or consume something another job produces. That is the difference between a task list and the kind of job script that actually creates roleplay.

Where to go from here

A finished ESX custom job is three grades, a society, a duty toggle and one honest work loop. Ship that, then leave it alone for a week. Most jobs get worse as they get bigger, because the second feature usually exists to paper over the first one not being fun yet.

If you only change one thing today, make it the paycheck gate: salary at 0 on every grade, wages paid on your own timer, and only to players whose duty state is true. Otherwise that field outside Sandy Shores keeps filling up with sleeping millionaires on your payroll.

Related posts

How to Install ESX in 2026: ESX Legacy Setup From Zero to First Job
Guide
How to Install ESX in 2026: ESX Legacy Setup From Zero to First Job
ESX Inventory and Items: Adding Items, Weight, Usable Callbacks and Moving to ox_inventory
Guide
ESX Inventory and Items: Adding Items, Weight, Usable Callbacks and Moving to ox_inventory
Published · Sep 02, 2026 Read more posts →