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.
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
| Event | Trigger | Default color |
|---|---|---|
| create | A board is placed | 🟢 0x2ecc71 |
| rename | The owner renames a board | 🟡 0xf1c40f |
| edit | An editor session ends with at least one mutation. Embed includes a screenshot | 🟣 0x9b59b6 |
| delete | The 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:
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:
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:
events[name].url(per-event override)Webhooks.defaultUrl- 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.
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
- Coords —
x, 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:
- Renders the board content into an offscreen NUI canvas at 1024 × 1024.
- Encodes the canvas as a JPEG of quality 0.7.
- Has an 8 s budget to deliver the result; on timeout the edit event is dropped.
- Sends the base64 payload to the server.
- Server decodes and attaches it as a multipart file to the Discord webhook (handled by a Node.js uploader, see Image Upload Runtime).
Webhooks.screenshot = {
width = 1024,
height = 1024,
jpegQuality = 0.7,
maxBase64Bytes = 7 * 1024 * 1024,
captureTimeoutMs = 8000,
}
| Option | Default | Description |
|---|---|---|
maxBase64Bytes | 7 MiB | Max 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 / height | 1024 / 1024 | Documents the render resolution used by the client (informational) |
jpegQuality | 0.7 | Documents the JPEG quality used by the client (informational) |
captureTimeoutMs | 8000 | Documents the client-side render budget (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.
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.
Webhooks.session = {
hardTtlMs = 60 * 60 * 1000, -- 1h
idleTtlMs = 15 * 60 * 1000, -- 15min
}
| Option | Default | Description |
|---|---|---|
hardTtlMs | 1 h | Hard upper bound on a single edit session. After this the server force-ends it (still emits the embed if changes happened) and drops state |
idleTtlMs | 15 min | Soft 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.
Webhooks.imageCache = {
enabled = true,
ttlMs = 5 * 60 * 1000, -- 5min
maxEntries = 200,
maxBytesPerImg = 4 * 1024 * 1024,
maxTotalBytes = 100 * 1024 * 1024,
fetchTimeoutMs = 4000,
}
| Option | Default | Description |
|---|---|---|
enabled | true | Master switch. false → URLs ship as-is; client must fetch directly (likely fails for offscreen render) |
ttlMs | 5 min | Per-entry TTL. After this the URL is re-fetched on demand |
maxEntries | 200 | LRU eviction beyond this count |
maxBytesPerImg | 4 MiB | Skip oversized remote images |
maxTotalBytes | 100 MiB | LRU eviction beyond this total cache size |
fetchTimeoutMs | 4000 | Per-image HTTP timeout |
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:
Webhooks.queue = {
tickMs = 250,
maxSize = 200,
retryAfterMaxSec = 30,
requestTimeoutMs = 10000,
retry5xxOnce = true,
}
| Option | Default | Description |
|---|---|---|
tickMs | 250 | Min interval between outbound HTTP requests (~4/s, safely under Discord's 5/s) |
maxSize | 200 | Max events buffered in the queue. Beyond this the oldest event is dropped |
retryAfterMaxSec | 30 | Upper clamp on Discord's Retry-After header. Beyond this the event is dropped rather than blocking the queue indefinitely |
requestTimeoutMs | 10000 | Per-request HTTP timeout (callback fallback if Discord never responds) |
retry5xxOnce | true | If Discord returns 5xx, retry once after the regular tick, then drop. 4xx (except 429) drops immediately |
Behaviour summary:
| HTTP response | Action |
|---|---|
2xx | Success, drop from queue |
429 (rate limited) | Honor Retry-After (clamped to retryAfterMaxSec), keep in queue |
4xx (other) | Drop with a warning log |
5xx | Retry once (if retry5xxOnce), then drop |
URL Validation
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.
Webhooks.uploader = {
resultTimeoutMs = 15000,
}
| Option | Default | Description |
|---|---|---|
resultTimeoutMs | 15000 | Soft 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:
Webhooks.defaultUrl = ''
Webhooks.events.create.url = ''
Webhooks.events.rename.url = ''
Webhooks.events.edit.url = ''
Webhooks.events.delete.url = ''
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.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
- Features → Discord Audit Trail — what each event records
- Configuration → Debug — enabling the
WEBHOOKcategory - Localization — translating the embed labels
- Troubleshooting — when embeds don't show up