Discord Webhooks
F5 Combat HUD has exactly one Discord integration: the marketplace log. Eight events — three by players, five by moderators — reach Discord as rich embeds, optionally with a rendered picture of the crosshair involved.
Nothing else is logged to Discord. Kills, killstreaks, profile saves and settings changes never leave your server.
Where It Lives
F5Cfg.MarketplaceWebhook = {
enabled = true,
url = 'CHANGE_ME',
botName = 'F5 Combat HUD',
avatarUrl = '',
attachPreview = true,
colors = { ... },
logEvents = { ... },
footerText = 'F5 Combat HUD — Marketplace',
footerIcon = '',
}
server/config_server.lua is listed under server_scripts and nowhere else, so the URL is never downloaded by a client. The only configuration the NUI ever receives is a hand-written whitelist of booleans that contains no webhook fields. Never move this table into config/ — those files are shared scripts and every client would get your webhook URL.
Setup
- In Discord: Server Settings → Integrations → Webhooks → New Webhook, pick a channel, copy the URL.
- Paste it into
url. - Restart the resource.
F5Cfg.MarketplaceWebhook = {
enabled = true,
url = 'https://discord.com/api/webhooks/123456789/abcdef...',
}
Until you do, the server says so once at startup and sends nothing:
[f5_combathud] MarketplaceWebhook.enabled = true, but url is still the placeholder — no marketplace event will reach Discord. Paste the webhook url in server/config_server.lua, or set enabled = false to silence this.
That warning fires for the literal 'CHANGE_ME', for an empty string, and when url is not a string at all. Any other value passes — there is no check that the URL is even a Discord address. A mistyped URL produces no startup warning; you only find out from one yellow line per dropped event (see Failures).
There is also no way to test a webhook from in game — no command, no button. The only proof it works is a real marketplace action arriving in the channel.
Options
| Option | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Master switch. Any value other than false/nil counts as on |
url | string | 'CHANGE_ME' | The Discord webhook URL. 'CHANGE_ME' and '' mean "not configured" |
botName | string | 'F5 Combat HUD' | Sent as the webhook username. Not validated |
avatarUrl | string | '' | Sent as avatar_url. Always sent, even when empty |
attachPreview | boolean | true | Attach a rendered PNG of the crosshair. Only the literal false turns it off |
colors | table | see below | Embed accent colour per event, as a decimal integer |
logEvents | table | all true | Which events reach Discord. Compared with == true |
footerText | string | 'F5 Combat HUD — Marketplace' | Prefix of the embed footer, followed by | and the server-local date |
footerIcon | string | '' | Footer icon URL |
logEvents is strict, everything else is looselogEvents.publish = 1 or = 'yes' disables that event — only the boolean true enables it. enabled, by contrast, treats any truthy value as on. If the whole logEvents table is missing, nothing is ever sent.
Events and colours
| Event | Fired by | Default colour |
|---|---|---|
publish | A player publishing a crosshair | 3066993 — green |
update | A player editing their listing | 3447003 — blue |
unpublish | A player removing their own listing | 15158332 — red |
admin_remove | A moderator deleting a listing | 10038562 — dark red |
admin_hide | A moderator hiding a listing | 15105570 — orange |
admin_unhide | A moderator restoring a listing | 3066993 — green |
admin_ban | A moderator banning a player from the marketplace | 10038562 — dark red |
admin_unban | A moderator lifting a ban | 3447003 — blue |
Colours are decimal, not hex strings: 0x2ECC71 is written 3066993. An event with no entry in colors uses Discord blurple (7506394).
To log moderation only:
logEvents = {
publish = false,
update = false,
unpublish = false,
admin_remove = true,
admin_hide = true,
admin_unhide = true,
admin_ban = true,
admin_unban = true,
},
What an Embed Contains
Every embed opens with the same three inline fields, describing the player who performed the action:
| Field | Content |
|---|---|
| 🎮 Player | In-game name and server id |
| 🆔 Citizen ID | citizenid on QBCore / QBox, identifier on ESX |
| 👤 Character | The character's name |
and closes with 🔑 Identifiers — Steam, License, Discord and FiveM, with their prefixes stripped. Missing ones read N/A; the Discord id is rendered as a mention that does not ping (allowed_mentions is empty).
Between them come the event's own fields:
| Event | Extra fields |
|---|---|
publish, update | 📋 Listing (id + title), 📁 Type, 📝 Description (first 200 bytes), 🎯 Crosshair |
unpublish | 📋 Listing, 📁 Type, 🎯 Crosshair |
admin_remove, admin_hide, admin_unhide | 📋 Listing, 📁 Type, 👤 Listing Owner, 🎯 Crosshair |
admin_ban | 🚫 Target, 📝 Reason, ⏳ Duration, 🗑️ Listings removed (how many of their listings were deleted) |
admin_unban | 🚫 Target (the target's license) |
⏳ Duration is one of 1 days, 3 days, 7 days, 30 days, 90 days or permanent — the ban callback refuses any other length before anything is written or logged.
🎮 Player, 🆔 Citizen ID, 👤 Character and 🔑 Identifiers always describe who acted. The affected player appears as 👤 Listing Owner or 🚫 Target. Reading the identifier block as "the offender" is the classic mistake.
The 🎯 Crosshair field
The crosshair is described in words, so a moderator can judge a listing without opening the game:
Type **CROSS** • Size 14 • Thickness 2 • Gap 4
Colour `#00FFFF` • Outline `#000000` (1px)
Effects: dynamic, glow, rainbow
Recognised effect words: center dot, dynamic, rainbow, glow, HP colour, the animation name, ADS only, hidden on ADS. A custom-image crosshair is described instead as Custom image (external URL | uploaded image) • Size N • Opacity N% • Rotation N°.
For publish and update the description is built from the profile the server just validated and stored; for unpublish and the moderation events it is rebuilt from the listing row in the database. Either way it is never text the client supplied.
Crosshair Preview Images
With attachPreview = true most embeds carry a 200×200 PNG of the crosshair, uploaded as crosshair.png.
How it gets there: the acting player's NUI renders it on a canvas and base64-encodes it; the server decodes it and attaches it to the POST. The server accepts it only if:
- the base64 string is at most 196 608 characters,
- the decoded image is between 1 byte and 96 KB,
- and the bytes start with a valid PNG signature.
Anything else is dropped silently and the text-only embed is sent.
Because the image is produced client-side, a modified client could attach a picture of its own choosing — it would end up permanently hosted on Discord's CDN in your channel. The server checks the size and the PNG header, nothing else. Set attachPreview = false if you would rather not have player-supplied images in your Discord; the written description of the crosshair is sent either way.
Three cases produce no image even when attachPreview is on:
- Custom-image crosshairs — the NUI does not render a preview for them.
admin_ban/admin_unban— those events never carry a picture.- A listing the acting client has not got loaded — unpublish and the moderation events draw the picture from the listing the client currently has in view, so an action taken on a listing that is no longer in its cached page arrives as text only.
Delivery
| Behaviour | Value |
|---|---|
| Batching | None — one event, one POST |
| Spacing | 250 ms between two posts to the same URL |
| Attempts | 3 |
| Backoff | Retry-After if Discord sends one, otherwise 5 s then 10 s |
| HTTP timeout | 10 s |
| Success | HTTP 200 or 204 |
| Retried | Timeouts, 429, and any status ≥ 500 |
| Dropped immediately | Everything else, including 400, 401, 403 and 404 |
| Persistence | None — the queue lives in memory |
Events are queued per URL and delivered strictly in order by a single worker. A retry waits at the front of the queue, so a Discord outage delays everything behind it, and a resource restart during that wait discards whatever was still queued.
Failures
| Console line | Meaning |
|---|---|
MarketplaceWebhook.enabled = true, but url is still the placeholder … | Printed once at startup. Nothing will ever be sent |
F5 Combat HUD webhook failed (status: 401, attempts: 1) | Discord rejected it. 400/401/403/404 usually mean a wrong URL, an over-long botName, or a malformed avatarUrl / footerIcon |
F5 Combat HUD webhook failed (status: 429, attempts: 3) | Rate-limited past the retries — several servers sharing one webhook URL, or a very busy marketplace |
F5 Combat HUD webhook: image upload rejected (status: …) — resending without it | The POST carrying the image failed; the event is re-sent as text only. A status of 0 here means the host was unreachable, not that the image was bad |
A player never sees any of this — the in-game action succeeds regardless of whether Discord accepted the log.
shared/config_validator.lua does not inspect this table. A typo'd key, a colour written as a hex string, or a missing logEvents produces no complaint — the events simply never arrive. Check the console after your first publish.
On admin_ban, 🚫 Target is the player name the moderator's menu supplied, sanitised — the banned license appears there only when that name comes through empty. admin_unban is the opposite and always shows the raw license.
If you need an authoritative record of who was banned, read the f5_combathud_admin_audit table: it stores the license itself.
Discord Is Not Your Only Audit Trail
Every moderation action is also written to the f5_combathud_admin_audit table, independently of Discord. Turning the webhook off does not lose the audit trail — it only stops the Discord copy. See Database → Admin audit.
See Also
- Marketplace — the actions that produce these events
- Permissions — who can trigger the moderation events
- Database — the audit table
- Installation — where
server/config_server.luasits - Troubleshooting — "nothing arrives in Discord"