What you get

A Discord channel that shows a red message when a workflow fails: repo, branch, and a link to the GitHub run.

No server of your own, and no Discord bot. Two things are enough: an Incoming Webhook — a secret address Discord gives you to post into a channel, with no other permissions — and a few more lines in your GitHub Actions setup.

Flow
  1. A job fails in Actions
  2. A step POSTs to the webhook
  3. Discord shows the message

If webhooks are still fuzzy, read Introduction to webhooks first.

Requirements

  • A GitHub repo. You do not need existing workflows: this is where you create your first
  • A Discord channel where you are an admin — admins are the ones who can create webhooks
  • Permission to add secrets in that repo, or someone who can add it for you

Create the Discord webhook

  1. Open the channel where you want the alert.
  2. Channel gear → IntegrationsWebhooksNew Webhook (labels vary slightly by client).
  3. Give it a clear name, e.g. CI failures.
  4. Copy Webhook URL.

That URL is a secret. Anyone who has it can post to the channel. Do not paste it into chat, an issue, or the repo.

Test the webhook locally

Before Actions, confirm the URL posts to the channel. Replace the entire quoted value with the URL Discord gave you (it starts with https://discord.com/api/webhooks/ — do not paste it on top of a prefix).

Language: bash
WEBHOOK_URL="paste-your-full-webhook-url"
curl -sS -o /dev/null -w "%{http_code}\
" -X POST "$WEBHOOK_URL" \
  -H "Content-Type: application/json" \
  -d "{\"content\":\"Test from the integration\"}"

Expected: 204 — which in Discord means “received, nothing to return” — and the message visible in the channel.

From here the OS tabs disappear: workflows run on GitHub's machine, which is Linux, so the commands are always the same even if you are on Windows.

Store it in GitHub Secrets

  1. In the repo: SettingsSecrets and variablesActions.
  2. New repository secret.
  3. Exact name: DISCORD_WEBHOOK_URL.
  4. Value: the URL you copied. Save.

If the secret has another name, the workflow below will not see it.

Add the workflow

Create .github/workflows/discord-on-failure.yml (filename is up to you) with:

Language: yaml

name: Discord alert on CI failure

on:
  workflow_dispatch:

jobs:
  # Fails on purpose so you can verify the alert.
  # Swap this for your real job later.
  fail-on-purpose:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Force failure (test only)
        run: exit 1

  notify-discord:
    needs: fail-on-purpose
    if: failure()
    runs-on: ubuntu-latest
    steps:
      - name: Notify Discord
        env:
          DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
        run: |
          if [ -z "$DISCORD_WEBHOOK_URL" ]; then
            echo "Missing secret DISCORD_WEBHOOK_URL."
            exit 1
          fi
          payload=$(jq -n \
            --arg repo "${{ github.repository }}" \
            --arg branch "${{ github.ref_name }}" \
            --arg url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
            '{
              embeds: [{
                title: "GitHub Actions failed",
                description: ("**" + $repo + "** · branch `" + $branch + "`"),
                url: $url,
                color: 15158332
              }]
            }')
          curl -sS -X POST \
            -H "Content-Type: application/json" \
            -d "$payload" \
            "$DISCORD_WEBHOOK_URL"

What is worth noticing in that block:

PieceRole
needs: + if: failure()The pair that does the trick: needs says which job to wait for, if: failure() that it should only run when that one failed. Without this it would alert every time
DISCORD_WEBHOOK_URLPulls the URL from GitHub Secrets. Never written in the file: the YAML is visible in the repo
jq + curlBuild the message and send it. Both come installed on GitHub's machine, nothing to add
color: 15158332The red stripe down the side of the message, in decimal. Change it if you prefer another

Try it

  1. Create the file in the repo: Add file → Create new file → path `.github/workflows/discord-on-failure.yml` → paste the YAML → Commit (or push with git).
  2. In Actions, open Discord alert on CI failure → Run workflow.
  3. Wait for fail-on-purpose to fail (expected) and notify-discord to finish green.
  4. Check Discord: you should see the red embed with repo, branch, and link.

Wire it into your CI

When the test alert works, drop fail-on-purpose. Below are paste-ready YAML blocks: just adapt your job command (or job names); the secret stays the same.

One job (usual)

Swap npm test for your real command. You can paste this whole block:

Language: yaml

name: Discord alert on CI failure

on:
  workflow_dispatch:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm test

  notify-discord:
    needs: test
    if: failure()
    runs-on: ubuntu-latest
    steps:
      - name: Notify Discord
        env:
          DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
        run: |
          if [ -z "$DISCORD_WEBHOOK_URL" ]; then
            echo "Missing secret DISCORD_WEBHOOK_URL."
            exit 1
          fi
          payload=$(jq -n \
            --arg repo "${{ github.repository }}" \
            --arg branch "${{ github.ref_name }}" \
            --arg url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
            '{
              embeds: [{
                title: "GitHub Actions failed",
                description: ("**" + $repo + "** · branch `" + $branch + "`"),
                url: $url,
                color: 15158332
              }]
            }')
          curl -sS -X POST \
            -H "Content-Type: application/json" \
            -d "$payload" \
            "$DISCORD_WEBHOOK_URL"

needs: test = wait for test.
if: failure() = run only if test failed.
If test is green, Discord stays quiet.

Several jobs

Same idea, but the alert waits on several jobs. Discord fires if any of them fails:

Language: yaml

name: Discord alert on CI failure

on:
  workflow_dispatch:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm test

  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm run build

  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm run lint

  notify-discord:
    needs: [test, build, lint]
    if: failure()
    runs-on: ubuntu-latest
    steps:
      - name: Notify Discord
        env:
          DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }}
        run: |
          if [ -z "$DISCORD_WEBHOOK_URL" ]; then
            echo "Missing secret DISCORD_WEBHOOK_URL."
            exit 1
          fi
          payload=$(jq -n \
            --arg repo "${{ github.repository }}" \
            --arg branch "${{ github.ref_name }}" \
            --arg url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
            '{
              embeds: [{
                title: "GitHub Actions failed",
                description: ("**" + $repo + "** · branch `" + $branch + "`"),
                url: $url,
                color: 15158332
              }]
            }')
          curl -sS -X POST \
            -H "Content-Type: application/json" \
            -d "$payload" \
            "$DISCORD_WEBHOOK_URL"

When it runs

This is not a third flow. It is the on: block in the same file.

What you put in on:Effect
workflow_dispatch onlyYou start it by hand (like the test)
push to main onlyRuns only on push to main
BothManual tests and real alerts on main

The “one job” / “several jobs” examples already include both. If you are still on the test YAML (manual only), add push when the message looks right:

Language: yaml

on:
  workflow_dispatch:
  push:
    branches: [main]

Security

  • Keep the secret only in GitHub Secrets (or your org’s secret store).
  • This flow does not verify GitHub signatures: Discord trusts whoever knows the URL.
  • Do not fire the alert from every noisy step; one message per failed job is usually enough. Discord may return 429 if you spam it.

Common mistakes

What you seeLikely causeWhat to do
notify-discord never runsPrevious job did not fail, or it is not in needsCheck the run graph; if: failure() only runs after a failure
Step fails with a weird URL / non-zero curlSecret pasted wrong (spaces, quotes, truncated URL)Re-enter the whole secret; do not “fix” it from the log
404 from DiscordWebhook deleted or stale URLCreate a new webhook and update the secret
401 / odd auth errorsLess common on Incoming Webhooks; truncated URLs misbehaveRegenerate the URL
Actions green, Discord silentWrong channel, or the watched job did not failConfirm the webhook’s channel and the run graph
Duplicate messageYou re-ran the workflowExpected here; there is no deduping

Alternatives

GitHub email
It arrives, then gets lost. Weaker for a shared team channel.
GitHub webhook with Discord’s /github suffix
Repo events (push, PR, and so on); not the same control as “this Actions job failed”.
Telegram bot + Actions
Same CI alert to a chat, with token and chat_id.
Slack Incoming Webhook
Same pattern (POST with a secret URL), different destination.

Resources