F5 StudioF5 Studio
Skip to main content

Discord Webhooks

F5 Board ships with a full Discord audit-logging pipeline. Every meaningful action — board placed, renamed, edited (with a screenshot), or removed — is dispatched to a Discord webhook as a rich embed.

This page documents the entire discord_webhook_config.lua file.

Server-only

config/discord_webhook_config.lua is loaded only on the server. Webhook URLs never reach the client. Do not move them into other config files.

Events

EventTriggerDefault color
createA board is placed🟢 0x2ecc71
renameThe owner renames a board🟡 0xf1c40f
editAn editor session ends with at least one mutation. Embed includes a screenshot🟣 0x9b59b6
deleteThe owner collects a board, OR an admin force-removes it via /badmin🔴 0xe74c3c

Minimal Setup

The fastest way to enable logging — one webhook for everything:

config/discord_webhook_config.lua
Webhooks.defaultUrl = 'https://discord.com/api/webhooks/XXXXXXXXXX/YYYYYYYYYY'

Webhooks.events = {
create = { enabled = true, color = 0x2ecc71, url = '' },
rename = { enabled = true, color = 0xf1c40f, url = '' },
edit = { enabled = true, color = 0x9b59b6, url = '' },
delete = { enabled = true, color = 0xe74c3c, url = '' },
}

That's it. All four events go to defaultUrl.

Per-Event Webhook URLs

Each event can target a different channel by setting its own url:

config/discord_webhook_config.lua
Webhooks.defaultUrl = ''   -- no default

Webhooks.events = {
create = { enabled = true, color = 0x2ecc71, url = 'https://discord.com/api/webhooks/.../create_channel' },
rename = { enabled = true, color = 0xf1c40f, url = 'https://discord.com/api/webhooks/.../rename_channel' },
edit = { enabled = true, color = 0x9b59b6, url = 'https://discord.com/api/webhooks/.../edit_channel' },
delete = { enabled = true, color = 0xe74c3c, url = 'https://discord.com/api/webhooks/.../delete_channel' },
}

Resolution order for the target URL:

  1. events[name].url (per-event override)
  2. Webhooks.defaultUrl
  3. If neither is set → event is silently skipped (warning is logged when Config.Debug.categories.WEBHOOK = true)

Disabling a Single Event

Webhooks.events.edit.enabled = false

Disabling the master switch on an event drops it before queue insertion — saves a request even if a URL is set.

Bot Identity

Webhooks.username  = 'f5_board logger'
Webhooks.avatarUrl = '' -- public image URL; empty = Discord default

Sent with every message. Discord can override per-message (e.g. through the webhook's channel-specific avatar in Discord settings).

Embed Content (Localized)

Embed labels live in locales/<code>.lua under webhook_* keys. The language follows Config.Locale. Customize there — not in the dispatcher.

locales/en.lua (excerpt)
webhook_title_create        = 'Board placed',
webhook_title_rename = 'Board renamed',
webhook_title_edit = 'Board edited',
webhook_title_delete = 'Board removed',
webhook_title_delete_admin = 'Board removed by admin',

webhook_field_board = 'Board',
webhook_field_model = 'Model',
webhook_field_player = 'Player',
webhook_field_owner = 'Owner',
webhook_field_admin = 'Admin',
webhook_field_steam = 'Steam',
webhook_field_discord = 'Discord',
webhook_field_coords = 'Coords',
webhook_field_location = 'Location',
webhook_field_name_old = 'Previous name',
webhook_field_name_new = 'New name',
webhook_footer = 'f5_board',

Each embed always includes:

  • Title — colored by events[name].color
  • Board#<id> · <name>
  • Model — the prop model
  • Player / Owner / Admin — name + citizen ID, plus Steam HEX and Discord ID
  • Coordsx, y, z
  • Location — street + zone (resolved from GTA natives)
  • Timestamp — UTC

The edit event additionally attaches a screenshot of the board state at session end.

Screenshot Pipeline (edit event only)

When an edit session ends with at least one mutation, the client:

  1. Renders the board content into an offscreen NUI canvas at 1024 × 1024.
  2. Encodes the canvas as a JPEG of quality 0.7.
  3. Has an 8 s budget to deliver the result; on timeout the edit event is dropped.
  4. Sends the base64 payload to the server.
  5. Server decodes and attaches it as a multipart file to the Discord webhook (handled by a Node.js uploader, see Image Upload Runtime).
config/discord_webhook_config.lua
Webhooks.screenshot = {
width = 1024,
height = 1024,
jpegQuality = 0.7,
maxBase64Bytes = 7 * 1024 * 1024,
captureTimeoutMs = 8000,
}
OptionDefaultDescription
maxBase64Bytes7 MiBMax base64 payload the server will accept from a client. Larger payloads are rejected and the edit webhook is dropped. Base64 inflates raw bytes ~33%, so 7 MiB base64 ≈ 5.25 MiB raw image
width / height1024 / 1024Documents the render resolution used by the client (informational)
jpegQuality0.7Documents the JPEG quality used by the client (informational)
captureTimeoutMs8000Documents the client-side render budget (informational)
Effective vs informational

Only maxBase64Bytes is a server-side guard you can actually tune. The other four fields document the values the client uses but are not themselves read by the client — render size, quality and timeout are fixed at 1024 × 1024 / 0.7 / 8000 ms.

Sizing

The fixed defaults (1024², 0.7 quality) typically produce ~150–400 KiB images. The 7 MiB cap is a hard ceiling against runaway payloads. Lower it if you want stricter input validation; raise it only if you regularly trip the cap on legitimate boards.

Edit Session Tracking

The server tracks edit sessions in memory so a single embed represents an "editor opened → did stuff → closed" arc, not one per mutation.

config/discord_webhook_config.lua
Webhooks.session = {
hardTtlMs = 60 * 60 * 1000, -- 1h
idleTtlMs = 15 * 60 * 1000, -- 15min
}
OptionDefaultDescription
hardTtlMs1 hHard upper bound on a single edit session. After this the server force-ends it (still emits the embed if changes happened) and drops state
idleTtlMs15 minSoft idle TTL — a session with no mutations for this long auto-ends

A session is "discarded silently" if the player closed the editor with no mutations — no embed is sent.

Image Cache

Image-variant notes carry external URLs. To avoid CORS issues during the offscreen NUI render (for the edit-event screenshot, and during normal rendering), the server pre-fetches each unique URL, encodes it as data:image/...;base64,..., and ships it inline in the state payload.

config/discord_webhook_config.lua
Webhooks.imageCache = {
enabled = true,
ttlMs = 5 * 60 * 1000, -- 5min
maxEntries = 200,
maxBytesPerImg = 4 * 1024 * 1024,
maxTotalBytes = 100 * 1024 * 1024,
fetchTimeoutMs = 4000,
}
OptionDefaultDescription
enabledtrueMaster switch. false → URLs ship as-is; client must fetch directly (likely fails for offscreen render)
ttlMs5 minPer-entry TTL. After this the URL is re-fetched on demand
maxEntries200LRU eviction beyond this count
maxBytesPerImg4 MiBSkip oversized remote images
maxTotalBytes100 MiBLRU eviction beyond this total cache size
fetchTimeoutMs4000Per-image HTTP timeout
Keep it on

Disabling the cache disables embedded screenshots for any board that contains image notes — the canvas can't draw <img src="https://..."> when the offscreen NUI has no network. Leave enabled = true unless you have a very specific reason.

Queue / Rate Limiting

Discord webhooks are rate-limited to ~5 requests / second. F5 Board has a built-in queue + rate limiter:

config/discord_webhook_config.lua
Webhooks.queue = {
tickMs = 250,
maxSize = 200,
retryAfterMaxSec = 30,
requestTimeoutMs = 10000,
retry5xxOnce = true,
}
OptionDefaultDescription
tickMs250Min interval between outbound HTTP requests (~4/s, safely under Discord's 5/s)
maxSize200Max events buffered in the queue. Beyond this the oldest event is dropped
retryAfterMaxSec30Upper clamp on Discord's Retry-After header. Beyond this the event is dropped rather than blocking the queue indefinitely
requestTimeoutMs10000Per-request HTTP timeout (callback fallback if Discord never responds)
retry5xxOncetrueIf Discord returns 5xx, retry once after the regular tick, then drop. 4xx (except 429) drops immediately

Behaviour summary:

HTTP responseAction
2xxSuccess, drop from queue
429 (rate limited)Honor Retry-After (clamped to retryAfterMaxSec), keep in queue
4xx (other)Drop with a warning log
5xxRetry once (if retry5xxOnce), then drop

URL Validation

config/discord_webhook_config.lua
Webhooks.urlValidationPattern = '^https://[%w%.%-]*discord[%w]*%.com/api/webhooks/'

The script soft-validates URLs against this Lua pattern at startup. Mismatches warn rather than hard-fail, so people behind reverse proxies still work.

Accepted forms:

  • https://discord.com/api/webhooks/<id>/<token>
  • https://discordapp.com/api/webhooks/... (legacy)
  • https://canary.discord.com/api/webhooks/...
  • https://ptb.discord.com/api/webhooks/...

Image Upload Runtime (Node.js)

Sending a JPEG to Discord requires multipart/form-data — and FiveM Lua's PerformHttpRequest cannot reliably send binary bodies (the V8/CEF bridge re-interprets bodies as UTF-8 and Discord rejects the multipart with HTTP 400).

F5 Board solves this with a small Node.js uploader (server/webhook/uploader.js) running in the FiveM server's Node runtime. The Lua queue forwards type='upload' jobs to it via cross-runtime events.

You don't need to install Node — FiveM ships its own runtime.

config/discord_webhook_config.lua
Webhooks.uploader = {
resultTimeoutMs = 15000,
}
OptionDefaultDescription
resultTimeoutMs15000Soft per-job timeout (ms). The Lua worker drops the job if no result arrives in this window — covers a frozen runtime or a stuck fetch

Common Recipes

Single audit channel

Webhooks.defaultUrl = 'https://discord.com/api/webhooks/.../audit'
Webhooks.events.create.enabled = true
Webhooks.events.rename.enabled = true
Webhooks.events.edit.enabled = true
Webhooks.events.delete.enabled = true

Only "delete" goes to a high-visibility staff channel

Webhooks.defaultUrl = 'https://discord.com/api/webhooks/.../audit-general'
Webhooks.events.delete.url = 'https://discord.com/api/webhooks/.../staff-deletes'

Disable screenshots, keep text events

Webhooks.events.edit.enabled = false

When the screenshot is dropped client-side (e.g. on a slow machine where the offscreen render exceeds the 8 s budget), the edit event is dropped too — by design.

Low-volume server — minimize overhead

Webhooks.queue.tickMs     = 1000    -- 1 req/s is plenty
Webhooks.imageCache.ttlMs = 60 * 60 * 1000 -- 1h cache for image notes

High-volume server — protect against runaway queues

Webhooks.queue.maxSize           = 500
Webhooks.queue.retryAfterMaxSec = 10 -- give up faster on 429s
Webhooks.screenshot.maxBase64Bytes = 4 * 1024 * 1024 -- tighter input cap (4 MiB)

Disabling Everything

Two ways to fully silence the webhook system:

Method 1 — no URL anywhere
Webhooks.defaultUrl = ''
Webhooks.events.create.url = ''
Webhooks.events.rename.url = ''
Webhooks.events.edit.url = ''
Webhooks.events.delete.url = ''
Method 2 — disable all events
Webhooks.events.create.enabled = false
Webhooks.events.rename.enabled = false
Webhooks.events.edit.enabled = false
Webhooks.events.delete.enabled = false

Either way produces zero outbound requests. Method 2 is cheaper because events are dropped before queue insertion.

Verifying

Enable webhook debug logs to see exactly what's happening:

config/config.lua
Config.Debug.enabled = true
Config.Debug.categories.WEBHOOK = true

You'll see lines like:

[SERVER][WEBHOOK] session start src=12 board=7 baseline=...
[SERVER][WEBHOOK] enqueue type=json kind=create tag=7 queue=1
[SERVER][WEBHOOK] sent ok type=json kind=create tag=7 status=204
[SERVER][WEBHOOK] [WARN] rate-limited retry-after=1.5s kind=create tag=7
[SERVER][WEBHOOK] edit skip mutationCount=0 board=7
[SERVER][WEBHOOK] edit skip no screenshot board=7

See Also