Skip to content
Blog

Discord attachment links expire: move FiveM images to a CDN

MDT mugshots and evidence saved as Discord links now say “This content is no longer available.” Why the links expire, how to find them and how to fix it.

By Termi

6 min read
  • Discord
  • Uploads
  • MySQL
  • MDT
On this page

Plenty of FiveM servers send screenshots, MDT mugshots and evidence photos to a Discord webhook and save the attachment link Discord sends back in the database. It works for a day. Then the MDT shows broken images, and opening one of the links prints a single line: This content is no longer available. The files are usually still in the channel. The links in your database have expired. This guide explains why, finds the dead links, and moves uploads to a CDN whose links stay put.

Why Discord links stop working

Discord’s API reference says that “attachments uploaded to Discord’s CDN (like user and bot-uploaded images) have signed URLs with a preset expiry time.” Discord announced the change to developers in late 2023 and documented it in February 2024. Every attachment link it hands out now carries three query parameters:

a signed link, split for reading
https://cdn.discordapp.com/attachments/<channel id>/<attachment id>/screenshot.png
  ?ex=66424c96
  &is=6640fb16
  &hm=0b3d3210b4ea…287dace4&
  • ex is, in Discord’s words, a “hex timestamp indicating when an attachment CDN URL will expire”.
  • is is the hex timestamp of when the link was issued.
  • hm is a signature that “remains valid until the URL’s expiration”.

Inside Discord nothing looks wrong, because “Discord automatically refreshes attachment CDN URLs that appear within the client.” The image in the channel keeps loading. The link your script saved in MySQL is a copy, and nothing refreshes it. Once its ex time has passed, the CDN refuses it.

How long a link lasts

Discord does not document a fixed lifetime, so read it from the link. Both values are Unix timestamps in hex: 6640fb16 is 12 May 2024 at 17:23:34 UTC and 66424c96 is exactly 24 hours later. That is the real link we tested below, but Discord may issue links for other periods, so check ex on your own rows instead of assuming a day.

What “This content is no longer available.” means

Open an expired link anywhere outside Discord and the CDN answers with a plain-text 404:

bash
$ curl -i "https://cdn.discordapp.com/attachments/…/screenshot.png?ex=66424c96&is=6640fb16&hm=…"
HTTP/2 404
content-type: text/plain;charset=UTF-8

This content is no longer available.

Links saved before Discord started signing them, the ones with no ex, is and hm at all, get the same answer, on cdn.discordapp.com and on media.discordapp.net alike. In an MDT or any other NUI page this looks like a broken image, with nothing in the server console to explain it.

Find the dead links in your database

Every Discord attachment link contains discordapp. and /attachments/. This query writes one counting query for every text column in the database your server uses (the one in mysql_connection_string):

SQL
SET SESSION group_concat_max_len = 1000000;

SELECT GROUP_CONCAT(
  CONCAT(
    'SELECT ''', table_name, '.', column_name, ''' AS place, COUNT(*) AS links FROM `',
    table_name, '` WHERE `', column_name, '` LIKE ''%discordapp.%/attachments/%'''
  )
  SEPARATOR ' UNION ALL '
) AS query
FROM information_schema.columns
WHERE table_schema = DATABASE()
  AND data_type IN ('char', 'varchar', 'tinytext', 'text', 'mediumtext', 'longtext', 'json');

It returns a single value: another query. Copy it and run it. Each row of that result names a table.column and how many Discord links it holds. Anything above zero is a column your scripts filled through a webhook.

To see which rows in one column have already expired, decode ex. Here for the mdt_mugshots table from the mugshot guide:

SQL
SELECT identifier, image,
  IF(image LIKE '%?ex=%',
     FROM_UNIXTIME(CONV(SUBSTRING_INDEX(SUBSTRING_INDEX(image, '?ex=', -1), '&', 1), 16, 10)),
     NULL) AS expires
FROM mdt_mugshots
WHERE image LIKE '%discordapp.%/attachments/%';

A row whose expires is in the past no longer loads outside Discord, and neither does a row where it is NULL, which means the link was never signed.

Upload to a CDN instead

The fix is to upload the file to storage built to serve it, keep that link in the database, and still post to Discord if staff want to see the picture in a channel, with the CDN link in the embed. Here is an evidence screenshot taken with screenshot-basic, uploaded to FiveDock’s base64 endpoint, stored, and then posted to a webhook:

server/evidence.lua
local UPLOAD_URL = 'https://api.fivedock.dev/api/v3/file/base64'
local API_TOKEN = GetConvar('fivedock_api_token', '')
local WEBHOOK = GetConvar('evidence_webhook', '')

local function postEvidence(playerId, caseId)
    exports['screenshot-basic']:requestClientScreenshot(playerId, { encoding = 'webp', quality = 0.85 }, function(err, data)
        if err then
            return print(('Screenshot failed: %s'):format(err))
        end

        local key = ('evidence/%s/%d.webp'):format((tostring(caseId):gsub('[^%w_%-]', '_')), os.time())

        PerformHttpRequest(UPLOAD_URL, function(status, body)
            if status ~= 200 and status ~= 201 then
                return print(('Evidence upload failed (%s): %s'):format(status, tostring(body)))
            end

            local response = json.decode(body)
            local url = response and response.data and response.data.url
            if not url then return end

            MySQL.insert('INSERT INTO `mdt_evidence` (`case_id`, `image`) VALUES (?, ?)', { caseId, url })

            PerformHttpRequest(WEBHOOK, function() end, 'POST', json.encode({
                embeds = { { title = ('Case %s'):format(caseId), image = { url = url } } },
            }), { ['Content-Type'] = 'application/json' })
        end, 'POST', json.encode({ base64 = data, filename = 'evidence.webp', key = key }), {
            ['Content-Type'] = 'application/json',
            ['Authorization'] = API_TOKEN,
        })
    end)
end
  • The API token (fd_live_…, with the files:write scope) goes in server.cfg with set, never setr. The mugshot guide explains why.
  • The key gives each picture a link that never changes: https://cdn.fivedock.dev/o/<your-workspace>/evidence/<case>/<time>.webp.
  • Discord shows the embed image straight from that link. The row in mdt_evidence holds the same link, and it keeps working until you delete the file or a retention rule you set removes it.

The request has the same shape as Fivemanage’s base64 upload, so a script written for Fivemanage needs only a new URL and token. Upload files yourself covers the panel, keyed links and the S3 endpoint.

If you run the FiveDock agent, exports.fivedock:screenshot takes the screenshot and uploads it in one call, with no token in your script. See the agent docs.

Getting the old pictures back

The links in your database are dead, but the files usually still sit in the Discord channel, and Discord hands out a fresh link every time an app fetches the message again. A Discord bot that can read the channel can page through its history, 100 messages at a time, and match each attachment to your rows: in /attachments/<channel id>/<attachment id>/, the second number is the attachment’s ID. Download each file through its fresh link, upload it to FiveDock with the key your script now uses, and update the row.

A webhook cannot list a channel, so this needs a bot. Files whose message was deleted, or whose channel is gone, cannot be recovered at all.

Common questions

Why do Discord image links stop working?

Discord signs every attachment link with an expiry time and documented this in its API reference in February 2024. Discord’s own app refreshes the links it shows, so the picture still loads in the channel, but a link saved in a database is never refreshed. Once it expires, the CDN answers 404 with the text This content is no longer available.

What do ex, is and hm mean in a Discord link?

ex is the time the link expires and is the time it was issued, both as Unix timestamps written in hex. hm is a signature that stays valid until ex. In MySQL, FROM_UNIXTIME(CONV('66424c96', 16, 10)) turns an ex value into a date.

How long do Discord attachment links last?

Discord does not document a fixed lifetime; each link carries its own expiry in the ex parameter. The real link we tested for this guide was issued for exactly 24 hours. Treat any Discord attachment link saved in a database as temporary.

Can I make a Discord attachment link permanent?

No. The only way to get a working link again is to fetch the message through Discord’s API, which takes a bot that can read the channel. For anything you need to load later, upload the file to a CDN, store that link, and post the CDN link to Discord if you still want it in a channel.

Is a Discord webhook a good place to store FiveM screenshots?

It is a good place to show them and a poor place to keep them. The link you get back expires, and Discord’s default upload limit, 20 MiB per file since September 2026, applies to webhooks too. Store the file on a CDN and send the CDN link in the webhook embed instead.

All articles