How Do I Get Notified When a GitHub Actions Workflow Fails?

The fastest way to get notified when a GitHub Actions workflow fails is to add one final step to the job that runs only if: failure() and sends an HTTP POST to a notification webhook. That step turns a red X buried in the Actions tab into a push notification on your phone within seconds. For scheduled workflows, pair it with a heartbeat monitor so you also hear about the runs that never started at all.

GitHub does email the person who pushed the commit when a workflow fails, but that email is easy to miss, only goes to one person, and says nothing when a schedule workflow silently stops firing. This guide shows the patterns that actually work for a solo developer or a small team, with copy-paste YAML for each.

Why GitHub's built-in failure emails are not enough

Out of the box, GitHub sends a "Run failed" email to the actor who triggered the workflow. That has three practical gaps:

  • It reaches one inbox. If a teammate's push breaks the nightly build, you do not hear about it unless you go looking.
  • It is an email. Failure emails land next to dependabot digests and marketing mail. They do not buzz your phone the way a push notification does.
  • It cannot report absence. If a cron-triggered workflow stops running (disabled repo, expired schedule, misconfigured cron, GitHub disabling scheduled workflows on inactive repositories after 60 days), there is no run and therefore no failure email. Silence looks identical to success.

The fix for the first two is a notification step in the workflow itself. The fix for the third is a heartbeat monitor, covered further down.

Pattern 1: notify on failure with if: failure()

GitHub Actions evaluates the if condition on every step. The built-in failure() function returns true when any previous step in the job has failed. Put a notify step last, gate it with failure(), and it will only fire when something broke.

Store the webhook token as a repository secret (Settings → Secrets and variables → Actions) so it never appears in the YAML or the logs. In this example the secret is called PINGWIRE_HOOK and holds a per-channel webhook token from Pingwire.

# .github/workflows/ci.yml
name: CI
on:
  push:
    branches: [main]
  pull_request:

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

      - name: Alert on failure
        if: failure()
        run: |
          curl -sS -X POST "https://pingwire.dev/hook/${{ secrets.PINGWIRE_HOOK }}" \
            -H "Content-Type: application/json" \
            -d "$(jq -n \
              --arg title "CI failed: ${{ github.repository }}" \
              --arg text  "${{ github.workflow }} on ${{ github.ref_name }} by ${{ github.actor }} — ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
              '{title: $title, text: $text, priority: "high"}')"

Two details matter here. First, the message body includes a direct link to the run, built from github.server_url, github.repository and github.run_id. When the alert lands you tap it and are looking at the failing log, not the Actions index. Second, the JSON is built with jq -n rather than string concatenation, so a commit message or branch name containing a quote cannot break the payload. jq is preinstalled on GitHub-hosted Ubuntu runners.

Everyone subscribed to the channel gets the push, so this scales from one person to a small team without changing the workflow. If you would rather use an API key than a webhook, the same call works against POST /api/v1/messages.php with an Authorization: Bearer header — see the developer docs for both forms.

Notify on success too, but quietly

Sometimes you want a low-priority "deploy finished" note as well. Add a second step gated with if: success(). Keep its priority normal so it does not compete with real failures for your attention. Do not send a success ping for every pull request build; that trains you to ignore the channel. Reserve it for the events you would actually want to know about, such as a production deploy.

      - name: Notify deploy
        if: success() && github.ref == 'refs/heads/main'
        run: |
          curl -sS -X POST "https://pingwire.dev/hook/${{ secrets.PINGWIRE_HOOK }}" \
            -H "Content-Type: application/json" \
            -d '{"title":"Deployed","text":"${{ github.repository }} ${{ github.sha }} is live"}'

Do not let the notify step mask the real failure

If your notify step is the last step and it succeeds, the job is still marked failed, because an earlier step failed. Good. But if you use continue-on-error: true on your test step to reach the notify step, the job goes green even though tests failed. Do not do that. if: failure() already runs after a failed step without any extra flags. Leave the test step's default behaviour alone.

Pattern 2: one alert per workflow with if: always()

When a workflow has several jobs, putting a notify step in each one means several alerts for one broken push. A cleaner shape is a final job that needs all the others and runs if: always(), then inspects the results.

jobs:
  lint:
    runs-on: ubuntu-latest
    steps: [ { uses: actions/checkout@v4 }, { run: npm run lint } ]
  test:
    runs-on: ubuntu-latest
    steps: [ { uses: actions/checkout@v4 }, { run: npm test } ]

  report:
    runs-on: ubuntu-latest
    needs: [lint, test]
    if: always()
    steps:
      - name: Alert if anything failed
        if: contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled')
        run: |
          curl -sS -X POST "https://pingwire.dev/hook/${{ secrets.PINGWIRE_HOOK }}" \
            -H "Content-Type: application/json" \
            -d "$(jq -n \
              --arg title "Workflow failed: ${{ github.repository }}" \
              --arg text  "lint=${{ needs.lint.result }} test=${{ needs.test.result }} — ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
              '{title: $title, text: $text, priority: "high"}')"

needs.*.result expands to the list of results for every job in needs. Checking it with contains() gives you exactly one alert per run, and the body tells you which job broke. The if: always() on the job is required; without it the report job is skipped as soon as any dependency fails, which is the opposite of what you want.

Pattern 3: catch scheduled workflows that stop running

Everything above fires when a run happens and fails. It cannot fire when no run happens. That is the failure mode of on: schedule workflows: nightly backups, dependency scans, data syncs. GitHub documents that scheduled workflows are automatically disabled on public repositories with no activity for 60 days, and in practice cron schedules also just drift or get edited wrong.

The answer is a dead man's switch, usually called a heartbeat or cron monitor. You create a monitor with an expected interval, say every 24 hours plus a grace period, and the workflow pings its URL when it completes successfully. If the ping does not arrive in time, the monitor alerts you. Absence becomes a signal.

name: Nightly backup
on:
  schedule:
    - cron: '15 3 * * *'   # 03:15 UTC daily
  workflow_dispatch:

jobs:
  backup:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run backup
        run: ./scripts/backup.sh

      - name: Heartbeat (only on success)
        if: success()
        run: curl -fsS --max-time 10 "https://pingwire.dev/hb/${{ secrets.PINGWIRE_HB }}"

      - name: Report failure
        if: failure()
        run: curl -fsS --max-time 10 "https://pingwire.dev/hb/${{ secrets.PINGWIRE_HB }}/fail"

Note the two branches. On success the workflow checks in. On failure it hits the /fail suffix, which reports the run as failed immediately instead of waiting for the grace period to expire. That gives you both signals from one monitor: "it broke" right away, and "it never ran" after the deadline. If you already covered your cron jobs on a server this way, the same idea from our uptime monitoring guide applies unchanged to GitHub's scheduler.

Keep the heartbeat step gated with if: success(). A heartbeat that fires whether or not the backup worked tells you only that the YAML ran, which is not the thing you care about.

What to put in the alert

An alert you cannot act on from your phone is half an alert. A useful failure message has:

  • What: the workflow and repository name in the title.
  • Where: the branch and, for a matrix job, the matrix values.
  • Who: github.actor, so you know whether to fix it or ping someone.
  • A link: the run URL, so one tap opens the log.

Skip the full commit message and the full SHA. They make the notification long and rarely change what you do next. If you need the SHA, the run URL has it.

Routing: which channel, which priority

Send CI failures to a channel dedicated to build alerts, not to your general team chat, and give real failures a high priority while success notes stay at the default. If you route different repositories or environments to different channels, use a separate webhook token per channel; the token identifies the destination, so the YAML stays identical across repositories and only the secret changes. This is the same principle we used for shell-script alerts: keep the sending code dumb and put the routing decision in the token.

For a small team, that is usually enough. If you find yourself needing an acknowledgement so two people do not both start fixing the same build, or an escalation to a second person when nobody responds, that is the point to move from plain messages to monitors and incidents, which carry ack and resolve state. Most teams do not need that for CI; they do for production.

Testing the alert path before you need it

A notification step that has never fired is a guess. Trigger it deliberately once. The simplest way is a temporary workflow with workflow_dispatch and a step that runs exit 1, followed by your notify step. Run it from the Actions tab, confirm the push arrives on your phone with the correct link, then delete the workflow. For the heartbeat, create the monitor with a short interval, run the workflow once, and check that the monitor shows a check-in before you set the real interval.

Also confirm what happens when the notification service is unreachable. Because the notify step uses curl -sS without -f in the failure examples above, a network error will print but not fail the step; the job already failed, so nothing is lost. In the heartbeat example -f is intentional: a heartbeat that could not be delivered should show up as a failed step so you notice.

Summary

Add an if: failure() step that POSTs to a webhook, and failed workflows reach your phone. Collapse multi-job workflows into one alert with a final needs job and if: always(). Cover scheduled workflows with a heartbeat monitor so silence is reported too. Put the run URL in every message, route by channel, and test the path once on purpose. That is the whole setup, and it takes about ten minutes.

Frequently asked questions

Does GitHub Actions notify me by default when a workflow fails?

Yes, but only by email and only to the person who triggered the run. It does not notify a team, it does not push to your phone, and it sends nothing when a scheduled workflow stops running altogether.

What is the difference between if: failure() and if: always()?

failure() is true when a previous step or needed job failed, so the step runs only on failure. always() runs the step regardless of outcome, including cancellation, and is used when you want to inspect results yourself, for example in a final reporting job.

How do I get one alert for a workflow with many jobs instead of one per job?

Add a final job that lists the others in needs, set if: always() on that job, and gate its notify step with contains(needs.*.result, 'failure'). That job runs once per workflow and can report which jobs failed.

How do I know if a scheduled GitHub Actions workflow silently stopped running?

Use a heartbeat (dead man's switch) monitor. The workflow pings a URL when it finishes successfully; if the ping does not arrive within the expected interval plus grace period, the monitor alerts you. A failure step can also hit the monitor's /fail URL to report a broken run immediately.

Where should I store the webhook token or API key?

In an Actions repository or organization secret, referenced as ${{ secrets.NAME }} in the workflow. GitHub masks secret values in logs. Never paste a token directly into the YAML.

Will a notification step make a failed job look green?

No. If an earlier step failed, the job stays failed even when the if: failure() notify step succeeds. Only continue-on-error on the failing step would hide the failure, so avoid using it for tests.

Try Pingwire

Send your first alert in under 30 seconds — one HTTP call, straight to a chat and your phone.

Create a free account Read the API docs

More from the blog