Integrations

Slack Notifications for Azure DevOps Events

Send rich Slack notifications from Azure DevOps events with Block Kit formatting, custom routing, and interactive approvals

Slack Notifications for Azure DevOps Events

Azure DevOps has built-in email notifications, but nobody reads those. Your team lives in Slack, and that is where your build failures, PR reviews, and work item updates need to land. This article walks through building a Node.js middleware service that receives Azure DevOps webhooks and transforms them into rich, actionable Slack messages using Block Kit formatting, custom routing logic, and interactive approval buttons.

Prerequisites

  • Node.js v16 or later installed
  • An Azure DevOps organization with at least one project
  • A Slack workspace where you can create apps
  • Basic familiarity with Express.js and webhooks
  • An accessible server or cloud endpoint (e.g., DigitalOcean droplet, Azure App Service) for receiving webhooks

Azure DevOps Service Hooks Overview

Azure DevOps provides a service hooks system that pushes event notifications to external services over HTTP. Unlike polling-based integrations, service hooks fire in near real-time when something happens in your project. The supported event categories include:

  • Build and release — build completed, release deployment completed, release approval pending
  • Code — code pushed, pull request created, pull request updated, pull request merge attempted
  • Work items — work item created, updated, deleted, commented on
  • Pipelines — run state changed, run stage state changed, run stage waiting for approval

Each event payload follows a consistent JSON structure with an eventType field, a resource object containing the event details, and metadata about the subscription. This consistency is what makes it straightforward to build a single receiver endpoint that handles multiple event types.

To configure a service hook, navigate to Project Settings > Service hooks in Azure DevOps. You select the event type, apply optional filters (like a specific build definition or branch), and provide your webhook URL. Azure DevOps will send a POST request with a JSON payload every time the event fires.

Configuring Slack Incoming Webhooks

Before writing any code, you need a Slack app with incoming webhook capability. Go to api.slack.com/apps, create a new app, and enable Incoming Webhooks under the Features section. Add a webhook URL for each channel you want to post to.

Each webhook URL looks like https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX. Store these as environment variables, never hardcode them.

export SLACK_WEBHOOK_BUILDS="#builds"
export SLACK_WEBHOOK_BUILDS_URL="https://hooks.slack.com/services/T.../B.../xxx"
export SLACK_WEBHOOK_PRS="#pull-requests"
export SLACK_WEBHOOK_PRS_URL="https://hooks.slack.com/services/T.../B.../yyy"
export SLACK_WEBHOOK_WORKITEMS="#work-items"
export SLACK_WEBHOOK_WORKITEMS_URL="https://hooks.slack.com/services/T.../B.../zzz"

For interactive features like approval buttons, you will also need a Slack app with Interactivity enabled and a Bot Token with chat:write scope. The bot token approach gives you more control over message updates and threading than simple incoming webhooks.

Pipeline Completion Notifications

Pipeline completions are the bread and butter of DevOps notifications. When a build finishes, you want to know immediately whether it passed or failed, which branch triggered it, and how long it took. Here is how the Azure DevOps payload looks for a build completed event:

{
  "eventType": "build.complete",
  "resource": {
    "id": 1234,
    "buildNumber": "20260213.1",
    "status": "completed",
    "result": "succeeded",
    "definition": { "name": "MyApp-CI" },
    "requestedFor": { "displayName": "Shane Larson" },
    "sourceBranch": "refs/heads/main",
    "startTime": "2026-02-13T10:00:00Z",
    "finishTime": "2026-02-13T10:04:32Z",
    "_links": {
      "web": { "href": "https://dev.azure.com/org/project/_build/results?buildId=1234" }
    }
  }
}

The key fields are result (succeeded, partiallySucceeded, failed, canceled) and the timing fields that let you calculate duration.

PR Review Notifications

Pull request events come in several flavors: created, updated, merged, and commented. The most useful notification is when a PR is created or when reviewers are added, so the right people know they need to take action.

function formatPRMessage(payload) {
  var pr = payload.resource;
  var reviewers = pr.reviewers || [];
  var reviewerNames = reviewers.map(function(r) { return r.displayName; }).join(", ");

  return {
    blocks: [
      {
        type: "header",
        text: { type: "plain_text", text: "Pull Request " + payload.eventType.split(".").pop() }
      },
      {
        type: "section",
        text: {
          type: "mrkdwn",
          text: "*<" + pr.url + "|" + pr.title + ">*\n" +
                "Author: " + pr.createdBy.displayName + "\n" +
                "Repository: " + pr.repository.name + "\n" +
                "Branch: `" + pr.sourceRefName.replace("refs/heads/", "") +
                "` → `" + pr.targetRefName.replace("refs/heads/", "") + "`"
        }
      },
      {
        type: "context",
        elements: [
          { type: "mrkdwn", text: "Reviewers: " + (reviewerNames || "None assigned") }
        ]
      }
    ]
  };
}

Work Item Change Alerts

Work item notifications can get noisy fast. You probably do not want a Slack message every time someone changes a description field. Focus on state transitions — when a bug moves to Active, when a user story moves to Resolved, when a task gets assigned.

function shouldNotifyWorkItem(payload) {
  var fields = payload.resource.revision.fields;
  var changedFields = payload.resource.fields || {};

  // Only notify on state changes or assignment changes
  if (changedFields["System.State"] || changedFields["System.AssignedTo"]) {
    return true;
  }

  return false;
}

Build Failure Alerts with Rich Formatting

Build failures deserve special treatment. They need to be loud, clear, and provide enough context that someone can start investigating without leaving Slack. Slack's Block Kit gives you the formatting tools to make failure notifications stand out.

function formatBuildFailure(payload) {
  var build = payload.resource;
  var duration = calculateDuration(build.startTime, build.finishTime);
  var branch = build.sourceBranch.replace("refs/heads/", "");
  var buildUrl = build._links.web.href;

  return {
    blocks: [
      {
        type: "header",
        text: { type: "plain_text", text: "🔴 Build Failed", emoji: true }
      },
      {
        type: "section",
        fields: [
          { type: "mrkdwn", text: "*Pipeline:*\n" + build.definition.name },
          { type: "mrkdwn", text: "*Build:*\n<" + buildUrl + "|#" + build.buildNumber + ">" },
          { type: "mrkdwn", text: "*Branch:*\n`" + branch + "`" },
          { type: "mrkdwn", text: "*Duration:*\n" + duration },
          { type: "mrkdwn", text: "*Triggered by:*\n" + build.requestedFor.displayName },
          { type: "mrkdwn", text: "*Result:*\n" + build.result }
        ]
      },
      {
        type: "actions",
        elements: [
          {
            type: "button",
            text: { type: "plain_text", text: "View Build Logs" },
            url: buildUrl,
            style: "danger"
          }
        ]
      }
    ]
  };
}

function calculateDuration(startTime, finishTime) {
  var start = new Date(startTime);
  var end = new Date(finishTime);
  var diffMs = end - start;
  var minutes = Math.floor(diffMs / 60000);
  var seconds = Math.floor((diffMs % 60000) / 1000);
  return minutes + "m " + seconds + "s";
}

Custom Notification Routing

Not every notification should go to the same channel. Build failures for the backend team should go to #backend-builds, frontend failures to #frontend-builds, and critical production deployment events to #ops-alerts. A routing configuration lets you map event types and project attributes to specific Slack channels.

var ROUTING_CONFIG = {
  rules: [
    {
      match: { eventType: "build.complete", result: "failed", definitionName: /^Backend/ },
      channel: process.env.SLACK_WEBHOOK_BACKEND
    },
    {
      match: { eventType: "build.complete", result: "failed", definitionName: /^Frontend/ },
      channel: process.env.SLACK_WEBHOOK_FRONTEND
    },
    {
      match: { eventType: "build.complete", result: "failed" },
      channel: process.env.SLACK_WEBHOOK_BUILDS
    },
    {
      match: { eventType: /^git\.pullrequest/ },
      channel: process.env.SLACK_WEBHOOK_PRS
    },
    {
      match: { eventType: /^workitem/ },
      channel: process.env.SLACK_WEBHOOK_WORKITEMS
    }
  ],
  defaultChannel: process.env.SLACK_WEBHOOK_DEFAULT
};

function resolveChannel(payload) {
  var eventType = payload.eventType;
  var build = payload.resource;

  for (var i = 0; i < ROUTING_CONFIG.rules.length; i++) {
    var rule = ROUTING_CONFIG.rules[i];
    var match = rule.match;

    if (match.eventType instanceof RegExp) {
      if (!match.eventType.test(eventType)) continue;
    } else if (match.eventType && match.eventType !== eventType) {
      continue;
    }

    if (match.result && build.result !== match.result) continue;

    if (match.definitionName && build.definition) {
      if (match.definitionName instanceof RegExp) {
        if (!match.definitionName.test(build.definition.name)) continue;
      } else if (build.definition.name !== match.definitionName) {
        continue;
      }
    }

    return rule.channel;
  }

  return ROUTING_CONFIG.defaultChannel;
}

The rules are evaluated top to bottom, and the first match wins. This gives you specificity — put your most specific rules first and your catch-all rules last.

Slack Block Kit for Rich Messages

Slack Block Kit is the modern way to format messages. It replaces the older attachment-based approach with a structured block system that gives you headers, sections with fields, images, buttons, dividers, and context blocks. The key block types you will use for DevOps notifications are:

  • header — large bold text for the event title
  • section — text content with optional fields arranged in a two-column grid
  • actions — buttons and interactive elements
  • context — small gray text for metadata like timestamps
  • divider — horizontal rule to separate sections

Always structure your messages with a header first for scanability, then section fields for key details, and actions at the bottom for links to Azure DevOps.

Creating a Notification Middleware with Node.js

The middleware service sits between Azure DevOps and Slack. It receives webhook payloads, validates them, transforms them into formatted Slack messages, routes them to the correct channel, and handles errors gracefully.

var express = require("express");
var https = require("https");
var url = require("url");
var crypto = require("crypto");

var app = express();
app.use(express.json());

// Validate Azure DevOps webhook signature
function validateWebhook(req, secret) {
  if (!secret) return true; // Skip validation if no secret configured

  var signature = req.headers["x-vss-signature"];
  if (!signature) return false;

  var hmac = crypto.createHmac("sha1", secret);
  hmac.update(JSON.stringify(req.body));
  var expected = "sha1=" + hmac.digest("hex");

  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(expected)
  );
}

// Send message to Slack
function sendToSlack(webhookUrl, message, callback) {
  var parsed = url.parse(webhookUrl);
  var payload = JSON.stringify(message);

  var options = {
    hostname: parsed.hostname,
    path: parsed.path,
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "Content-Length": Buffer.byteLength(payload)
    }
  };

  var req = https.request(options, function(res) {
    var body = "";
    res.on("data", function(chunk) { body += chunk; });
    res.on("end", function() {
      if (res.statusCode === 200) {
        callback(null, body);
      } else {
        callback(new Error("Slack returned " + res.statusCode + ": " + body));
      }
    });
  });

  req.on("error", callback);
  req.write(payload);
  req.end();
}

Filtering and Routing Notifications

Beyond channel routing, you also want content-level filtering. Some events are just noise. Successful builds on feature branches, work item description edits, draft PR updates — these clutter channels and train people to ignore notifications entirely.

var FILTER_CONFIG = {
  ignoreBranches: [/^refs\/heads\/dependabot/, /^refs\/heads\/renovate/],
  ignoreEvents: ["workitem.commented"],
  ignoreSuccessfulBuildsOnBranches: [/^refs\/heads\/feature\//],
  onlyNotifyFailuresFor: ["Nightly-Full-Suite"]
};

function shouldSendNotification(payload) {
  var eventType = payload.eventType;

  // Skip explicitly ignored event types
  if (FILTER_CONFIG.ignoreEvents.indexOf(eventType) !== -1) {
    return false;
  }

  // Skip builds on ignored branches
  if (eventType === "build.complete") {
    var branch = payload.resource.sourceBranch;

    for (var i = 0; i < FILTER_CONFIG.ignoreBranches.length; i++) {
      if (FILTER_CONFIG.ignoreBranches[i].test(branch)) return false;
    }

    // Skip successful builds on feature branches
    if (payload.resource.result === "succeeded") {
      for (var j = 0; j < FILTER_CONFIG.ignoreSuccessfulBuildsOnBranches.length; j++) {
        if (FILTER_CONFIG.ignoreSuccessfulBuildsOnBranches[j].test(branch)) return false;
      }
    }

    // Some pipelines only notify on failure
    var defName = payload.resource.definition.name;
    if (FILTER_CONFIG.onlyNotifyFailuresFor.indexOf(defName) !== -1) {
      if (payload.resource.result === "succeeded") return false;
    }
  }

  return true;
}

This kind of filtering is crucial for keeping your Slack channels useful. The moment people start muting a channel because it is too noisy, the whole notification system loses its value.

Thread-Based Conversation Tracking

When a build fails and then a subsequent build on the same branch succeeds, those messages should be threaded together. Slack threads reduce channel clutter and create a natural timeline for an incident. To thread messages, you need to track the ts (timestamp) of the original message and use it as the thread_ts parameter in subsequent messages.

var threadStore = {};

function getThreadKey(payload) {
  if (payload.eventType === "build.complete") {
    return "build:" + payload.resource.definition.name + ":" +
           payload.resource.sourceBranch;
  }
  if (payload.eventType.indexOf("git.pullrequest") === 0) {
    return "pr:" + payload.resource.repository.name + ":" +
           payload.resource.pullRequestId;
  }
  return null;
}

function sendThreadedMessage(webhookUrl, message, threadKey, callback) {
  if (threadKey && threadStore[threadKey]) {
    message.thread_ts = threadStore[threadKey];
  }

  sendToSlack(webhookUrl, message, function(err, response) {
    if (err) return callback(err);

    // For Bot Token API, response includes ts
    // For incoming webhooks, threading requires the Bot Token API
    if (response && response.ts && threadKey) {
      if (!threadStore[threadKey]) {
        threadStore[threadKey] = response.ts;
      }
    }

    callback(null);
  });
}

Note that incoming webhooks do not return message timestamps. For full threading support, you need to use the Slack Web API with a Bot Token (chat.postMessage) instead of incoming webhooks. The trade-off is more setup for richer functionality.

Interactive Slack Buttons for Approvals

This is where things get really powerful. Azure DevOps pipelines support manual approvals for deployments. Instead of making someone open Azure DevOps to click Approve, you can put approval buttons right in Slack.

When a pipeline reaches an approval gate, Azure DevOps sends a ms.vss-release.deployment-approval-pending event. You render that as a Slack message with Approve and Reject buttons. When someone clicks a button, Slack sends a POST to your interactivity endpoint, and you call the Azure DevOps REST API to submit the approval.

var AZDO_PAT = process.env.AZDO_PAT;
var AZDO_ORG = process.env.AZDO_ORG;

function formatApprovalRequest(payload) {
  var approval = payload.resource.approval;
  var release = payload.resource.release;
  var environment = payload.resource.environment;

  return {
    blocks: [
      {
        type: "header",
        text: { type: "plain_text", text: "Deployment Approval Required" }
      },
      {
        type: "section",
        fields: [
          { type: "mrkdwn", text: "*Release:*\n" + release.name },
          { type: "mrkdwn", text: "*Environment:*\n" + environment.name },
          { type: "mrkdwn", text: "*Requested by:*\n" + approval.approver.displayName }
        ]
      },
      {
        type: "actions",
        block_id: "approval_" + approval.id,
        elements: [
          {
            type: "button",
            text: { type: "plain_text", text: "Approve" },
            style: "primary",
            action_id: "approve_deployment",
            value: JSON.stringify({
              approvalId: approval.id,
              projectId: payload.resourceContainers.project.id
            })
          },
          {
            type: "button",
            text: { type: "plain_text", text: "Reject" },
            style: "danger",
            action_id: "reject_deployment",
            value: JSON.stringify({
              approvalId: approval.id,
              projectId: payload.resourceContainers.project.id
            })
          }
        ]
      }
    ]
  };
}

// Handle Slack interactive button clicks
app.post("/slack/interactions", function(req, res) {
  var payload = JSON.parse(req.body.payload);
  var action = payload.actions[0];
  var data = JSON.parse(action.value);
  var user = payload.user.name;

  var status = action.action_id === "approve_deployment" ? "approved" : "rejected";

  submitApproval(data.projectId, data.approvalId, status, user, function(err) {
    if (err) {
      console.error("Approval submission failed:", err.message);
      res.json({
        replace_original: true,
        text: "Failed to submit " + status + " — check Azure DevOps directly."
      });
      return;
    }

    res.json({
      replace_original: true,
      text: "Deployment " + status + " by @" + user
    });
  });
});

function submitApproval(projectId, approvalId, status, approver, callback) {
  var token = Buffer.from(":" + AZDO_PAT).toString("base64");
  var body = JSON.stringify({
    status: status,
    comments: status + " via Slack by " + approver
  });

  var options = {
    hostname: "dev.azure.com",
    path: "/" + AZDO_ORG + "/" + projectId +
          "/_apis/release/approvals/" + approvalId + "?api-version=7.1",
    method: "PATCH",
    headers: {
      "Content-Type": "application/json",
      "Authorization": "Basic " + token,
      "Content-Length": Buffer.byteLength(body)
    }
  };

  var req = https.request(options, function(res) {
    var data = "";
    res.on("data", function(chunk) { data += chunk; });
    res.on("end", function() {
      if (res.statusCode >= 200 && res.statusCode < 300) {
        callback(null);
      } else {
        callback(new Error("Azure DevOps returned " + res.statusCode + ": " + data));
      }
    });
  });

  req.on("error", callback);
  req.write(body);
  req.end();
}

This is a game-changer for deployment workflows. Approvers can act directly from Slack without context-switching, and the approval action is logged with who approved and when.

Monitoring Notification Health

A notification system that silently fails is worse than no notification system at all. You need health monitoring to know when webhooks are not arriving or Slack is rejecting messages.

var notificationStats = {
  received: 0,
  sent: 0,
  failed: 0,
  lastReceived: null,
  lastSent: null,
  lastError: null,
  errorsByType: {}
};

function recordReceived(eventType) {
  notificationStats.received++;
  notificationStats.lastReceived = new Date().toISOString();
}

function recordSent() {
  notificationStats.sent++;
  notificationStats.lastSent = new Date().toISOString();
}

function recordError(eventType, error) {
  notificationStats.failed++;
  notificationStats.lastError = {
    time: new Date().toISOString(),
    eventType: eventType,
    message: error.message
  };
  notificationStats.errorsByType[eventType] =
    (notificationStats.errorsByType[eventType] || 0) + 1;
}

app.get("/health", function(req, res) {
  var healthy = notificationStats.failed / Math.max(notificationStats.received, 1) < 0.1;
  res.status(healthy ? 200 : 503).json({
    status: healthy ? "healthy" : "degraded",
    stats: notificationStats
  });
});

Set up an external monitor (UptimeRobot, Pingdom, or even a simple cron job) to hit your /health endpoint. If the failure rate exceeds 10%, trigger an alert through a separate channel so you know your notification pipeline itself has a problem.

Complete Working Example

Here is the full service that ties together everything discussed above. This is a production-ready starting point that you can deploy and extend.

// server.js
var express = require("express");
var https = require("https");
var url = require("url");
var crypto = require("crypto");

var app = express();
app.use(express.json());

var PORT = process.env.PORT || 3000;
var WEBHOOK_SECRET = process.env.AZDO_WEBHOOK_SECRET;
var SLACK_BOT_TOKEN = process.env.SLACK_BOT_TOKEN;
var AZDO_PAT = process.env.AZDO_PAT;
var AZDO_ORG = process.env.AZDO_ORG;

// Channel mapping
var CHANNELS = {
  builds: process.env.SLACK_CHANNEL_BUILDS || "#builds",
  prs: process.env.SLACK_CHANNEL_PRS || "#pull-requests",
  workitems: process.env.SLACK_CHANNEL_WORKITEMS || "#work-items",
  approvals: process.env.SLACK_CHANNEL_APPROVALS || "#deployments",
  default: process.env.SLACK_CHANNEL_DEFAULT || "#devops"
};

// Stats tracking
var stats = { received: 0, sent: 0, failed: 0, lastError: null };

// ---- Helpers ----

function calculateDuration(startTime, finishTime) {
  var start = new Date(startTime);
  var end = new Date(finishTime);
  var diffMs = end - start;
  var minutes = Math.floor(diffMs / 60000);
  var seconds = Math.floor((diffMs % 60000) / 1000);
  return minutes + "m " + seconds + "s";
}

function postSlackMessage(channel, blocks, threadTs, callback) {
  var payload = JSON.stringify({
    channel: channel,
    blocks: blocks,
    thread_ts: threadTs || undefined,
    unfurl_links: false
  });

  var options = {
    hostname: "slack.com",
    path: "/api/chat.postMessage",
    method: "POST",
    headers: {
      "Content-Type": "application/json; charset=utf-8",
      "Authorization": "Bearer " + SLACK_BOT_TOKEN,
      "Content-Length": Buffer.byteLength(payload)
    }
  };

  var req = https.request(options, function(res) {
    var body = "";
    res.on("data", function(chunk) { body += chunk; });
    res.on("end", function() {
      try {
        var data = JSON.parse(body);
        if (data.ok) {
          stats.sent++;
          callback(null, data);
        } else {
          stats.failed++;
          callback(new Error("Slack API error: " + data.error));
        }
      } catch (e) {
        stats.failed++;
        callback(e);
      }
    });
  });

  req.on("error", function(err) {
    stats.failed++;
    callback(err);
  });

  req.write(payload);
  req.end();
}

// ---- Formatters ----

function formatBuildComplete(payload) {
  var build = payload.resource;
  var result = build.result;
  var branch = build.sourceBranch.replace("refs/heads/", "");
  var duration = calculateDuration(build.startTime, build.finishTime);
  var buildUrl = build._links.web.href;

  var statusEmoji = {
    succeeded: ":white_check_mark:",
    partiallySucceeded: ":warning:",
    failed: ":red_circle:",
    canceled: ":no_entry_sign:"
  };

  var emoji = statusEmoji[result] || ":question:";

  return {
    channel: CHANNELS.builds,
    blocks: [
      {
        type: "header",
        text: {
          type: "plain_text",
          text: emoji + " Build " + result.charAt(0).toUpperCase() + result.slice(1),
          emoji: true
        }
      },
      {
        type: "section",
        fields: [
          { type: "mrkdwn", text: "*Pipeline:*\n" + build.definition.name },
          { type: "mrkdwn", text: "*Build:*\n<" + buildUrl + "|#" + build.buildNumber + ">" },
          { type: "mrkdwn", text: "*Branch:*\n`" + branch + "`" },
          { type: "mrkdwn", text: "*Duration:*\n" + duration },
          { type: "mrkdwn", text: "*Triggered by:*\n" + build.requestedFor.displayName },
          { type: "mrkdwn", text: "*Result:*\n" + result }
        ]
      },
      {
        type: "actions",
        elements: [
          {
            type: "button",
            text: { type: "plain_text", text: "View Build" },
            url: buildUrl,
            style: result === "failed" ? "danger" : undefined
          }
        ]
      }
    ]
  };
}

function formatPullRequest(payload) {
  var pr = payload.resource;
  var action = payload.eventType.replace("git.pullrequest.", "");
  var reviewers = (pr.reviewers || []).map(function(r) {
    return r.displayName;
  }).join(", ");

  var prUrl = pr._links && pr._links.web ? pr._links.web.href : pr.url;

  return {
    channel: CHANNELS.prs,
    blocks: [
      {
        type: "header",
        text: { type: "plain_text", text: "Pull Request " + action }
      },
      {
        type: "section",
        text: {
          type: "mrkdwn",
          text: "*<" + prUrl + "|" + pr.title + ">*\n" +
                "Author: " + pr.createdBy.displayName + "\n" +
                "Repo: " + pr.repository.name + "\n" +
                "`" + pr.sourceRefName.replace("refs/heads/", "") + "` → " +
                "`" + pr.targetRefName.replace("refs/heads/", "") + "`"
        }
      },
      {
        type: "context",
        elements: [
          { type: "mrkdwn", text: "Reviewers: " + (reviewerNames || "None assigned") }
        ]
      },
      {
        type: "actions",
        elements: [
          {
            type: "button",
            text: { type: "plain_text", text: "Review PR" },
            url: prUrl,
            style: "primary"
          }
        ]
      }
    ]
  };
}

function formatWorkItem(payload) {
  var wi = payload.resource.revision || payload.resource;
  var fields = wi.fields;
  var wiUrl = wi._links && wi._links.html ? wi._links.html.href : wi.url;

  return {
    channel: CHANNELS.workitems,
    blocks: [
      {
        type: "header",
        text: {
          type: "plain_text",
          text: "Work Item " + payload.eventType.replace("workitem.", "")
        }
      },
      {
        type: "section",
        fields: [
          { type: "mrkdwn", text: "*Title:*\n<" + wiUrl + "|" + fields["System.Title"] + ">" },
          { type: "mrkdwn", text: "*Type:*\n" + fields["System.WorkItemType"] },
          { type: "mrkdwn", text: "*State:*\n" + fields["System.State"] },
          { type: "mrkdwn", text: "*Assigned To:*\n" + (fields["System.AssignedTo"] || "Unassigned") }
        ]
      }
    ]
  };
}

function formatApproval(payload) {
  var approval = payload.resource.approval;
  var release = payload.resource.release;
  var environment = payload.resource.environment;

  return {
    channel: CHANNELS.approvals,
    blocks: [
      {
        type: "header",
        text: { type: "plain_text", text: ":rocket: Deployment Approval Required", emoji: true }
      },
      {
        type: "section",
        fields: [
          { type: "mrkdwn", text: "*Release:*\n" + release.name },
          { type: "mrkdwn", text: "*Environment:*\n" + environment.name },
          { type: "mrkdwn", text: "*Approver:*\n" + approval.approver.displayName }
        ]
      },
      {
        type: "actions",
        block_id: "approval_" + approval.id,
        elements: [
          {
            type: "button",
            text: { type: "plain_text", text: "Approve" },
            style: "primary",
            action_id: "approve_deployment",
            value: JSON.stringify({
              approvalId: approval.id,
              projectId: payload.resourceContainers.project.id
            })
          },
          {
            type: "button",
            text: { type: "plain_text", text: "Reject" },
            style: "danger",
            action_id: "reject_deployment",
            value: JSON.stringify({
              approvalId: approval.id,
              projectId: payload.resourceContainers.project.id
            })
          }
        ]
      }
    ]
  };
}

// ---- Event Router ----

var EVENT_HANDLERS = {
  "build.complete": formatBuildComplete,
  "git.pullrequest.created": formatPullRequest,
  "git.pullrequest.updated": formatPullRequest,
  "git.pullrequest.merged": formatPullRequest,
  "workitem.created": formatWorkItem,
  "workitem.updated": formatWorkItem,
  "ms.vss-release.deployment-approval-pending": formatApproval
};

// ---- Routes ----

app.post("/webhooks/azuredevops", function(req, res) {
  stats.received++;

  // Validate webhook signature
  if (WEBHOOK_SECRET && !validateWebhook(req, WEBHOOK_SECRET)) {
    console.error("Invalid webhook signature");
    return res.status(401).json({ error: "Invalid signature" });
  }

  var payload = req.body;
  var eventType = payload.eventType;

  console.log("Received event:", eventType);

  // Check if we have a handler
  var handler = EVENT_HANDLERS[eventType];
  if (!handler) {
    console.log("No handler for event type:", eventType);
    return res.status(200).json({ status: "ignored", eventType: eventType });
  }

  // Format the message
  var message;
  try {
    message = handler(payload);
  } catch (err) {
    console.error("Error formatting message:", err.message);
    stats.failed++;
    return res.status(500).json({ error: "Failed to format message" });
  }

  // Send to Slack
  postSlackMessage(message.channel, message.blocks, null, function(err, data) {
    if (err) {
      console.error("Failed to send to Slack:", err.message);
      stats.lastError = { time: new Date().toISOString(), error: err.message };
    } else {
      console.log("Sent notification for", eventType, "to", message.channel);
    }
  });

  // Respond immediately — do not wait for Slack
  res.status(200).json({ status: "accepted", eventType: eventType });
});

// Slack interactivity endpoint
app.post("/slack/interactions", function(req, res) {
  var payload = JSON.parse(req.body.payload);
  var action = payload.actions[0];
  var data = JSON.parse(action.value);
  var user = payload.user.name;
  var status = action.action_id === "approve_deployment" ? "approved" : "rejected";

  submitApproval(data.projectId, data.approvalId, status, user, function(err) {
    if (err) {
      console.error("Approval failed:", err.message);
      return res.json({
        replace_original: true,
        text: ":x: Failed to submit " + status + ". Check Azure DevOps directly."
      });
    }

    res.json({
      replace_original: true,
      text: ":white_check_mark: Deployment " + status + " by @" + user
    });
  });
});

function submitApproval(projectId, approvalId, status, approver, callback) {
  var token = Buffer.from(":" + AZDO_PAT).toString("base64");
  var body = JSON.stringify({
    status: status,
    comments: status + " via Slack by " + approver
  });

  var options = {
    hostname: "dev.azure.com",
    path: "/" + AZDO_ORG + "/" + projectId +
          "/_apis/release/approvals/" + approvalId + "?api-version=7.1",
    method: "PATCH",
    headers: {
      "Content-Type": "application/json",
      "Authorization": "Basic " + token,
      "Content-Length": Buffer.byteLength(body)
    }
  };

  var req = https.request(options, function(res) {
    var data = "";
    res.on("data", function(chunk) { data += chunk; });
    res.on("end", function() {
      if (res.statusCode >= 200 && res.statusCode < 300) {
        callback(null);
      } else {
        callback(new Error("Azure DevOps API returned " + res.statusCode));
      }
    });
  });

  req.on("error", callback);
  req.write(body);
  req.end();
}

function validateWebhook(req, secret) {
  var signature = req.headers["x-vss-signature"];
  if (!signature) return false;

  var hmac = crypto.createHmac("sha1", secret);
  hmac.update(JSON.stringify(req.body));
  var expected = "sha1=" + hmac.digest("hex");

  try {
    return crypto.timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expected)
    );
  } catch (e) {
    return false;
  }
}

// Health check
app.get("/health", function(req, res) {
  var failureRate = stats.received > 0 ? stats.failed / stats.received : 0;
  res.status(failureRate < 0.1 ? 200 : 503).json({
    status: failureRate < 0.1 ? "healthy" : "degraded",
    stats: stats
  });
});

app.listen(PORT, function() {
  console.log("Azure DevOps Slack notification service running on port " + PORT);
});

To deploy this, install the dependencies and set your environment variables:

npm init -y
npm install express
export SLACK_BOT_TOKEN="xoxb-your-bot-token"
export AZDO_PAT="your-personal-access-token"
export AZDO_ORG="your-organization"
export AZDO_WEBHOOK_SECRET="a-strong-random-secret"
export SLACK_CHANNEL_BUILDS="C01234BUILDS"
export SLACK_CHANNEL_PRS="C01234PRS"
export SLACK_CHANNEL_WORKITEMS="C01234WI"
export SLACK_CHANNEL_APPROVALS="C01234DEPLOY"
node server.js

Then in Azure DevOps, create a service hook subscription pointing to https://your-server.com/webhooks/azuredevops for each event type you want to monitor.

Common Issues and Troubleshooting

Webhook payloads are not arriving. Azure DevOps service hooks require your endpoint to be publicly accessible over HTTPS. If you are testing locally, use a tunneling tool like ngrok. Also check the service hook subscription history in Azure DevOps — it shows delivery attempts and response codes. Failed deliveries are retried with exponential backoff, but Azure DevOps will disable the subscription after repeated failures.

Slack messages are not posting. Verify your bot token has the chat:write scope and that the bot has been invited to the target channel. A common mistake is using a channel name (#builds) instead of a channel ID (C01234BUILDS) — the Slack API requires channel IDs for chat.postMessage. Use the conversations.list API to look up IDs.

Rate limiting from Slack. Slack allows roughly 1 message per second per channel for incoming webhooks and has tiered rate limits for the Web API. If you are sending high volumes of notifications (large monorepo with many pipelines), implement a queue with throttling. A simple in-memory queue with setInterval draining at 1 message per second is usually sufficient.

Interactive buttons return dispatch_failed. This means Slack could not reach your interactivity endpoint. Make sure the Request URL in your Slack app's Interactivity settings points to your /slack/interactions endpoint and that the URL is accessible. Also verify you are parsing the payload correctly — Slack sends interactivity payloads as application/x-www-form-urlencoded with a single payload field containing JSON, not as raw JSON.

Azure DevOps approval API returns 403. The Personal Access Token needs the Release (Read, write, & execute) scope to submit approvals. Also, the person associated with the PAT must have permission to approve deployments for that environment. Consider using a service account PAT rather than a personal one.

Duplicate notifications for the same event. Azure DevOps may retry webhook deliveries if your endpoint does not respond with a 2xx status within a few seconds. Always respond immediately (before sending to Slack) and implement idempotency by tracking event IDs. The payload.id field is unique per event delivery.

Best Practices

  • Respond to webhooks immediately. Return a 200 response before doing any processing. Azure DevOps has a short timeout and will retry on failure, leading to duplicate messages. Process the notification asynchronously after responding.

  • Use channel IDs, not channel names. Channel names can change. Channel IDs are permanent. Look them up once and store them in your configuration.

  • Filter aggressively. Start with fewer notifications and add more based on team feedback. It is much easier to add a notification than to undo the damage of a noisy channel that everyone has muted.

  • Include direct links in every notification. Every Slack message about a build, PR, or work item should include a clickable link directly to that resource in Azure DevOps. Never make someone search for context.

  • Rotate secrets regularly. Both your Azure DevOps webhook secret and your Slack Bot Token should be rotated on a schedule. Store them in a secrets manager, not in environment files committed to source control.

  • Log everything. Log every received webhook, every Slack API response, and every error. When notifications stop working at 2 AM, these logs are how you will figure out what happened.

  • Test with real payloads. Azure DevOps lets you send test notifications from the service hook configuration page. Use these to verify your formatting before relying on the system in production.

  • Set up a dead letter channel. Route unhandled event types to a catch-all channel during development so you can see what payloads look like and decide whether to add handlers for them.

  • Use Block Kit Builder. Slack provides an interactive Block Kit Builder at app.slack.com/block-kit-builder where you can prototype your message layouts visually before writing code.

References

Powered by Contentful