Integrations

Slack Notifications for Azure DevOps Events

A practical guide to setting up Slack notifications for Azure DevOps events, covering incoming webhooks, service hook configuration, custom notification formatting, pipeline status alerts, work item updates, pull request notifications, and building a custom Slack bot for Azure DevOps.

Slack Notifications for Azure DevOps Events

Overview

Your team lives in Slack. Build failures, pull request reviews, deployment completions, and work item assignments all need to surface in the channels where people are already communicating. Azure DevOps has a built-in Slack app and service hooks for sending notifications, but the default messages are verbose and noisy. Effective Slack integration means sending the right information to the right channel at the right time -- build failures to the engineering channel with direct links, deployment completions to the release channel with environment details, and PR approvals to the team channel with just enough context to act.

I have seen teams with Slack channels drowning in Azure DevOps notifications where nobody reads them, and teams with zero notifications where build failures go unnoticed for hours. The sweet spot is selective, well-formatted notifications that prompt action. This article covers both the official Azure DevOps Slack app and custom webhook integrations for complete control over message content and routing.

Prerequisites

  • An Azure DevOps organization with Azure Pipelines
  • A Slack workspace with permission to install apps and create webhooks
  • Admin access to the Slack workspace (for app installation) or ability to create incoming webhooks
  • Node.js 18+ for custom integration scripts
  • Familiarity with Azure DevOps service hooks
  • Basic understanding of Slack's Block Kit message format

The Official Azure DevOps Slack App

Microsoft provides an official Azure DevOps app for Slack that supports subscriptions to pipeline events, work item changes, pull request updates, and repository events.

Installation

  1. In Slack, go to the App Directory and search for "Azure DevOps"
  2. Click "Add to Slack" and authorize the app
  3. In the Slack channel where you want notifications, type /azdevops signin
  4. Follow the OAuth flow to connect your Azure DevOps account

Creating Subscriptions

Use the /azdevops subscribe command to set up notifications:

/azdevops subscribe https://dev.azure.com/myorg/myproject

This creates default subscriptions for the project. To customize:

# Subscribe to specific events
/azdevops subscribe https://dev.azure.com/myorg/myproject --event build.complete
/azdevops subscribe https://dev.azure.com/myorg/myproject --event pullrequest.created
/azdevops subscribe https://dev.azure.com/myorg/myproject --event workitem.updated

# Filter subscriptions
/azdevops subscribe https://dev.azure.com/myorg/myproject --event build.complete --pipeline "Production Deploy"
/azdevops subscribe https://dev.azure.com/myorg/myproject --event workitem.updated --area-path "MyProject\Backend"

# List current subscriptions
/azdevops subscriptions

# Remove a subscription
/azdevops unsubscribe <subscription-id>

Limitations of the Official App

The official app works well for basic notifications but has limitations:

  • Message format is fixed -- you cannot customize the layout or content
  • No conditional logic -- you cannot suppress notifications for successful builds while alerting on failures
  • Limited filtering -- area paths and pipeline names, but no custom field filters
  • No thread support -- every notification is a new message, cluttering the channel
  • Rate limiting -- high-volume projects can hit Slack's rate limits

For teams that need more control, custom webhooks provide the flexibility the official app lacks.

Custom Notifications with Incoming Webhooks

Slack incoming webhooks accept JSON payloads and post messages to a specific channel. Combined with Azure DevOps service hooks, you can build a custom notification system with full control over message content, formatting, and routing.

Setting Up Slack Incoming Webhooks

  1. Go to https://api.slack.com/apps and create a new app
  2. Under "Incoming Webhooks," enable the feature
  3. Click "Add New Webhook to Workspace"
  4. Select the channel for notifications
  5. Copy the webhook URL (format: https://hooks.slack.com/services/T.../B.../xxx)

Sending Messages via Webhook

// slack/send-notification.js
var https = require("https");
var url = require("url");

function sendSlackMessage(webhookUrl, message) {
  return new Promise(function (resolve, reject) {
    var parsed = url.parse(webhookUrl);
    var options = {
      hostname: parsed.hostname,
      path: parsed.path,
      method: "POST",
      headers: { "Content-Type": "application/json" },
    };

    var req = https.request(options, function (res) {
      var data = "";
      res.on("data", function (chunk) { data += chunk; });
      res.on("end", function () {
        if (res.statusCode === 200) {
          resolve(data);
        } else {
          reject(new Error("Slack webhook failed: " + res.statusCode + " " + data));
        }
      });
    });

    req.on("error", reject);
    req.write(JSON.stringify(message));
    req.end();
  });
}

module.exports = { sendSlackMessage: sendSlackMessage };

Build Status Notifications

// slack/build-notification.js
var sendSlack = require("./send-notification").sendSlackMessage;

var WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL;

function buildStatusMessage(buildData) {
  var status = buildData.status;
  var result = buildData.result;
  var emoji = result === "succeeded" ? ":white_check_mark:" : result === "failed" ? ":x:" : ":warning:";
  var color = result === "succeeded" ? "#36a64f" : result === "failed" ? "#dc3545" : "#ffc107";

  var message = {
    blocks: [
      {
        type: "section",
        text: {
          type: "mrkdwn",
          text: emoji + " *Build " + result.toUpperCase() + "*: " + buildData.definition.name,
        },
      },
      {
        type: "section",
        fields: [
          {
            type: "mrkdwn",
            text: "*Build:* <" + buildData._links.web.href + "|#" + buildData.buildNumber + ">",
          },
          {
            type: "mrkdwn",
            text: "*Branch:* `" + (buildData.sourceBranch || "").replace("refs/heads/", "") + "`",
          },
          {
            type: "mrkdwn",
            text: "*Triggered by:* " + (buildData.requestedFor ? buildData.requestedFor.displayName : "unknown"),
          },
          {
            type: "mrkdwn",
            text: "*Duration:* " + formatDuration(buildData.startTime, buildData.finishTime),
          },
        ],
      },
    ],
    attachments: [
      {
        color: color,
        blocks: [],
      },
    ],
  };

  // Add failure details if available
  if (result === "failed" && buildData.failedStage) {
    message.blocks.push({
      type: "section",
      text: {
        type: "mrkdwn",
        text: ":mag: *Failed stage:* " + buildData.failedStage,
      },
    });
  }

  return message;
}

function formatDuration(start, end) {
  if (!start || !end) { return "unknown"; }
  var ms = new Date(end) - new Date(start);
  var minutes = Math.floor(ms / 60000);
  var seconds = Math.floor((ms % 60000) / 1000);
  return minutes + "m " + seconds + "s";
}

// Used from pipeline
var BUILD_RESULT = process.env.AGENT_JOBSTATUS || process.argv[2] || "unknown";
var BUILD_NUMBER = process.env.BUILD_BUILDNUMBER || "local";
var BUILD_URL = process.env.BUILD_BUILDURI || "#";
var BUILD_BRANCH = process.env.BUILD_SOURCEBRANCH || "unknown";
var BUILD_REQUESTEDFOR = process.env.BUILD_REQUESTEDFOR || "local";
var BUILD_DEFINITION = process.env.BUILD_DEFINITIONNAME || "unknown";
var BUILD_START = process.env.BUILD_STARTTIME || null;

var buildData = {
  status: "completed",
  result: BUILD_RESULT === "Succeeded" ? "succeeded" : BUILD_RESULT === "Failed" ? "failed" : BUILD_RESULT.toLowerCase(),
  buildNumber: BUILD_NUMBER,
  sourceBranch: BUILD_BRANCH,
  requestedFor: { displayName: BUILD_REQUESTEDFOR },
  definition: { name: BUILD_DEFINITION },
  _links: { web: { href: BUILD_URL.replace("vstfs:///Build/Build/", "/_build/results?buildId=") } },
  startTime: BUILD_START,
  finishTime: new Date().toISOString(),
};

if (!WEBHOOK_URL) {
  console.error("SLACK_WEBHOOK_URL not set");
  process.exit(1);
}

sendSlack(WEBHOOK_URL, buildStatusMessage(buildData))
  .then(function () { console.log("Slack notification sent"); })
  .catch(function (err) {
    console.error("Failed to send notification: " + err.message);
    // Don't fail the pipeline for notification errors
  });

Pipeline Integration

steps:
  - script: npm test
    displayName: Run tests

  # Send notification on completion (success or failure)
  - script: node slack/build-notification.js
    displayName: Notify Slack
    condition: always()
    env:
      SLACK_WEBHOOK_URL: $(SLACK_WEBHOOK_URL)

  # Or send only on failure
  - script: node slack/build-notification.js
    displayName: Notify Slack (failure only)
    condition: failed()
    env:
      SLACK_WEBHOOK_URL: $(SLACK_WEBHOOK_URL)

Pull Request Notifications

// slack/pr-notification.js
var sendSlack = require("./send-notification").sendSlackMessage;

function prMessage(pr, action) {
  var emoji = {
    created: ":new:",
    updated: ":pencil2:",
    approved: ":thumbsup:",
    merged: ":merged:",
    abandoned: ":wastebasket:",
  };

  var message = {
    blocks: [
      {
        type: "section",
        text: {
          type: "mrkdwn",
          text: (emoji[action] || ":git:") + " *Pull Request " + action.toUpperCase() + "*",
        },
      },
      {
        type: "section",
        text: {
          type: "mrkdwn",
          text: "<" + pr.url + "|" + pr.title + "> (#" + pr.pullRequestId + ")",
        },
      },
      {
        type: "context",
        elements: [
          {
            type: "mrkdwn",
            text: "*Author:* " + pr.createdBy.displayName +
              " | *Reviewers:* " + (pr.reviewers || []).map(function (r) {
                return r.displayName;
              }).join(", ") +
              " | *Branch:* `" + pr.sourceRefName.replace("refs/heads/", "") +
              "` → `" + pr.targetRefName.replace("refs/heads/", "") + "`",
          },
        ],
      },
    ],
  };

  return message;
}

module.exports = { prMessage: prMessage };

Channel Routing Strategy

Send different event types to different channels:

// slack/router.js
var CHANNELS = {
  builds: process.env.SLACK_BUILDS_WEBHOOK,       // #builds
  deployments: process.env.SLACK_DEPLOYS_WEBHOOK,  // #deployments
  pullRequests: process.env.SLACK_PR_WEBHOOK,      // #pull-requests
  alerts: process.env.SLACK_ALERTS_WEBHOOK,        // #alerts (failures only)
  general: process.env.SLACK_GENERAL_WEBHOOK,      // #engineering
};

function routeNotification(eventType, result) {
  var targets = [];

  if (eventType === "build.complete") {
    targets.push(CHANNELS.builds);
    if (result === "failed") {
      targets.push(CHANNELS.alerts);
    }
  } else if (eventType === "release.deployment.completed") {
    targets.push(CHANNELS.deployments);
    if (result === "failed") {
      targets.push(CHANNELS.alerts);
    }
  } else if (eventType === "pullrequest.created" || eventType === "pullrequest.updated") {
    targets.push(CHANNELS.pullRequests);
  }

  return targets.filter(function (t) { return t; });
}

module.exports = { routeNotification: routeNotification, CHANNELS: CHANNELS };

Azure DevOps Service Hooks for Slack

For server-side integration without a custom middleware, use Azure DevOps service hooks that post directly to Slack webhooks.

Configuring Service Hooks

  1. Navigate to Project Settings > Service Hooks
  2. Click "Create subscription"
  3. Select "Slack" as the service
  4. Configure the event trigger and Slack channel

Available event triggers:

  • Build completed: Pipeline build finishes
  • Release deployment completed: Deployment to an environment finishes
  • Code pushed: New commits are pushed to a repository
  • Pull request created/updated/merged: PR lifecycle events
  • Work item created/updated: Work item changes

Filtering Service Hooks

Configure filters to reduce noise:

Event: Build completed
Filter: Build definition = "Production Deploy"
Filter: Build status = Failed
Channel: #alerts

This sends notifications only when the production deployment pipeline fails -- not for every build of every pipeline.

Complete Working Example

A comprehensive Slack notification middleware that receives Azure DevOps webhooks, formats messages with Block Kit, and routes them to appropriate channels:

// slack/notification-service.js
var http = require("http");
var https = require("https");
var url = require("url");

var PORT = process.env.PORT || 8091;

var WEBHOOKS = {
  builds: process.env.SLACK_BUILDS_WEBHOOK,
  deploys: process.env.SLACK_DEPLOYS_WEBHOOK,
  prs: process.env.SLACK_PR_WEBHOOK,
  alerts: process.env.SLACK_ALERTS_WEBHOOK,
};

function sendToSlack(webhookUrl, payload) {
  if (!webhookUrl) { return Promise.resolve(); }

  return new Promise(function (resolve, reject) {
    var parsed = url.parse(webhookUrl);
    var options = {
      hostname: parsed.hostname,
      path: parsed.path,
      method: "POST",
      headers: { "Content-Type": "application/json" },
    };

    var req = https.request(options, function (res) {
      var data = "";
      res.on("data", function (chunk) { data += chunk; });
      res.on("end", function () { resolve(data); });
    });

    req.on("error", reject);
    req.write(JSON.stringify(payload));
    req.end();
  });
}

function handleBuildComplete(payload) {
  var resource = payload.resource;
  var result = resource.result || "unknown";
  var isFailure = result === "failed" || result === "partiallySucceeded";
  var emoji = result === "succeeded" ? ":white_check_mark:" : isFailure ? ":x:" : ":warning:";
  var color = result === "succeeded" ? "#36a64f" : isFailure ? "#dc3545" : "#ffc107";

  var buildUrl = "https://dev.azure.com/" + payload.resourceContainers.project.baseUrl +
    "/_build/results?buildId=" + resource.id;

  var message = {
    attachments: [{
      color: color,
      blocks: [
        {
          type: "section",
          text: {
            type: "mrkdwn",
            text: emoji + " *" + resource.definition.name + "* build " + result +
              "\n<" + buildUrl + "|Build #" + resource.buildNumber + "> on branch `" +
              (resource.sourceBranch || "").replace("refs/heads/", "") + "`" +
              "\nTriggered by " + (resource.requestedFor ? resource.requestedFor.displayName : "unknown"),
          },
        },
      ],
    }],
  };

  var promises = [sendToSlack(WEBHOOKS.builds, message)];
  if (isFailure) {
    promises.push(sendToSlack(WEBHOOKS.alerts, message));
  }

  return Promise.all(promises);
}

function handlePREvent(payload) {
  var resource = payload.resource;
  var eventType = payload.eventType;
  var action = eventType.replace("git.pullrequest.", "");

  var emoji = {
    created: ":new:",
    updated: ":pencil2:",
    merged: ":merged:",
  };

  var prUrl = resource.url || "#";

  var message = {
    blocks: [
      {
        type: "section",
        text: {
          type: "mrkdwn",
          text: (emoji[action] || ":git:") + " *PR " + action + ":* <" + prUrl + "|" +
            resource.title + "> (#" + resource.pullRequestId + ")\n" +
            "by " + (resource.createdBy ? resource.createdBy.displayName : "unknown") +
            " | `" + (resource.sourceRefName || "").replace("refs/heads/", "") +
            "` → `" + (resource.targetRefName || "").replace("refs/heads/", "") + "`",
        },
      },
    ],
  };

  return sendToSlack(WEBHOOKS.prs, message);
}

function handleDeployment(payload) {
  var resource = payload.resource;
  var environment = resource.environment || {};
  var status = resource.deploymentStatus || "unknown";
  var isFailure = status === "failed";
  var emoji = status === "succeeded" ? ":rocket:" : isFailure ? ":boom:" : ":hourglass:";

  var message = {
    attachments: [{
      color: isFailure ? "#dc3545" : "#36a64f",
      blocks: [{
        type: "section",
        text: {
          type: "mrkdwn",
          text: emoji + " *Deployment " + status + "* to *" +
            (environment.name || "unknown") + "*\n" +
            "Release: " + (resource.release ? resource.release.name : "unknown"),
        },
      }],
    }],
  };

  var promises = [sendToSlack(WEBHOOKS.deploys, message)];
  if (isFailure) {
    promises.push(sendToSlack(WEBHOOKS.alerts, message));
  }

  return Promise.all(promises);
}

// Webhook server
var server = http.createServer(function (req, res) {
  if (req.method !== "POST") {
    res.writeHead(405);
    res.end("Method not allowed");
    return;
  }

  var body = "";
  req.on("data", function (chunk) { body += chunk; });
  req.on("end", function () {
    var payload;
    try { payload = JSON.parse(body); } catch (e) {
      res.writeHead(400);
      res.end("Invalid JSON");
      return;
    }

    var eventType = payload.eventType || "";
    console.log("Event: " + eventType);

    var handler;
    if (eventType === "build.complete") {
      handler = handleBuildComplete(payload);
    } else if (eventType.startsWith("git.pullrequest.")) {
      handler = handlePREvent(payload);
    } else if (eventType.startsWith("ms.vss-release.deployment")) {
      handler = handleDeployment(payload);
    } else {
      console.log("Unhandled event type: " + eventType);
      res.writeHead(200);
      res.end("Ignored");
      return;
    }

    handler
      .then(function () {
        res.writeHead(200);
        res.end("OK");
      })
      .catch(function (err) {
        console.error("Error: " + err.message);
        res.writeHead(200); // Return 200 to prevent retries
        res.end("OK");
      });
  });
});

server.listen(PORT, function () {
  console.log("Slack notification service listening on port " + PORT);
});

Common Issues and Troubleshooting

Webhook Returns "channel_not_found"

The incoming webhook is associated with a specific channel. If the channel is renamed, archived, or deleted, the webhook stops working. Create a new webhook URL for the renamed or replacement channel. Webhook URLs are permanently tied to the original channel ID, not the channel name.

Notifications Are Too Noisy

The default Azure DevOps Slack app sends notifications for every event with minimal filtering. Fix by: (a) removing the default subscription with /azdevops unsubscribe, (b) creating specific subscriptions with filters: /azdevops subscribe <url> --event build.complete --pipeline "Production", (c) using custom webhooks with server-side filtering logic.

Messages Not Formatting Correctly

Slack's Block Kit has strict JSON structure requirements. Common mistakes: (a) using text blocks without the required type field, (b) exceeding the 3000-character limit for text blocks, (c) nesting blocks incorrectly. Use the Slack Block Kit Builder (https://app.slack.com/block-kit-builder) to validate message payloads before implementing them in code.

Rate Limiting from Slack

Slack limits incoming webhook messages to 1 per second per webhook URL. High-volume projects with many simultaneous builds can exceed this limit. Solutions: (a) batch messages using a queue with a 1-second delay between sends, (b) use different webhook URLs for different event types, (c) aggregate multiple events into a single summary message.

Service Hook Not Triggering

Azure DevOps service hooks require the webhook endpoint to respond with HTTP 200 within 20 seconds. If your middleware is slow or returns a non-200 status, the service hook marks the delivery as failed. Check the delivery history in Project Settings > Service Hooks. Also verify that the event filters match -- a filter for "Build definition = Production Deploy" will not fire for builds named "production-deploy" (case-sensitive).

Best Practices

  • Route notifications to purpose-specific channels. Build results go to #builds, deployments to #deployments, PR reviews to #pull-requests. A single #azure-devops channel with everything becomes noise that everyone mutes.

  • Only notify on actionable events. Successful builds that nobody needs to act on do not need notifications. Failed builds, pending PR reviews, and deployment completions are actionable. Filter out events that do not require a response.

  • Include direct links in every notification. Every Slack message about a build should include a clickable link to the build results. Every PR notification should link to the PR. Eliminate the "let me go find that" step.

  • Use Slack's Block Kit for rich formatting. Block Kit messages with sections, context, and attachments are more readable than plain text. Color-code by status (green for success, red for failure) so the team can scan the channel at a glance.

  • Handle notification failures silently. Never fail a pipeline because a Slack notification could not be sent. Notification is a courtesy, not a gate. Log the error and continue.

  • Consolidate related notifications. When a pipeline has 5 stages, do not send 5 separate Slack messages. Send one summary message when the pipeline completes with the status of each stage.

References

Powered by Contentful