Skip to main content
cc_heistcontracts doesn’t ship any heists — it just runs the marketplace, slot economy, and dashboard around them. The whole integration lives in one RegisterContract call. No separate exports need to live on the heist resource.
This page is for developers implementing their own heists. All of our heists already handles the registering of contracts.

The minimum integration

For a custom heist you control:
exports.cc_heistcontracts:RegisterContract({
    id          = 'cool_bank',
    title       = 'Cool Bank Heist',
    description = "It's a heist. It's cool.",
    difficulty  = 'medium',
    weight      = 1.0,
    levelRequired = 5,
    price       = 5000,
    crewSize    = { min = 2, max = 4 },
    duration    = 1800,
    reward      = { vice = 3000, cash = 25000, xp = 500 },
    locations   = { { id = 'main', label = 'Vinewood Branch' } },
}, function(runtimeData)
    -- Start your heist. runtimeData carries:
    --   contractId, acquiredAt, startedBy, slotIndex, group, location?
    exports.cool_bank:Start({ group = runtimeData.group, location = runtimeData.location })

    -- Listen for the heist's completion event. Registered inside entry_point
    -- so the closure can match against this run's group, and removed after
    -- it fires so handlers don't stack across heists.
    local handler
    handler = AddEventHandler('cool_bank:complete', function(group, success)
        if group ~= runtimeData.group then return end
        RemoveEventHandler(handler)
        exports.cc_heistcontracts:FinishHeist(group, success)
    end)
end, {
    -- Optional. Fires when a player presses Give Up on the dashboard.
    cancel = function(ctx)
        -- ctx = { src, group, contractId, slotIndex }
        -- Tear the heist down for ctx.group. Slots are freed automatically.
        exports.cool_bank:Abort(ctx.group)
    end,
})
That’s the whole integration. Three exports total: RegisterContract (once), FinishHeist (whenever the heist ends, fired from inside the handler above), and optionally ApplyCooldown if the contract has a cooldown set.
Why register the completion handler inside entry_point rather than at the top of the file?
  • It only listens while a heist is actually running.
  • The handler closes over runtimeData.group, so it knows which run the event belongs to. A top-level handler would have to figure that out from the event payload alone.
Removing the handler on first fire (RemoveEventHandler(handler)) prevents the listener list from growing every time the heist starts. The wider pattern is “one handler per run, scoped to that run.”

The three exports

ExportWhen
exports.cc_heistcontracts:RegisterContract(data, entry_point, opts?)On resource start. Registers the contract so it can drip into the marketplace.
exports.cc_heistcontracts:FinishHeist(groupName, success)When the heist ends. Loops every member of groupName and finishes their slot.
exports.cc_heistcontracts:FinishContract(src, success)Single-player variant of FinishHeist. Use when you already have a specific src.
And one optional helper:
ExportWhen
exports.cc_heistcontracts:ApplyCooldown(contractId)When you want to lock the contract server-wide for the configured cooldown.

RegisterContract

Signature

local id = exports.cc_heistcontracts:RegisterContract(data, entry_point, opts?)
data is the contract table. entry_point(runtimeData) is called when a player starts the contract from a slot. opts is an optional table that currently accepts two callbacks:
opts = {
    -- Fired when the contract's cooldown timer expires. Server-wide, fires
    -- once per cooldown cycle, no player context.
    reset  = function(ctx) ... end,    -- ctx = { contractId, firedAt }

    -- Fired when a player presses Give Up on the dashboard. Per-player, with
    -- the group context attached. cc_heistcontracts auto-finishes every group
    -- member's slot as failure after this callback returns.
    cancel = function(ctx) ... end,    -- ctx = { src, group, contractId, slotIndex }
}
Both are optional. The Give Up button on the dashboard is hidden when cancel isn’t set — the integration shouldn’t offer a control that does nothing. RegisterContract returns the contract id.

Field reference

FieldTypeDefaultNotes
idstringStable key. Required unless you pass name (then id = name). Don’t change after release — players’ slot rows reference it.
namestringfalls back to idLegacy display field, kept for back-compat.
titlestringfalls back to nameHeader shown in the dashboard.
descriptionstring''One- or two-line pitch.
difficultystring'medium'Free-form; the dashboard renders it as a chip. Common values: easy, medium, hard.
weightnumber1.0Relative probability of being picked when the marketplace drips a new listing. Higher = more common. Set to 0 to disable from marketplace generation entirely.
levelRequiredint1Server enforces on purchase. The dashboard greys out the buy button below the threshold.
priceint0VICE cost to acquire a slot.
crewSize{ min, max }{ min = 1, max = 4 }Display-only today. Your entry point is responsible for enforcing actual crew size.
durationint (seconds)0Display-only.
reward{ vice, cash, xp }{}vice and xp are awarded automatically by FinishContract/FinishHeist on success. cash is display-only — your entry point handles cash and item payouts.
itemsRequiredarray of { id, qty }{}Display-only. The dashboard shows whether the player has them; nothing is consumed at start.
locationsarray of { id, label }{}If non-empty and the caller didn’t pick one, the server rolls a random entry and threads it into runtimeData.location.
objectivesarray of strings{}Display-only checklist in the dashboard.
cooldownint (seconds)0Server-wide cooldown duration. Stamped only when you call ApplyCooldown. While active, Purchase.Start rejects with 'on_cooldown' for everyone. 0 disables. See Cooldowns.

What happens after registration

1

Contract enters the registry

Stored in memory, keyed by id. Survives until the resource restarts.
2

Marketplace drips a listing

On the next dripIntervalMs tick, the marketplace may pick your contract (weighted by weight) and add a new listing. The same contract can be listed multiple times in parallel.
3

Player buys a listing

The server validates VICE balance, level, and slot availability, debits the price, fills a slot, and removes the listing.
4

Player starts a slot

The server checks the contract isn’t on cooldown and the player isn’t already in another heist, marks the slot active, rolls a random location if you didn’t pass one, and calls your entry_point function.
5

Heist runs

Your code runs. The dashboard, if reopened, shows an “in progress” panel with elapsed time and (if you provided opts.cancel) a Give Up button.
6

Heist ends

Your code calls FinishHeist(group, success) for the whole group, or FinishContract(src, success) for one player. cc_heistcontracts consumes each player’s slot, awards rewards on success, records stats, and pushes a fresh profile to their dashboard.

FinishHeist and FinishContract

exports.cc_heistcontracts:FinishHeist(groupName, success)
exports.cc_heistcontracts:FinishContract(src, success)
Two ways to signal completion:
  • FinishHeist(groupName, success) — call once with a cc_lib.Groups name. Walks every member and finishes their slot.
  • FinishContract(src, success) — single-player variant. Use when you already have a specific source id and don’t want to walk a group.
Both pass true for success or false for failure. What happens on success:
  • The player’s active slot is consumed (gone after this call).
  • reward.vice is credited to their VICE balance with a ledger entry.
  • reward.xp is credited to their profile, advancing level if a threshold is crossed.
  • stats.completed and stats.byContract[id].completed increment.
  • stats.favoriteContract updates if this contract is now their most-completed.
  • A fresh profile snapshot is pushed to the player’s dashboard.
What happens on failure:
  • The slot is consumed.
  • stats.failed and stats.byContract[id].failed increment.
  • No rewards.
Idempotent. A second call for the same player while no heist is active returns (false, 'not_active'). Safe to call defensively from multiple code paths.

Picking your finish moment

Where you call FinishHeist defines what “the heist is over” means for your players. Typical patterns:
  • Objective-driven: when the last objective is completed (cc_lib.TaskUI.Create accepts an onComplete callback that fires when every task hits 'complete').
  • Escape-driven: when the player crosses an escape boundary or hands the goods off.
  • Timer-driven: when the heist’s overall timer runs out — call with success = false.
FinishHeist is a pure server-side state mutation — players don’t see anything happen visually from it alone. Your heist resource is still responsible for the in-world feedback (notifications, animations, world reset).

opts.cancel — handling Give Up

When a player presses Give Up on the dashboard, cc_heistcontracts:
  1. Calls your opts.cancel(ctx) callback. Your job here is to tear the heist down for ctx.group (notify players, hide UI, reset world state).
  2. After the callback returns, automatically finishes every member of ctx.group as failure. You don’t need to call FinishContract yourself for cancellation.
opts = {
    cancel = function(ctx)
        -- ctx.src         — the player who pressed Give Up
        -- ctx.group       — the cc_lib.Groups name for the active heist
        -- ctx.contractId  — id of the contract being cancelled
        -- ctx.slotIndex   — 1-based slot the contract was in

        -- Tell everyone, then tear the in-world heist down.
        local cc = exports.cc_lib:GetLib()
        for _, member in pairs(cc.Groups.GetMembers(ctx.group)) do
            cc.Notification.Info(member, 'Heist cancelled')
        end
        exports.cool_bank:Abort(ctx.group)
    end,
}
If you don’t provide opts.cancel, the Give Up button is hidden on the dashboard’s active-heist panel. The player can still finish the heist normally, or wait it out — they just can’t bail mid-run. This is the right default for heists that don’t have a clean abort path.

Why this isn’t an export on your resource

opts.cancel is a closure registered alongside your contract data. It lets a third-party heist integration live entirely inside a single wrapper file — the wrapper doesn’t need to add an export to the escrowed heist resource (which you couldn’t modify anyway).

Granting rewards

reward.vice and reward.xp from your contract definition are credited automatically on success. You don’t need to call Profile.AddVice or Profile.AddXp yourself for those. reward.cash is not handled by cc_heistcontracts — it’s there for the dashboard’s display only. Cash and inventory payouts are your responsibility:
AddEventHandler('cool_bank:complete', function(group, success)
    if success then
        local cc = exports.cc_lib:GetLib()
        for _, member in pairs(cc.Groups.GetMembers(group)) do
            -- Cash + items: your code, your core.
            cc.Core.AddMoney(member, 'cash', 12000)
            cc.Inventory.AddItem(member, 'cash_stack', 1)
        end
    end
    -- VICE + XP + stats: cc_heistcontracts handles them.
    exports.cc_heistcontracts:FinishHeist(group, success)
end)

Cooldowns

Cooldowns are server-wide per contract, not per player. Set cooldown = <seconds> on the contract and call ApplyCooldown from your entry point when the heist starts (or when it ends — your choice). While the cooldown is active, Purchase.Start rejects with 'on_cooldown' for every player. Buying is not blocked — players can still acquire a slot during cooldown, they just can’t start it until the timer ends.
exports.cc_heistcontracts:RegisterContract({
    id = 'paleto_bank',
    -- ...
    cooldown = 3600, -- 1 hour, server-wide
}, function(runtimeData)
    exports.cc_heistcontracts:ApplyCooldown(runtimeData.contractId)
    -- ...run the heist...
end, {
    reset = function(ctx)
        -- ctx = { contractId, firedAt }
        -- Fires once, server-wide, when the cooldown ends. Use this to
        -- restore world state for the next run (respawn props, reset NPCs,
        -- clear pending alerts, etc.).
    end,
})

ApplyCooldown export

exports.cc_heistcontracts:ApplyCooldown(contractId)
Stamps the configured cooldown on contractId and schedules the reset callback. Returns the unix-seconds expiry timestamp, or 0 if the contract has no cooldown configured (or the id is unknown). Calling it again before the timer expires re-stamps from “now” — the previously-scheduled reset will silently no-op.

Behaviour notes

  • In-memory only. Cooldowns do not persist across resource restarts.
  • Single timer per contract. reset fires exactly once per cooldown cycle, even if ApplyCooldown is called multiple times during the cycle.
  • No automatic apply. cooldown on the contract definition is just a duration — nothing happens until your entry point calls ApplyCooldown.
  • Surfaced in the rotation payload. Active cooldowns ship to the dashboard as rotation.cooldowns = { [contractId] = expiresAtUnixSeconds }, so the UI can grey out cooldowned cards.

reset vs. cancel

Both are callbacks you can register in opts. They fire on different events and have different jobs:
resetcancel
TriggerCooldown expiry timerPlayer pressed Give Up
ScopeServer-wide, world statePer-player, the active run
Fires perCooldown cycle (once)Cancellation (once per Give Up)
Context{ contractId, firedAt }{ src, group, contractId, slotIndex }
Typical jobRespawn props, re-arm alarms, reset NPCs so the next run starts freshNotify the crew, hide the task UI, abort the in-world heist for ctx.group
A heist can have both, one, or neither.

Integrating a third-party (escrowed) heist

The same shape works for heists you didn’t write — including ones whose source is escrowed. Create a small wrapper resource (call it myserver_heists) with a single Lua file:
-- myserver_heists/server/cool_bank.lua
CreateThread(function()
    Wait(2000) -- let cc_heistcontracts boot

    exports.cc_heistcontracts:RegisterContract({
        id    = 'cool_bank',
        title = 'Cool Bank Heist',
        -- ...the contract fields...
    }, function(runtimeData)
        -- Start the escrowed heist however its docs tell you to.
        exports.cool_bank:Start({ group = runtimeData.group })

        -- Hook the heist's completion event for this run, then drop the
        -- handler once it fires so listeners don't pile up across heists.
        local handler
        handler = AddEventHandler('cool_bank:complete', function(group, success)
            if group ~= runtimeData.group then return end
            RemoveEventHandler(handler)
            exports.cc_heistcontracts:FinishHeist(group, success)
        end)
    end, {
        cancel = function(ctx)
            -- Call whatever abort/cancel the escrowed heist exposes.
            if exports.cool_bank.Abort then
                exports.cool_bank:Abort(ctx.group)
            end
        end,
    })
end)
That’s the entire wrapper. No edits to the escrowed heist itself. What if the escrowed heist doesn’t expose an abort? Omit opts.cancel. The Give Up button will be hidden on the dashboard — players have to finish or wait it out. What if the escrowed heist doesn’t fire a completion event? That’s the one case you genuinely can’t wrap around. Players’ slots will get stuck “active” because nothing ever calls FinishHeist. Ask the escrowed heist’s author to add an event. Add cc_heistcontracts and cc_lib to your wrapper’s fxmanifest.lua dependencies so boot order is correct:
dependencies {
    'ox_lib',
    'cc_heistcontracts',
    'cc_lib',
}