Discord webhook logs from a FiveM server: PerformHttpRequest guide

Send logs to Discord from FiveM with PerformHttpRequest and JSON embeds. Server side only, URL in a convar, a queue for rate limits, and what to log.

You want to know who bought what, who gave money, who ran an admin command and who joined, without watching the console all day. A Discord webhook does it: your server posts a message to a channel. This guide shows the server side Lua with PerformHttpRequest, how to build embeds, how to keep the URL safe and how to avoid the rate limit.

Create the webhook

In Discord, open the channel settings, then Integrations, then Webhooks, create one and copy its URL. It looks like https://discord.com/api/webhooks/<id>/<token>. Anyone with that URL can post in your channel, so treat it like a password.

Keep the URL in a convar

Put it in server.cfg:

cfg
set discord_webhook_logs "https://discord.com/api/webhooks/ID/TOKEN"

Use set, not setr or sets. A replicated convar is sent to every client, which would leak the URL. Read it on the server:

lua
local WEBHOOK = GetConvar('discord_webhook_logs', '')

If the convar is empty, skip sending instead of erroring. You can keep one convar per type of log (money, admin, joins) to send them to different channels.

Send a message

lua
local function sendLog(title, description, color, fields)
    if WEBHOOK == '' then return end

    local embed = {
        title = title,
        description = description,
        color = color or 3447003,
        fields = fields,
        timestamp = os.date('!%Y-%m-%dT%H:%M:%SZ'),
    }

    PerformHttpRequest(WEBHOOK, function(status, body, headers)
        if status ~= 204 and status ~= 200 then
            print(('webhook failed: %s'):format(status))
        end
    end, 'POST', json.encode({ username = 'Server logs', embeds = { embed } }), {
        ['Content-Type'] = 'application/json',
    })
end

Points to know:

  • color is a decimal number, not a hex string. 3447003 is blue and 15158332 is red. Convert hex with tonumber('FF0000', 16).
  • timestamp must be an ISO 8601 date in UTC. The ! in the format makes os.date use UTC.
  • fields is a list of { name = '...', value = '...', inline = true }. A field value cannot be empty.
  • A successful webhook call returns status 204, with an empty body.
  • Discord limits sizes. Titles are short (256 characters), descriptions are long but capped (4096), and a message holds a limited number of fields and embeds. Cut long text before you send it.

An example call, for a player who sent money:

lua
sendLog('Money transfer', 'A player sent money', 3066993, {
    { name = 'From', value = ('%s (id %d)'):format(GetPlayerName(src), src), inline = true },
    { name = 'To', value = ('%s (id %d)'):format(GetPlayerName(target), target), inline = true },
    { name = 'Amount', value = ('$%d'):format(amount), inline = false },
})

Server side only, never from the client

Warning: do not call PerformHttpRequest for a webhook in a client script, and do not send the URL to the client. Client files can be read by every player, so the URL leaks and anyone can flood your channel.

If an event on the client should be logged, send an event to the server, and let the server decide what to log from data it trusts. A client that can trigger "log this text" can also trigger fake logs. For event safety in general, read secure events in FiveM. Log what the server did, not what the client says it did.

Rate limits: use a queue

Discord rejects webhook calls that arrive too fast, with a 429 response. A burst of logs, such as 50 players triggering the same event, can hit it. Send the messages from a queue at a steady pace:

lua
local queue = {}

local function enqueue(payload)
    queue[#queue + 1] = payload
end

CreateThread(function()
    while true do
        local item = table.remove(queue, 1)
        if item then
            PerformHttpRequest(WEBHOOK, function(status, _, headers)
                if status == 429 then
                    -- put it back and wait a bit longer
                    table.insert(queue, 1, item)
                    Wait(5000)
                end
            end, 'POST', json.encode(item), { ['Content-Type'] = 'application/json' })
        end
        Wait(2000)
    end
end)

Waiting two seconds between messages stays under the limit. If you need more, group several embeds into one message: one request can carry up to 10 embeds in its embeds list. For the use of Wait, see Wait explained.

What to log

Log the events you would want when something goes wrong:

  • Money: large transfers, purchases, anything that creates money. This is where abuse shows first.
  • Admin actions: who used which admin command on whom, bans, warnings, teleports. See ban and warn players.
  • Joins and leaves, with the player's identifiers, which helps with disputes and bans.
  • Item spawning and weapon use, if you allow them.

Do not log everything. A channel with a message every second is a channel nobody reads. Pick a few channels, put the important ones where staff look, and keep the rest short. Keep personal data to what you need, and do not log private chat.

Tip: put your logging function in a small resource and call it with an export from your other scripts, so the webhook URL and the rate limit queue live in one place. See exports in FiveM.

Checklist

Symptom Fix
Nothing arrives Check the convar name, that the URL is complete and that the call runs on the server
Status 400 The JSON is invalid: empty field value, too long text, or a hex colour string
Status 429 Send through a queue with a pause between messages
Timestamp ignored Use UTC ISO 8601: os.date('!%Y-%m-%dT%H:%M:%SZ')
URL leaked Delete the webhook in Discord, create a new one, and keep it in a set convar
Too many messages Log only money, admin actions and joins

Quick answers

Can I send a Discord webhook from the client?

No. Anyone can read client code and steal the webhook URL, then spam your channel. Trigger the log on the server and send it from there.

How many webhook messages can I send?

Discord rate limits webhooks, and roughly 30 messages per minute per webhook is a safe ceiling. Beyond that you get a 429 response, so queue your messages and send them at a steady pace.

Where should I keep the webhook URL?

In a server convar set with set in server.cfg, read with GetConvar. Never put it in a client file or in a convar set with setr or sets.

Scripts that skip this problem

Advanced BoostingTablet-driven vehicle boosting: contracts from class D to S+, crews and a live queue.View script →Crypto MiningBuy a warehouse, build rigs part by part and mine coins on a market that moves.View script →Shop CreatorBuild a shop in under a minute — owners, employees, vaults and robberies included.View script →

Keep reading