---
title: "How I get Discord notifications for new AMD Chipset releases"
description: "A step-by-step guide to my Cloudflare Worker that polls AMD's driver pages and sends Discord webhook notifications whenever new chipset drivers are released."
url: "https://knng.de/blog/amd-chipset-notifier/"
published: "2026-03-16"
updated: "2026-08-11"
series: "Discord notifiers for driver and BIOS releases"
---

# How I get Discord notifications for new AMD Chipset releases

## Preface

There is currently no good way to get notified when AMD releases new chipset drivers.

If you care about keeping systems up to date, you usually end up doing one of two things: checking the AMD driver page yourself every once in a while, or joining the AMD Discord and hoping release announcements are still readable between the GPU driver spam and marketing posts.

Neither is great, and forwarding the AMD Discord into your own server via [Channel Following](https://support.discord.com/hc/en-us/articles/360028384531-Channel-Following-FAQ) doesn't fix it either, you just get the noise one server over.

So I built a small Cloudflare Worker that checks AMD's driver pages on a schedule and sends exactly one Discord message when a new chipset driver shows up. Nothing else.

The same setup later became the base for my ASUS ROG BIOS notifier, which is the second part of this series.

Everything in this post applies to both, the second part only covers what's different.

---

### Architecture

The worker does four things per check:

1. Fetch chipset driver metadata from AMD
2. Compare it with the last known version
3. Send a Discord webhook if the version changed
4. Persist the new state in Cloudflare KV

No VM, no container, no cron job sitting on a server somewhere, Cloudflare just triggers the worker on a schedule.

State lives in Workers KV, one entry per watched chipset page:

```json
{
  "productName": "AMD Chipset Drivers",
  "version": "8.02.18.557",
  "releaseDate": "2026-03-09",
  "pageUrl": "https://www.amd.com/en/support/downloads/drivers.html/chipsets/am5/x870e.html",
  "checkedAt": "2026-03-15T12:00:44.154Z"
}
```

Only `version` is used for the actual change detection, everything else is just there for the Discord message and for `/state`.

Newer versions of the worker also store `fileSize` and `changelog` there, both end up in the Discord message.

---

### Watching more than one chipset page

I originally only watched the X870E page, since that's what I run. Someone then asked me to add their Discord server too, on a different board, which is where it got interesting.

The driver itself is identical across a whole socket generation, AM5 boards all get the same package. But AMD still hosts a separate download page per chipset, X870E, X670E, B650, and so on.

If I'd just added a second webhook to the existing config, that person would get pinged with a link to my X870E page while sitting on a B650 board, correct version, wrong link.

So instead of one page and a list of webhooks, the worker now watches a list of targets, each with its own page, KV key and webhook. `src/config.js`:

```js
const TARGETS = [
  {
    id: "x870e",
    amdPageUrl: "https://www.amd.com/en/support/downloads/drivers.html/chipsets/am5/x870e.html",
    productName: "AMD Chipset Drivers"
  }
  // {
  //   id: "b550e",
  //   amdPageUrl: "https://www.amd.com/en/support/downloads/drivers.html/chipsets/am4/b550.html",
  //   productName: "AMD Chipset Drivers"
  // }
];

export function getConfig(env) {
  return {
    targets: TARGETS.map((target) => ({
      amdPageUrl: target.amdPageUrl,
      productName: target.productName,
      kvKey: "amd-" + target.id + "-chipset-version",
      discordWebhookUrls: (env["DISCORD_WEBHOOK_URL_" + target.id.toUpperCase()] || "")
        .split(",")
        .map((u) => u.trim())
        .filter(Boolean)
    })),
    stateBinding: env.STATE
  };
}
```

Adding a chipset is just adding an object to `TARGETS` and setting a matching secret, e.g. `id: "x670e"` needs `DISCORD_WEBHOOK_URL_X670E` in Cloudflare. Everything else, page URL, product name, KV key, is right in the file.

`src/index.js` runs every target independently and collects the results:

```js
for (const target of config.targets) {
  try {
    results.push(await runCheckForTarget(env, target, forceNotify, manual));
  } catch (error) {
    results.push({
      ok: false,
      kvKey: target.kvKey,
      error: error instanceof Error ? error.message : String(error)
    });
  }
}
```

One target failing, say AMD changed the markup on one specific page, doesn't stop the others from being checked and notified.

---

### Fetching the AMD driver version

AMD doesn't expose an API for this, so the worker pulls the version straight out of the page HTML.

```js
export async function fetchDriverInfo(config) {
  const html = await fetchHtml(config.amdPageUrl);
  const parsed = extractDriverInfo(html, config.productName);

  if (!parsed.version) {
    throw new Error("Could not find a version number on the AMD page");
  }

  return {
    productName: config.productName,
    version: parsed.version,
    releaseDate: parsed.releaseDate,
    fileSize: parsed.fileSize,
    changelog: parsed.releaseNotesUrl ? await fetchChangelog(parsed.releaseNotesUrl) : null,
    pageUrl: config.amdPageUrl
  };
}
```

Some AMD endpoints reject requests without a User-Agent, so `fetchHtml` sends one:

```js
async function fetchHtml(url) {
  const response = await fetch(url, {
    method: "GET",
    headers: {
      "user-agent": "Mozilla/5.0 amd-chipset-notifier/1.0",
      "accept-language": "en-US,en;q=0.9"
    }
  });

  if (!response.ok) {
    throw new Error("Failed to load AMD page. HTTP " + response.status);
  }

  return await response.text();
}
```

The HTML gets stripped down to plain text, then a regex anchored to the product name pulls out the version:

```js
const versionRegex = new RegExp(
  escapedName +
    "[\\s\\S]{0,1200}?Revision Number[\\s\\S]{0,200}?([0-9]+(?:\\.[0-9]+)+)",
  "i"
);
```

Release date and file size work the same way, one regex each, anchored to "Release Date" and "File Size".

> **Note:** AMD occasionally changes the page markup. Keeping the parsing logic small and anchored to visible text, rather than CSS classes or DOM structure, is what keeps it from breaking every redesign.

The changelog lives on a separate release notes page, linked from the driver table.  
The worker follows that link and pulls out the "Release Highlights" section:

```js
async function fetchChangelog(releaseNotesUrl) {
  try {
    const html = await fetchHtml(releaseNotesUrl);
    const text = cleanupHtmlToText(html);

    const match = text.match(
      /Release Highlights([\s\S]{0,1000}?)(Known Issues|Chipset Support|Package Contents|Change Details|$)/i
    );

    return match ? match[1].trim() || null : null;
  } catch {
    return null;
  }
}
```

If the release notes are missing or can't be parsed, it returns `null` instead of failing.  
A new driver without a changelog is still worth a message.

---

### State and version comparison

State handling is three small functions in `src/state.js`, load, save, and one that only bumps the timestamp when nothing changed. Load and save:

```js
export async function loadStoredState(env, kvKey) {
  return await env.STATE.get(kvKey, "json");
}

export async function saveCurrentState(env, kvKey, state) {
  await env.STATE.put(kvKey, JSON.stringify(state));
}
```

Comparing versions is plain numeric segment comparison in `src/utils.js`, `compareVersions("8.02.18.557", "8.02.18.556")` returns `1` if the left side is newer.

---

### Sending the Discord webhook

`src/discord.js` posts to every webhook configured for that target:

```js
export async function sendDiscordNotification(config, data) {
  const payload = buildDiscordMessage(config, data);

  for (const url of config.discordWebhookUrls) {
    const response = await fetch(url, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify(payload)
    });

    if (!response.ok) {
      const body = await response.text();
      throw new Error("Discord webhook error. HTTP " + response.status + " Body: " + body);
    }
  }
}
```

The message itself is a Discord embed, one field per piece of information:

```js
function buildDiscordMessage(config, data) {
  const fields = [
    { name: "Last Version", value: "`" + data.previousVersion + "`", inline: false },
    { name: "Current Version", value: "`" + data.currentVersion + "`", inline: false },
    { name: "Release date", value: "`" + (data.releaseDate || "unknown") + "`", inline: false }
  ];

  if (data.fileSize) {
    fields.push({ name: "File size", value: "`" + data.fileSize + "`", inline: false });
  }

  if (data.changelog) {
    fields.push({ name: "Changelog", value: quoteLines(data.changelog), inline: false });
  }

  return {
    embeds: [
      {
        author: { name: config.productName },
        title: data.forceNotify ? "TEST NOTIFICATION" : "New AMD Chipset Driver released!",
        url: config.amdPageUrl,
        color: EMBED_COLOR,
        fields
      }
    ]
  };
}
```

A few details:

* The embed title links to `config.amdPageUrl`, which comes from the specific target, so the link always matches the chipset the webhook was configured for.
* `/notify-test` sends a message with the title "TEST NOTIFICATION", so nobody mistakes a test for a real release.
* Discord only quotes the first line of a multi-line blockquote, so `quoteLines` puts `> ` in front of every line of the changelog.
* `EMBED_COLOR` is AMD's red, `0xed1c24`.

---

### When a check fails

Sooner or later AMD will change their page and the regex won't match anymore.  
Without any handling, that would just show up in the worker's logs, which nobody reads.

So a failed check sends its own Discord message, but only once per distinct error:

```js
async function reportFailureOnce(env, target, message) {
  try {
    const lastError = await loadStoredError(env, target.kvKey);
    if (lastError === message) {
      return;
    }

    await sendDiscordError(target, message);
    await saveStoredError(env, target.kvKey, message);
  } catch {
    // A broken notification path shouldn't take down the rest of the run
  }
}
```

The last reported error is stored in KV next to the state, under `<kvKey>-last-error`.

If the same error happens again an hour later, nothing is sent. A page that stays broken for days shouldn't ping every single hour.

As soon as a check succeeds again, the stored error is deleted.  
That way the next failure gets reported again, even if it's the same message as last time.

---

### Endpoints

Three HTTP endpoints on the worker, useful for debugging without waiting for the next cron run.

| Path           | Description                                                |
| -------------- | ----------------------------------------------------------- |
| /run           | Runs a check manually, notifies only if a version changed   |
| /state         | Returns the current stored state for every target           |
| /notify-test   | Runs a check and always sends a Discord notification         |

`/state` now returns one entry per target instead of a single object:

```json
{
  "ok": true,
  "states": [
    {
      "kvKey": "amd-x870e-chipset-version",
      "productName": "AMD Chipset Drivers",
      "state": {
        "version": "8.02.18.557",
        "releaseDate": "2026-03-09",
        "pageUrl": "https://www.amd.com/en/support/downloads/drivers.html/chipsets/am5/x870e.html",
        "checkedAt": "2026-03-16T12:00:44.154Z"
      }
    }
  ]
}
```

`/run` and `/notify-test` work the same way, one result object per target, with `changed`, `notified` and `initialized` flags for each.

---

### Tests

The parsing is the part most likely to break, so that's what the tests cover.

`extractDriverInfo` is exported from `src/amd.js` so it can be tested against a small fake AMD page, without any network requests.

`compareVersions` gets its own tests as well.

They use Node's built-in test runner, so there's nothing to install:

```bash
npm test
```

A GitHub Actions workflow runs the same tests on every push and pull request.

---

### Running it yourself

> **Note:** The [README](https://github.com/danielknng/amd-chipset-notifier#setup) has the full setup, this is the short version.

1. Add your targets to `src/config.js`
2. Create a KV namespace in Cloudflare (Storage & databases -> Worker KV) and put its ID into `wrangler.jsonc`
3. Connect the repository to Cloudflare (Workers & Pages -> Create -> Connect to Git), every push to `main` deploys automatically
4. Add one secret per target in the worker's settings: `DISCORD_WEBHOOK_URL_<ID>`, e.g. `DISCORD_WEBHOOK_URL_X870E`

> **Warning:** Set the webhook variable's type to **Secret**, not text.  
> A plain text variable can be read in the dashboard and through the API, a secret can't.

The cron schedule is in `wrangler.jsonc`, `0 * * * *` runs the check at the start of every hour.

---

### Why Cloudflare Workers are a good fit here

The workload per target is tiny, one HTTP request to AMD, one KV read, maybe a KV write, maybe a Discord webhook. Running this on an actual server would be overkill for something that takes milliseconds and fires once an hour.

The whole thing is a few hundred lines of code and costs nothing to run at this scale.

---

### Final result

The worker checks every configured chipset page hourly. If a version changes, exactly one Discord message goes to exactly the people who need it, linking to their own chipset's download page.

No AMD Discord spam, no manual checks, no one getting sent to the wrong page.

You can find the project here: [amd-chipset-notifier](https://github.com/danielknng/amd-chipset-notifier)

Cheers.

