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.
On this page
- Store the webhook URL as a secret
- GitLab CI: a notify stage with on_success and on_failure
- Per-job failure alerts with after_script
- Jenkins: the post ${} block in a declarative pipeline
- The httpRequest plugin instead of curl
- The Discord Notifier plugin
- Colour by status
- Cutting the noise: one message per pipeline
- Common errors
- FAQ
- Can GitLab post to Discord without any YAML?
- How do I ping a person or role when the pipeline fails?
- Can I attach the test log or a coverage report to the message?
- Does the same setup work in scripted Jenkins pipelines?
- Wrapping up
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_failureruns 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.urlon the embed turns the title into a link to the pipeline.CI_PIPELINE_URL,CI_COMMIT_TITLE,CI_COMMIT_SHORT_SHAandGITLAB_USER_NAMEare predefined variables.--argjson colorturns the string variable into a JSON number:colormust be a decimal integer, not"#57F287".curl -ffails the job on a 4xx/5xx response instead of silently going green;--retry 3covers 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.toJsonescapes quotes and newlines, andwriteFile+-d @filekeeps the payload out of shell quoting entirely.withCredentialsinjects the secret and masks it in the console, so even theset -xecho shows****; the single-quotedshstring lets the shell, not Groovy, expand$DISCORD_WEBHOOK_URL.currentBuild.durationStringends with ” and counting” while the build is still running, which is always true insidepost, hence thereplace.
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:
| State | Hex | Decimal |
|---|---|---|
| Passed | #57F287 | 5763719 |
| Failed | #ED4245 | 15548997 |
| Unstable / warning | #FEE75C | 16705372 |
| Running | #5865F2 | 5793266 |
| Cancelled | #95A5A6 | 9807270 |
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: