Ban list: export, backup and interop
Your ban list is your data. This page covers the two directions: getting it out of the anticheat, and plugging in a ban system you already run.
The four directions
| What you want | How | Section |
|---|---|---|
| A copy of my bans, right now | Export button in the panel | 1 |
| A copy that keeps itself up to date, and survives uninstalling the anticheat | Automatic mirror into another resource | 2 |
| The anticheat should pick up the bans my own system already holds | Inbound bridge (file or Lua export) | 4 |
| My system should know when the anticheat bans someone | Outbound events | 5 |
1. Export from the panel
Sanctions, then the Export ban list button. It downloads the complete list for the selected server as JSON.
What the export carries that no screen in the panel shows:
- Every ban in force, with no display cap.
- Every known identifier: Steam, license, Discord, Xbox Live, hardware tokens.
- Imported txAdmin bans that have no Steam ID yet. Those only become sanctions the day the player reconnects. They are real, they are usually the oldest ones, and nothing else in the panel lists them.
2. The automatic mirror on your game server
The anticheat writes the same list, on its own, into another resource on your server. Removing baobab does not touch that file.
Create an empty resource to hold the file. A folder with a minimal fxmanifest.lua is enough. It needs no script at all.
fx_version 'cerulean'
game 'rdr3'Open the server settings in the panel, section Ban list portability, and fill in the outbound mirror: turn it on, name the resource you just created, and pick how often to write.
That is all. Nothing to restart: the resource re-reads the switch every minute, so it takes effect in under a minute. Check it from the server console.
ac:bans:status # where the mirror writes, and how many bans are in it
ac:bans:export # force a write right now
ac:bans:import # force a re-read of your own ban systemFour rules worth knowing
| Rule | Why |
|---|---|
| The target resource must already exist | SaveResourceFile creates no folder, and no native does mkdir. |
| The file setting is a name, not a path | Same reason. A data/bans.json aimed at a resource with no data/ folder would fail on every write, forever, silently. The resource refuses any separator and says so in the console. |
| The target cannot be the anticheat itself | The file would leave with the resource, which defeats the entire point. Refused. |
| An empty list never overwrites a populated mirror | A backend answering "0 bans" is far more often a misconfiguration than a deliberate mass unban, and overwriting is irreversible. The write is refused, loudly. If you really did revoke everything, delete the file by hand. |
3. The format
Self-describing on purpose: a flat array, ISO dates, identifiers named the way RedM names them.
{
"format": "twiste-ac.banlist.v1",
"generatedAt": "2026-08-25T10:00:00.000Z",
"serverId": "...",
"count": 128,
"truncated": false,
"bans": [
{
"steamhex": "110000112345678",
"identifiers": {
"steam": ["110000112345678"],
"license": ["license:deadbeef"],
"discord": ["discord:42"],
"hwid": ["i:abc123"]
},
"reason": "Aimbot",
"bannedAt": "2026-08-01T00:00:00.000Z",
"expiresAt": null,
"permanent": true,
"referenceCode": "ABC-123",
"bannedBy": "staff#1",
"playerName": "Jean",
"source": "sanction"
}
]
}Notes for whoever writes a reader
- Check format before reading bans. A future version will change that value.
- truncated: true means the list went past the 50,000 entry cap and the file is incomplete. It is never cut silently.
- steamhex can be null (an imported ban never matched to a player). Rely on identifiers, never on steamhex alone.
- source is sanction (issued from the panel or in game), imported (came from txAdmin, not matched yet) or local (issued offline, not yet pushed back).
- Dates are ISO 8601 UTC. A null expiresAt with permanent: true means a permanent ban.
4. Plug in the ban system you already run
The other direction: the anticheat picks up bans from an existing system (a home-made script, an exported SQL table, a JSON kept by hand) so they block players just like its own.
By file, without writing a line of code
Your system writes a JSON, the anticheat reads it back. Turn the inbound bridge on in the same panel section, and name the resource and file to read.
By Lua export, if your script prefers to push itself
exports['baobab']:ImportBans({
{ steam = '110000112345678', reason = 'RDM', bannedBy = 'Me' },
{ license = 'license:abcdef', reason = 'Cheating', expiresAt = '2026-12-01T00:00:00Z' },
}, function(ok, res)
print(ok and ('imported: ' .. res.imported) or ('failed: ' .. tostring(res)))
end)The accepted format is deliberately forgiving
You do not have to produce our format. All of these are accepted:
{ "identifiers": { "steam": ["110000..."], "discord": ["42"] } } // our v1 export
{ "identifiers": ["steam:110000...", "discord:42"] } // FiveM-style prefixed list
{ "steamhex": "110000...", "discord": "42" } // flat fields
{ "license": "abc", "message": "RDM", "admin": "me", "created_at": 1785542400 }- Dates: ISO 8601, epoch in seconds or in milliseconds, interchangeably.
- Reason: reason, message or comment. Author: bannedBy, author, admin, issuedBy or by. Expiry: expiresAt, expires or until.
- Lifted bans: a row carrying revoked: true, active: false, isActive: false, revokedAt or unbannedAt is recognised as lifted and is not re-applied. This matters: plugging in a table that keeps its revoked rows would otherwise re-ban every player you already forgave.
- An identifier from an unknown family (fivem: and the like) is dropped, not let through.
What makes repeated reads safe
| Rule | Why |
|---|---|
| Reading the same file twice writes nothing the second time | The key of an imported ban is deterministic: a hash of the sorted identifiers plus the ban date. A referenceCode you supply wins, so an export then import round trip is idempotent. |
| A new ban on the same person is not swallowed as a duplicate | The date differs, so the key differs. |
| Everything is undoable in one click | Each push feeds an import batch visible under Sanctions, with its revert button. External and txAdmin batches never mix. |
| Nothing is written twice on an already banned player | A row whose player already holds a ban in force is classed already_banned and skipped. |
| A failed push stops | A half-written import is harder to recover from than one that never started. The next read retries. |
5. Be told when the anticheat bans someone
The mirror image of the previous section: your own system stays in sync.
AddEventHandler('anticheat:banIssued', function(ban)
-- ban.steamhex, ban.reason, ban.bannedAt, ban.expiresAt,
-- ban.permanent, ban.referenceCode, ban.bannedBy
end)
AddEventHandler('anticheat:banRevoked', function(ban)
-- ban.steamhex
end)Each ban is announced once. A ban lifted then re-issued is announced again.
Catching up when your resource starts
The two events above are a diff: they only carry what changes from now on. A resource that starts, or restarts, after the anticheat reads the snapshot to catch up on the current list.
-- At YOUR resource start: catch up on the list as it stands.
CreateThread(function()
local snap
repeat
Wait(1000)
local ok, res = pcall(function() return exports['baobab']:GetBanList() end)
snap = ok and res or nil
until snap and snap.ready -- ready = false means "I do not know", not "nobody is banned"
for hex, ban in pairs(snap.bans) do
-- ban.reason, ban.bannedAt, ban.expiresAt, ban.permanent,
-- ban.referenceCode, ban.bannedBy, ban.offline
MySystem.Upsert(hex, ban)
end
end)
-- If you start BEFORE the anticheat, it wakes you up (once per boot).
AddEventHandler('anticheat:banListReady', function(snap) --[[ same table ]] end)
-- One-off question, straight from the local cache. Three states, not two.
local r = exports['baobab']:GetBan(hex) -- { ready = true, banned = true, ban = { ... } }For a single question on a connection path, GetBan(hex) answers from the local cache without copying the whole list. It has three states, not two: ready = false means "I do not know", which is not "not banned". For an authoritative answer, keep using the asynchronous IsBannedBySteamhex.
Asking the anticheat a question
These exports already existed and remain the way to query it:
exports['baobab']:IsBannedBySteamhex(hex, function(res) ... end)
exports['baobab']:BanBySteamhex(hex, reason, '24h', 'Me', cb)
exports['baobab']:UnbanBySteamhex(hex, 'Me', cb)6. Every setting on one page
All of it sits in one card: server settings, Ban list portability.
| Setting | Default | Purpose |
|---|---|---|
| Outbound mirror (keep your bans) | Off | Turn the outbound mirror on. |
| Mirror: target resource | - | Resource the file is written into. Must already exist. |
| Mirror: file name | bans.json | File name. No folder. |
| Mirror: write every | 10 | Minutes between two mirror writes. |
| Inbound bridge (plug in your own ban system) | Off | Turn the inbound bridge on. |
| Import: source resource | - | Resource the list is read from. |
| Import: file name | bans.json | File name to read. |
| Import: re-read every | 10 | Minutes between two reads. |
Console commands
| Command | Purpose |
|---|---|
ac:bans:status | Where the mirror writes, how many bans it holds, and the state of the sync. |
ac:bans:export | Force a mirror write now. This is the one to run on your last day, rather than waiting for the next cycle. |
ac:bans:import | Force a re-read of your ban system now. |
Still stuck?
If this page did not answer your question, ask on Discord or write to us. Both reach the people who build Baobab AC.
New to the product? Start with the overview: Baobab anticheat, detections and panel on a single page.