On this page
Ban list

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.

No lock-in, and no paywall
Everything on this page works on every plan, including none. A ban list produced by your staff belongs to you, so holding it hostage in an internal format is not something we do.

The four directions

What you wantHowSection
A copy of my bans, right nowExport button in the panel1
A copy that keeps itself up to date, and survives uninstalling the anticheatAutomatic mirror into another resource2
The anticheat should pick up the bans my own system already holdsInbound bridge (file or Lua export)4
My system should know when the anticheat bans someoneOutbound events5

1. Export from the panel

Sanctions, then the Export ban list button. It downloads the complete list for the selected server as JSON.

Not the same as Export CSV
The CSV button next to it reproduces the table on screen and stops at 500 rows. This one is the whole list, with every identifier.

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.
IP addresses are never exported
An IP from 2023 belongs to somebody else in 2026. Putting it in a file you will plug in elsewhere, away from the rest of the system, would hand you a way to ban an innocent player.

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.

resources/[local]/mes_bans/fxmanifest.lua
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.

Nothing goes into server.cfg
These settings live in the panel, not in a file. The only variable that stays in your server.cfg is the licence key. Changing a destination folder should not cost you a server restart.

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.

bash
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 system

Four rules worth knowing

RuleWhy
The target resource must already existSaveResourceFile creates no folder, and no native does mkdir.
The file setting is a name, not a pathSame 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 itselfThe file would leave with the resource, which defeats the entire point. Refused.
An empty list never overwrites a populated mirrorA 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.
What happens when your subscription ends
Any answer that is not a 200 leaves the file untouched. The day the API stops replying, the last good file simply stays on disk.
The mirror enforces nothing
It is a data file, not an enforcement path: nobody is refused a connection because it exists. That is deliberate. The goal is that you keep your data, not to suggest an anticheat keeps running without an anticheat. To act on those bans later, you need a script that reads the file at playerConnecting. The format below is built for that.

3. The format

Self-describing on purpose: a flat array, ISO dates, identifiers named the way RedM names them.

bans.json
{
  "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

lua
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:

json
{ "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

RuleWhy
Reading the same file twice writes nothing the second timeThe 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 duplicateThe date differs, so the key differs.
Everything is undoable in one clickEach 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 playerA row whose player already holds a ban in force is classed already_banned and skipped.
A failed push stopsA half-written import is harder to recover from than one that never started. The next read retries.
What the bridge does not do
It takes bans in, it does not write yours back out to your system. For that, see the events below. And it never deletes anything on your side.

5. Be told when the anticheat bans someone

The mirror image of the previous section: your own system stays in sync.

lua
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)
A ban issued from the panel on an offline player is covered
The events come from a diff of the synced list, not from each banning point. A ban that never reaches the game would otherwise be missed. The price is a latency bounded by the sync cycle (60 s) for those. A ban issued in game is announced immediately.
Restarting the resource emits nothing
The first pass primes silently. Without that guard, every restart would replay banIssued for the whole list, and a system plugged into it would re-ban everyone.

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.

lua
-- 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 = { ... } }
Check ready before you reconcile
Until a list has actually been loaded (first boot, backend unreachable, no on-disk cache), the snapshot returns ready = false and an empty table. An empty list is not an answer: reconciling against it would tell your system that nobody is banned any more. The loop above waits for ready, and the pcall covers the case where the anticheat has not started yet.

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.

Your own bans come back to you
A ban you issue through BanBySteamhex is announced back by banIssued on the next sync: the events are emitted from a diff, which does not know who asked. Key your writes on the steamhex or the reference code and they will simply be idempotent.

Asking the anticheat a question

These exports already existed and remain the way to query it:

lua
exports['baobab']:IsBannedBySteamhex(hex, function(res) ... end)
exports['baobab']:BanBySteamhex(hex, reason, '24h', 'Me', cb)
exports['baobab']:UnbanBySteamhex(hex, 'Me', cb)
Fixed on 25 August 2026
IsBanned and IsBannedBySteamhex used to answer "not banned" for a player under a permanent ban as soon as an expired temporary ban had been issued after it: they only looked at the most recent sanction. They also ignored revokedAt, so a lifted ban could answer "banned". If you plugged a whitelist into them before that date, it was letting permanently banned players through.

6. Every setting on one page

All of it sits in one card: server settings, Ban list portability.

SettingDefaultPurpose
Outbound mirror (keep your bans)OffTurn the outbound mirror on.
Mirror: target resource-Resource the file is written into. Must already exist.
Mirror: file namebans.jsonFile name. No folder.
Mirror: write every10Minutes between two mirror writes.
Inbound bridge (plug in your own ban system)OffTurn the inbound bridge on.
Import: source resource-Resource the list is read from.
Import: file namebans.jsonFile name to read.
Import: re-read every10Minutes between two reads.

Console commands

CommandPurpose
ac:bans:statusWhere the mirror writes, how many bans it holds, and the state of the sync.
ac:bans:exportForce a mirror write now. This is the one to run on your last day, rather than waiting for the next cycle.
ac:bans:importForce 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.