Skip to main content

GitLab CI Discord Webhook and Jenkins Discord Notifications

Send CI/CD alerts to Discord: a GitLab CI Discord webhook job with masked variables and on_failure, plus Jenkins post{} notifications coloured by status.

7 min read Also available in Русский
On this page

Green CI can stay quiet; red CI has to reach a human within a minute. Both GitLab CI and Jenkins can put that signal in a Discord channel with nothing more than curl and a webhook URL. This guide covers .gitlab-ci.yml (masked variables, on_success/on_failure jobs, the pipeline URL inside an embed), the Jenkins post {} block (curl, httpRequest or the Discord Notifier plugin), colours by status, and how to stop a 40-job pipeline from posting 40 messages.

Store the webhook URL as a secret

Create the webhook (channel settings → Integrations → Webhooks) and copy the URL, https://discord.com/api/webhooks/{id}/{token}. The token is the whole secret: anyone with the URL can post to the channel, so never commit it to .gitlab-ci.yml or a Jenkinsfile.

GitLab: Settings → CI/CD → Variables → Add variable. Key DISCORD_WEBHOOK_URL, value the URL, tick Mask variable so job logs print [MASKED] instead of the value. Tick Protect variable only if the notify jobs run solely on protected branches and tags; otherwise feature-branch pipelines get an empty variable.

GitLab restricts what a masked value may contain (single line, minimum length, a limited character set — see the GitLab docs). If the form refuses to mask your URL, store a base64 copy and decode it in the job:

# on your machine
echo -n 'https://discord.com/api/webhooks/ID/TOKEN' | base64 -w0
# in the job
script:
  - export DISCORD_WEBHOOK_URL=$(echo "$DISCORD_WEBHOOK_B64" | base64 -d)

Jenkins: Manage Jenkins → Credentials → Add Credentials → kind Secret text, ID discord-webhook-url. withCredentials reads it in the pipeline and masks it in the console log.

GitLab CI: a notify stage with on_success and on_failure

The cleanest layout is a final notify stage with two jobs: one runs when every previous stage passed, the other when something failed. A hidden template job holds the shared script, and jq builds the JSON so a commit title with a quote in it cannot break the payload.

stages:
  - build
  - test
  - notify

build:
  stage: build
  script:
    - npm ci && npm run build

test:
  stage: test
  script:
    - npm test

.discord_notify:
  stage: notify
  image: alpine:3.20
  before_script:
    - apk add --no-cache curl jq
  script:
    - |
      PAYLOAD=$(jq -n \
        --arg title "$STATUS_TEXT $CI_PROJECT_NAME" \
        --arg url "$CI_PIPELINE_URL" \
        --arg desc "$CI_COMMIT_TITLE" \
        --arg branch "$CI_COMMIT_REF_NAME" \
        --arg sha "$CI_COMMIT_SHORT_SHA" \
        --arg author "$GITLAB_USER_NAME" \
        --arg pipeline "$CI_PIPELINE_ID" \
        --argjson color "$STATUS_COLOR" \
        '{
          username: "GitLab CI",
          embeds: [{
            title: $title,
            url: $url,
            description: $desc,
            color: $color,
            fields: [
              { name: "Branch", value: $branch, inline: true },
              { name: "Commit", value: $sha, inline: true },
              { name: "Triggered by", value: $author, inline: true }
            ],
            footer: { text: ("Pipeline #" + $pipeline) },
            timestamp: (now | todate)
          }]
        }')
      curl -sS -f --retry 3 \
        -H "Content-Type: application/json" \
        -d "$PAYLOAD" "$DISCORD_WEBHOOK_URL"

notify_success:
  extends: .discord_notify
  when: on_success
  variables:
    STATUS_TEXT: '✅ Pipeline passed:'
    STATUS_COLOR: '5763719'

notify_failure:
  extends: .discord_notify
  when: on_failure
  variables:
    STATUS_TEXT: '❌ Pipeline failed:'
    STATUS_COLOR: '15548997'

What each piece does:

  • when: on_failure runs the job only if a job in an earlier stage failed; when: on_success (the default) only if everything passed. At most one of the two fires per pipeline; a cancelled pipeline runs neither.
  • url on the embed turns the title into a link to the pipeline. CI_PIPELINE_URL, CI_COMMIT_TITLE, CI_COMMIT_SHORT_SHA and GITLAB_USER_NAME are predefined variables.
  • --argjson color turns the string variable into a JSON number: color must be a decimal integer, not "#57F287".
  • curl -f fails the job on a 4xx/5xx response instead of silently going green; --retry 3 covers transient errors, including 429.

Per-job failure alerts with after_script

If you want to know which job failed without adding a stage, hook into after_script. CI_JOB_STATUS is success, failed or canceled there:

.alert_on_fail:
  after_script:
    - |
      if [ "$CI_JOB_STATUS" = "failed" ]; then
        curl -sS -H "Content-Type: application/json" \
          -d "{\"content\": \"❌ **$CI_JOB_NAME** failed on \`$CI_COMMIT_REF_NAME\` — [open job]($CI_JOB_URL)\"}" \
          "$DISCORD_WEBHOOK_URL"
      fi

test:
  extends: .alert_on_fail
  script:
    - npm test

Jenkins: the post {} block in a declarative pipeline

Declarative pipelines have a post section that runs after all stages. Instead of copying curl into success, failure and unstable, map currentBuild.currentResult to a colour in one helper called from always:

pipeline {
  agent any

  stages {
    stage('Build') {
      steps { sh 'npm ci && npm run build' }
    }
    stage('Test') {
      steps { sh 'npm test' }
    }
  }

  post {
    always {
      discordNotify(currentBuild.currentResult)
    }
  }
}

def discordNotify(String result) {
  def colors = [SUCCESS: 5763719, FAILURE: 15548997, UNSTABLE: 16705372, ABORTED: 9807270]
  def icons  = [SUCCESS: '✅', FAILURE: '❌', UNSTABLE: '⚠️', ABORTED: '⏹️']

  def payload = groovy.json.JsonOutput.toJson([
    username: 'Jenkins',
    embeds: [[
      title: "${icons[result]} ${env.JOB_NAME} #${env.BUILD_NUMBER}: ${result}",
      url: env.BUILD_URL,
      color: colors[result],
      fields: [
        [name: 'Branch', value: env.GIT_BRANCH ?: 'n/a', inline: true],
        [name: 'Duration', value: currentBuild.durationString.replace(' and counting', ''), inline: true]
      ]
    ]]
  ])

  writeFile file: 'discord-payload.json', text: payload
  withCredentials([string(credentialsId: 'discord-webhook-url', variable: 'DISCORD_WEBHOOK_URL')]) {
    sh 'curl -sS -f -H "Content-Type: application/json" -d @discord-payload.json "$DISCORD_WEBHOOK_URL"'
  }
}

Why this shape:

  • JsonOutput.toJson escapes quotes and newlines, and writeFile + -d @file keeps the payload out of shell quoting entirely.
  • withCredentials injects the secret and masks it in the console, so even the set -x echo shows ****; the single-quoted sh string lets the shell, not Groovy, expand $DISCORD_WEBHOOK_URL.
  • currentBuild.durationString ends with ” and counting” while the build is still running, which is always true inside post, hence the replace.

The httpRequest plugin instead of curl

Without curl on the agent, the HTTP Request plugin makes the same call inside the same withCredentials block:

httpRequest url: env.DISCORD_WEBHOOK_URL, httpMode: 'POST',
  contentType: 'APPLICATION_JSON', requestBody: payload, validResponseCodes: '200:204'

The plugin logs the request URL, but inside withCredentials the console filter masks it.

The Discord Notifier plugin

If you want zero JSON, the Discord Notifier plugin adds a discordSend step that colours the embed from the build result on its own:

post {
  always {
    withCredentials([string(credentialsId: 'discord-webhook-url', variable: 'DISCORD_WEBHOOK_URL')]) {
      discordSend(
        webhookURL: env.DISCORD_WEBHOOK_URL,
        title: "${env.JOB_NAME} #${env.BUILD_NUMBER}",
        link: env.BUILD_URL,
        result: currentBuild.currentResult,
        description: "Branch: ${env.GIT_BRANCH}\nDuration: ${currentBuild.durationString}",
        footer: 'Jenkins'
      )
    }
  }
}

With curl you own the full payload (up to 25 fields, images, several embeds); with the plugin you get what its parameters expose.

Colour by status

color is a decimal integer; a small palette covers every CI state:

StateHexDecimal
Passed#57F2875763719
Failed#ED424515548997
Unstable / warning#FEE75C16705372
Running#5865F25793266
Cancelled#95A5A69807270

In a shell, echo $((16#ED4245)) converts hex to decimal; more shades are in Discord embed colours. Keep the status word in the title too: colour-blind teammates and mobile notification previews do not see the side bar.

Cutting the noise: one message per pipeline

Twelve jobs, twelve green pings: the usual failure mode. Four rules fix it.

Notify from one place. A final notify stage in GitLab, the pipeline-level post block in Jenkins. Never per job or per stage, failure alerts aside.

Success only where it matters. The default branch and tags get success and failure; feature branches get failures only; merge-request pipelines get nothing, the MR widget already shows their status:

notify_success:
  extends: .discord_notify
  variables:
    STATUS_TEXT: '✅ Pipeline passed:'
    STATUS_COLOR: '5763719'
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH || $CI_COMMIT_TAG
      when: on_success

notify_failure:
  extends: .discord_notify
  variables:
    STATUS_TEXT: '❌ Pipeline failed:'
    STATUS_COLOR: '15548997'
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
      when: never
    - when: on_failure

In Jenkins, post on change. failure plus fixed gives you the red message and exactly one green one when the build recovers, instead of a green message per build:

post {
  failure { discordNotify('FAILURE') }
  fixed   { discordNotify('SUCCESS') }
}

Edit instead of re-posting. Send a “running” message with ?wait=true, keep the id from the response (in GitLab, write it to a dotenv artifact so later jobs see it), then PATCH that message with the final payload. One line per pipeline, always current:

MSG_ID=$(curl -sS -H "Content-Type: application/json" -d "$RUNNING_PAYLOAD" \
  "$DISCORD_WEBHOOK_URL?wait=true" | jq -r .id)
# later, in the notify job:
curl -sS -f -X PATCH -H "Content-Type: application/json" -d "$PAYLOAD" \
  "$DISCORD_WEBHOOK_URL/messages/$MSG_ID"

The full flow is in editing and deleting webhook messages. Whichever route you take, a monorepo where every job posts will hit 429 quickly, see Discord webhook rate limits.

Common errors

The job is green but nothing appeared in Discord. Without -f, curl exits 0 on a 400 or 404. Add -f -sS (or --fail-with-body, curl 7.76+, which fails the job but still prints the response) or print the status with -w '%{http_code}', then read the response body: Discord names the invalid field. Plain -f hides the body.

400 Bad Request after one specific commit. The commit title contained a quote or backslash and went straight into a JSON string. Build the payload with jq --arg or JsonOutput.toJson, never by hand in shell.

{"message": "Unknown Webhook", "code": 10015}. The URL is wrong or the webhook was deleted in Discord. If curl instead exits with (3) URL using bad/illegal format or missing URL, the variable is empty here: a protected variable is invisible to unprotected branches, an environment-scoped one outside its environment. A safe check is echo ${#DISCORD_WEBHOOK_URL}: length 0 means it is missing.

notify_failure never runs although a job is red. The red job has allow_failure: true, so GitLab counts the pipeline as passed. Failures in allow_failure jobs never trigger on_failure.

Jenkins post { failure } stays silent when tests fail. junit marks the build UNSTABLE, not FAILED. Handle unstable too, or use always with currentBuild.currentResult as above.

No such DSL method 'httpRequest'. The plugin is not installed on that controller; install it or fall back to sh 'curl …'.

The token showed up in the log. GitLab masks only the exact variable value: echo a decoded or transformed copy and it is visible. Rotate the webhook (delete and recreate it) and fix the script. The same applies in Jenkins outside withCredentials.

429 Too Many Requests. Too many jobs posting at once: honour retry_after and move to a single notify stage.

FAQ

Can GitLab post to Discord without any YAML?

Yes. GitLab has a Discord Notifications integration (Settings → Integrations → Discord Notifications) that sends pipeline, push and merge-request events to a webhook URL you paste in. It is fine for basic pings; the curl job gives you your own embed layout and colours.

How do I ping a person or role when the pipeline fails?

Put <@&ROLE_ID> in content and allow it explicitly: "allowed_mentions": {"roles": ["ROLE_ID"]}. Without allowed_mentions Discord parses every mention in content, so an @everyone that slipped into a commit title would ping the whole server; listing the role keeps the ping to that role only. Details in Discord webhook mentions.

Can I attach the test log or a coverage report to the message?

Yes, switch to multipart/form-data: curl -F 'payload_json={"content":"Test log"}' -F 'files[0][email protected]' "$DISCORD_WEBHOOK_URL". Up to 10 files, 10 MB each by default. See sending files through webhooks.

Does the same setup work in scripted Jenkins pipelines?

Yes. There is no post block, so wrap the stages in try { … } catch (e) { currentBuild.result = 'FAILURE'; throw e } finally { discordNotify(currentBuild.currentResult) }.

Wrapping up

Post from one place, serialise the JSON properly, colour by status and keep success pings for branches people watch. To design the embed before you script it, open the Discord Webhook builder, assemble fields and colours visually and export the JSON into your .gitlab-ci.yml or Jenkinsfile.

Related reading:

Tags: gitlab cijenkinsci/cddiscord webhookpipeline notificationsdevopscurl

Related articles

All articles

Build it in the visual editor

Embeds, Components V2, buttons and polls with a live Discord preview. Free, no signup.