FiveM Discord Webhook einrichten: sichere Server-Logs mit Embeds und Rate-Limit-Queue
Einen FiveM Discord Webhook richtest du in drei Schritten ein: Du erstellst in den Kanaleinstellungen von Discord einen Webhook, hinterlegst die URL als Convar in der server.cfg und sendest Nachrichten ausschließlich serverseitig per PerformHttpRequest als JSON-Embed. Damit das auch bei vielen Spielern stabil läuft, brauchst du zusätzlich eine kleine Warteschlange, die Discords Rate-Limits respektiert.
Diese Anleitung zeigt dir einen wiederverwendbaren Logger als eigene Resource, den du aus jedem anderen Script per Export aufrufst. Der Fokus liegt auf den Details, die in den meisten Snippets fehlen: Schutz der URL, Batching, Fehlercodes und das Abfangen von Massen-Pings über Spielernamen.
Wofür eignet sich ein Discord-Webhook auf einem FiveM-Server?
Ein Webhook ist eine Einweg-Schnittstelle: Dein Server schickt eine HTTP-Anfrage an eine URL, Discord postet den Inhalt in einen festen Kanal. Es gibt keinen Bot-Account, keine Gateway-Verbindung und keine Rückkanäle. Genau das macht Webhooks ideal für Server-Logs, also für Ereignisse, die dein Team nachvollziehen muss.
Typische Einsatzfälle sind Geldbewegungen über einem Schwellenwert, Admin-Aktionen, Item-Übergaben zwischen Spielern, Joins mit Identifiern und Fehlermeldungen deiner eigenen Resources. Für interaktive Funktionen wie Whitelist-Abfragen, Ticketsysteme oder Rollen-Sync reicht ein Webhook dagegen nicht, dafür brauchst du einen echten Bot.
FiveM Discord Webhook anlegen: Schritt für Schritt
- Lege für jede Log-Kategorie einen eigenen, nur für das Team sichtbaren Kanal an, zum Beispiel
#log-geld,#log-adminund#log-joins. - Öffne die Kanaleinstellungen, wähle Integrationen und dann Webhooks. Erstelle einen neuen Webhook und vergib einen sprechenden Namen.
- Kopiere die Webhook-URL. Sie hat die Form
https://discord.com/api/webhooks/ID/TOKEN. Wer diese URL kennt, kann in den Kanal schreiben. - Trage die URL als Convar in deine
server.cfgein (siehe nächster Abschnitt), nicht in eineconfig.lua. - Lege die Logger-Resource an, starte sie vor allen Resources, die sie nutzen, und teste mit einer einzelnen Nachricht.
Getrennte Kanäle sind kein Komfort-Detail. Jeder Webhook hat sein eigenes Rate-Limit, und ein Kanal voller Join-Meldungen begräbt sonst die eine Admin-Aktion, die du später suchst.
Webhook-URL sicher speichern: Convar statt config.lua
Der häufigste Fehler ist eine Webhook-URL in einer Datei, die als client_script oder shared_script eingebunden ist. Alles, was an den Client ausgeliefert wird, kann ein Spieler auslesen. Mit der URL kann er deinen Log-Kanal fluten oder gefälschte Einträge posten, die wie echte Logs aussehen.
Speichere die URLs deshalb als Convar in der server.cfg:
# server.cfg - mit set, NICHT mit setr
set log_webhook_money "https://discord.com/api/webhooks/ID/TOKEN"
set log_webhook_admin "https://discord.com/api/webhooks/ID/TOKEN"
set log_webhook_joins "https://discord.com/api/webhooks/ID/TOKEN"
ensure loggerDer Unterschied ist entscheidend: set bleibt auf dem Server, setr repliziert den Wert an alle Clients. Ein versehentliches setr macht die ganze Absicherung wertlos. Falls deine server.cfg in einem Git-Repository liegt, lagere die Zeilen in eine separate Datei aus, die du per exec secrets.cfg lädst und vom Repository ausschließt.
Wenn eine URL doch einmal öffentlich wird, löschst du den Webhook in Discord und legst einen neuen an. Den alten Token kannst du nicht zurückziehen, nur den ganzen Webhook entfernen.
Logger-Resource mit PerformHttpRequest und Queue
Discord begrenzt die Anfragen pro Webhook und antwortet bei Überschreitung mit HTTP 429 und einem retry_after-Wert in Sekunden. Wer bei jedem Ereignis sofort eine Anfrage abschickt, verliert bei Lastspitzen, etwa nach einem Server-Restart mit vielen gleichzeitigen Joins, Einträge. Ein zuverlässiger FiveM-Logger sammelt Ereignisse in einer Warteschlange und schickt sie gebündelt ab, statt pro Ereignis eine Anfrage zu senden.
Pro Nachricht erlaubt Discord bis zu 10 Embeds, die zusammen höchstens 6000 Zeichen umfassen dürfen. Der folgende Code berücksichtigt beides:
-- logger/server.lua
local queues = {}
local COLORS = { info = 3447003, warn = 15105570, danger = 15158332 }
local MAX_BATCH_CHARS = 5800
local function trim(str, max)
str = tostring(str or '')
if #str > max then return str:sub(1, max - 3) .. '...' end
return str
end
local function embedSize(e)
local size = #(e.title or '') + #(e.description or '')
for _, f in ipairs(e.fields or {}) do
size = size + #f.name + #f.value
end
return size
end
local function log(channel, title, description, level, fields)
local url = GetConvar('log_webhook_' .. channel, '')
if url == '' then return end
local cleanFields = {}
for i, f in ipairs(fields or {}) do
if i > 25 then break end
cleanFields[i] = {
name = trim(f.name, 256),
value = trim(f.value, 1024),
inline = f.inline or false
}
end
local embed = {
title = trim(title, 256),
description = trim(description, 1500),
color = COLORS[level] or COLORS.info,
fields = cleanFields,
timestamp = os.date('!%Y-%m-%dT%H:%M:%SZ')
}
queues[channel] = queues[channel] or { url = url, items = {}, pauseUntil = 0 }
table.insert(queues[channel].items, { embed = embed, size = embedSize(embed) })
end
local function flush(channel, q)
local batch, entries, chars = {}, {}, 0
while #q.items > 0 and #batch < 10 do
local nextItem = q.items[1]
if chars + nextItem.size > MAX_BATCH_CHARS and #batch > 0 then break end
table.remove(q.items, 1)
chars = chars + nextItem.size
batch[#batch + 1] = nextItem.embed
entries[#entries + 1] = nextItem
end
local payload = json.encode({
username = 'Server-Log',
embeds = batch,
allowed_mentions = { parse = {} }
})
PerformHttpRequest(q.url, function(status, body)
if status == 429 then
for i = #entries, 1, -1 do table.insert(q.items, 1, entries[i]) end
local data = json.decode(body or '{}') or {}
q.pauseUntil = GetGameTimer() + math.ceil((data.retry_after or 2) * 1000)
elseif status ~= 200 and status ~= 204 then
print(('[logger] Kanal %s: HTTP %s'):format(channel, tostring(status)))
end
end, 'POST', payload, { ['Content-Type'] = 'application/json' })
end
CreateThread(function()
while true do
Wait(2000)
local now = GetGameTimer()
for channel, q in pairs(queues) do
if #q.items > 0 and now >= q.pauseUntil then
flush(channel, q)
end
end
end
end)
exports('log', log)In der fxmanifest.lua bindest du die Datei nur als server_script ein. Ein Client-Teil existiert bewusst nicht.
Warum allowed_mentions so wichtig ist
Spielernamen sind frei wählbar. Nennt sich jemand @everyone und dein Join-Log übernimmt den Namen ungefiltert, pingt dein Server bei jedem Join den ganzen Discord. Mit allowed_mentions = { parse = {} } unterdrückst du alle Pings, unabhängig vom Inhalt. Erwähnungen wie <@123456> werden trotzdem als klickbarer Name angezeigt, nur ohne Benachrichtigung.
Welche Daten gehören in ein Log-Embed?
Ein Log ist nur so viel wert wie seine Zuordnung. Der Spielername allein reicht nicht, weil er sich jederzeit ändern lässt. Speichere immer einen stabilen Identifier mit, idealerweise die Rockstar-License und, falls vorhanden, die Discord-ID:
local function playerFields(src)
local license = GetPlayerIdentifierByType(src, 'license') or '-'
local discord = GetPlayerIdentifierByType(src, 'discord')
local mention = '-'
if discord then
mention = '<@' .. discord:gsub('discord:', '') .. '>'
end
return {
{ name = 'Spieler', value = GetPlayerName(src) or '-', inline = true },
{ name = 'Server-ID', value = tostring(src), inline = true },
{ name = 'Discord', value = mention, inline = true },
{ name = 'License', value = license }
}
endDer Aufruf aus einer anderen Resource sieht dann so aus, hier am Beispiel einer Überweisung, die erst ab einem selbst gewählten Schwellenwert protokolliert wird:
-- in deinem Banking- oder Job-Script, serverseitig
local LOG_THRESHOLD = 50000
if amount >= LOG_THRESHOLD then
local fields = playerFields(src)
fields[#fields + 1] = { name = 'Betrag', value = tostring(amount), inline = true }
fields[#fields + 1] = { name = 'Empfänger-ID', value = tostring(target), inline = true }
exports['logger']:log('money', 'Große Überweisung', 'Transfer über Schwellenwert', 'warn', fields)
endDen Schwellenwert legst du passend zu deiner Server-Wirtschaft fest. Wer alles protokolliert, erzeugt Rauschen, das niemand liest. Wer zu wenig protokolliert, hat bei einem Dupe-Verdacht keine Spur.
Logs nie aus Client-Events auslösen
Ein verbreitetes Muster ist ein Server-Event wie RegisterNetEvent('logger:send'), das der Client mit Titel und Text aufruft. Das ist eine offene Tür: Jeder Spieler mit einem Executor kann das Event mit beliebigem Inhalt triggern und dein Log fälschen oder fluten.
Die Regel lautet daher: Das Log entsteht dort, wo der Server die Aktion selbst ausführt und validiert hat. Bei einer Überweisung also nach der Prüfung von Kontostand und Empfänger, nicht vorher und nicht auf Basis dessen, was der Client behauptet. Wie du diese serverseitige Validierung in eigenen Scripts sauber aufbaust, zeigt der Beitrag zum eigenen Job-Script mit Datenbank und serverseitiger Prüfung.
Brauchst du trotzdem Informationen vom Client, etwa eine Fehlermeldung aus der NUI, dann übernimm nur feste Werte aus einer Whitelist, begrenze die Länge hart und setze pro Spieler ein Cooldown, zum Beispiel eine Meldung alle 30 Sekunden.
Häufige Fehler und ihre HTTP-Statuscodes
Weil PerformHttpRequest asynchron arbeitet und keine Exceptions wirft, siehst du Probleme nur, wenn du den Statuscode auswertest. Die wichtigsten:
- 204: Erfolg. Discord liefert ohne den Parameter
?wait=truekeinen Body zurück. - 400: Ungültiger Payload. Meist ein leeres Feld (
valuedarf nicht leer sein), ein überschrittenes Zeichenlimit oder eine ungültige Farbe. - 401 oder 404: Der Webhook wurde gelöscht oder die URL ist falsch kopiert. Hier hilft kein erneuter Versuch.
- 429: Rate-Limit. Die Queue oben wartet automatisch die angegebene Zeit ab.
- 0: Keine Verbindung, etwa weil ausgehende Anfragen auf deinem Host blockiert sind.
Leere Felder sind der Klassiker: GetPlayerName liefert nil, wenn der Spieler den Server bereits verlassen hat. Deshalb setzt der Beispielcode überall einen Fallback wie '-'.
Fertige Scripts mit integriertem Webhook-Logging
Für eigene Resources lohnt sich der zentrale Logger. Bei Standardsystemen kannst du alternativ auf Scripts setzen, die Webhook-Protokolle bereits mitbringen. Bei Web-Services enthält zum Beispiel ws_banking-v2 mit Transaktionslimits und Discord-Webhooks ein eigenes Logging für Bankvorgänge (benötigt ESX), und das Einreise- und Whitelist-System ws_einreise protokolliert alle Team-Aktionen per Webhook. Prüfe vor dem Einsatz immer, ob das jeweilige Script zu deinem Framework passt.
Wenn du über reine Logs hinaus interaktive Funktionen wie Tickets, Whitelist oder Dienstzeiten in Discord brauchst, findest du in der Übersicht der Discord-Bots für FiveM-Communities passende Ansätze, die über einen Webhook hinausgehen.
FAQ
Kann ich einen Discord-Webhook auch clientseitig in FiveM nutzen?
Technisch ja, sinnvoll ist es nicht. Jede URL im Client-Code lässt sich auslesen, und jeder Spieler könnte damit in deinen Kanal schreiben. Sende Webhooks ausschließlich aus server_script-Dateien und speichere die URL als Convar mit set.
Warum kommen manche Webhook-Nachrichten nicht an?
Meist liegt es am Rate-Limit (HTTP 429) bei vielen gleichzeitigen Ereignissen oder an einem ungültigen Payload (HTTP 400), etwa durch ein leeres Feld oder zu lange Texte. Werte den Statuscode im Callback von PerformHttpRequest aus und bündle Nachrichten über eine Warteschlange.
Wie viele Embeds passen in eine Webhook-Nachricht?
Discord erlaubt bis zu 10 Embeds pro Nachricht, die zusammen höchstens 6000 Zeichen umfassen dürfen. Ein einzelnes Embed kann bis zu 25 Felder haben, jedes Feld bis zu 1024 Zeichen im Wert.
Wie verhindere ich, dass Spielernamen @everyone auslösen?
Setze im Payload allowed_mentions = { parse = {} }. Damit ignoriert Discord alle Erwähnungen im Inhalt, egal ob @everyone, Rollen oder Nutzer. Erwähnungen werden weiterhin angezeigt, lösen aber keine Benachrichtigung aus.
