Microsoft Teams Webhooks and Azure DevOps
Send Adaptive Card notifications to Microsoft Teams from Azure DevOps with pipeline alerts, PR reviews, and deployment approvals
Microsoft Teams Webhooks and Azure DevOps
Microsoft Teams is where most engineering teams already spend their day. Instead of forcing developers to context-switch into Azure DevOps to check pipeline status or review pull requests, you can push those events directly into Teams channels using webhooks and Adaptive Cards. This article walks through building a production-grade Node.js notification service that connects Azure DevOps events to Teams with rich, actionable messages.
Prerequisites
- Node.js 14 or later installed
- An Azure DevOps organization with at least one project
- Microsoft Teams with permissions to create incoming webhooks
- A personal access token (PAT) for Azure DevOps with read permissions on Code, Build, Release, and Work Items
- Basic familiarity with Express.js and REST APIs
Setting Up Teams Incoming Webhooks
An incoming webhook gives you a unique URL that accepts JSON payloads and posts them as messages to a specific Teams channel. This is the foundation for everything else in this article.
To create one:
- Open Microsoft Teams and navigate to the channel where you want notifications.
- Click the three-dot menu on the channel name and select Manage channel.
- Under the Connectors section (or Workflows in newer Teams versions), add an Incoming Webhook.
- Give it a descriptive name like "Azure DevOps Pipeline Alerts" and optionally upload an icon.
- Copy the webhook URL. It looks something like
https://outlook.office.com/webhook/abc123/IncomingWebhook/def456/ghi789.
Store that URL as an environment variable. Never hardcode it.
export TEAMS_WEBHOOK_URL="https://outlook.office.com/webhook/abc123/IncomingWebhook/def456/ghi789"
A quick test to confirm it works:
var https = require("https");
var url = require("url");
var webhookUrl = process.env.TEAMS_WEBHOOK_URL;
var parsed = url.parse(webhookUrl);
var payload = JSON.stringify({
"@type": "MessageCard",
"@context": "http://schema.org/extensions",
summary: "Test Notification",
text: "Webhook is working."
});
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) {
console.log("Status:", res.statusCode);
});
req.write(payload);
req.end();
If you see a 200 status and a message appears in your Teams channel, you are set.
Adaptive Cards for Rich Notifications
Plain text messages are fine for quick alerts, but Adaptive Cards let you build structured, visually rich notifications with headers, facts, images, and action buttons. Teams supports Adaptive Card schema version 1.4 through incoming webhooks.
Here is the structure of an Adaptive Card payload for Teams:
var card = {
type: "message",
attachments: [
{
contentType: "application/vnd.microsoft.card.adaptive",
content: {
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
type: "AdaptiveCard",
version: "1.4",
body: [
{
type: "TextBlock",
text: "Pipeline Build Succeeded",
weight: "Bolder",
size: "Large",
color: "Good"
},
{
type: "FactSet",
facts: [
{ title: "Pipeline", value: "api-service-ci" },
{ title: "Branch", value: "refs/heads/main" },
{ title: "Duration", value: "2m 34s" },
{ title: "Triggered by", value: "Shane Larson" }
]
}
],
actions: [
{
type: "Action.OpenUrl",
title: "View Build",
url: "https://dev.azure.com/myorg/myproject/_build/results?buildId=4521"
}
]
}
}
]
};
The key difference from a simple MessageCard is the attachments array with contentType set to application/vnd.microsoft.card.adaptive. This tells Teams to render the payload as an Adaptive Card rather than a legacy connector card.
Pipeline Notifications to Teams
Azure DevOps pipelines emit service hook events for build completion, stage changes, and run state transitions. You can either configure service hooks in Azure DevOps to POST directly to your Teams webhook, or route them through a middleware service that formats the messages.
I strongly recommend the middleware approach. Azure DevOps service hooks send raw event payloads that look terrible when posted directly to Teams. A small Node.js service in between gives you full control over formatting, filtering, and routing.
Here is how to configure a service hook in Azure DevOps:
- Go to Project Settings > Service hooks.
- Click Create subscription.
- Select Web Hooks as the service.
- Choose the event type: Build completed, Run stage state changed, or Run state changed.
- Set filters (e.g., only for specific pipelines or specific statuses like
failed). - Set the URL to your middleware service endpoint.
Your middleware receives the event and transforms it:
function formatPipelineEvent(event) {
var resource = event.resource;
var status = resource.status || resource.result;
var color = status === "succeeded" ? "Good" : status === "failed" ? "Attention" : "Default";
var emoji = status === "succeeded" ? "β
" : status === "failed" ? "β" : "β³";
return {
type: "message",
attachments: [
{
contentType: "application/vnd.microsoft.card.adaptive",
content: {
type: "AdaptiveCard",
version: "1.4",
body: [
{
type: "TextBlock",
text: emoji + " Pipeline: " + resource.definition.name,
weight: "Bolder",
size: "Medium"
},
{
type: "FactSet",
facts: [
{ title: "Status", value: status },
{ title: "Branch", value: resource.sourceBranch },
{ title: "Build Number", value: resource.buildNumber },
{ title: "Requested by", value: resource.requestedFor.displayName },
{ title: "Finished", value: new Date(resource.finishTime).toLocaleString() }
]
}
],
actions: [
{
type: "Action.OpenUrl",
title: "View Build",
url: resource._links.web.href
}
]
}
}
]
};
}
PR and Code Review Alerts
Pull request events are arguably the most valuable notifications to push to Teams. When a PR is created, updated, or has a reviewer vote, the team should know immediately.
Azure DevOps emits several PR-related events: git.pullrequest.created, git.pullrequest.updated, git.pullrequest.merged, and ms.vss-code.git-pullrequest-comment-event.
function formatPullRequestEvent(event) {
var pr = event.resource;
var action = event.eventType.split(".").pop();
var title = "Pull Request " + action.charAt(0).toUpperCase() + action.slice(1);
var reviewers = pr.reviewers.map(function (r) {
var vote = r.vote;
var status = vote === 10 ? "Approved" : vote === 5 ? "Approved with suggestions" : vote === -5 ? "Waiting" : vote === -10 ? "Rejected" : "No vote";
return r.displayName + " (" + status + ")";
});
return {
type: "message",
attachments: [
{
contentType: "application/vnd.microsoft.card.adaptive",
content: {
type: "AdaptiveCard",
version: "1.4",
body: [
{
type: "TextBlock",
text: "π " + title,
weight: "Bolder",
size: "Medium"
},
{
type: "TextBlock",
text: pr.title,
wrap: true
},
{
type: "FactSet",
facts: [
{ title: "Author", value: pr.createdBy.displayName },
{ title: "Source", value: pr.sourceRefName.replace("refs/heads/", "") },
{ title: "Target", value: pr.targetRefName.replace("refs/heads/", "") },
{ title: "Reviewers", value: reviewers.join(", ") || "None assigned" }
]
}
],
actions: [
{
type: "Action.OpenUrl",
title: "Review PR",
url: pr.url.replace("_apis/git/repositories", "_git").replace("/pullRequests/", "/pullrequest/")
}
]
}
}
]
};
}
Deployment Status Cards
Deployments deserve their own card format. When a release pipeline deploys to staging or production, the card should prominently show the environment, the version, and whether it succeeded. Color-coding is critical here because production failures need to jump off the screen.
function formatDeploymentEvent(event) {
var resource = event.resource;
var environment = resource.environment;
var status = environment.status;
var isProduction = environment.name.toLowerCase().indexOf("prod") !== -1;
var color = status === "succeeded" ? "Good" : status === "failed" ? "Attention" : "Warning";
var urgency = isProduction && status === "failed" ? "π¨ PRODUCTION " : "";
return {
type: "message",
attachments: [
{
contentType: "application/vnd.microsoft.card.adaptive",
content: {
type: "AdaptiveCard",
version: "1.4",
body: [
{
type: "Container",
style: status === "failed" ? "attention" : "good",
items: [
{
type: "TextBlock",
text: urgency + "Deployment: " + environment.name,
weight: "Bolder",
size: "Large",
color: color
}
]
},
{
type: "FactSet",
facts: [
{ title: "Release", value: resource.release.name },
{ title: "Environment", value: environment.name },
{ title: "Status", value: status },
{ title: "Deployed by", value: environment.deploySteps[0].requestedFor.displayName },
{ title: "Completed", value: new Date(environment.deploySteps[0].lastModifiedOn).toLocaleString() }
]
}
],
actions: [
{
type: "Action.OpenUrl",
title: "View Release",
url: resource.release._links.web.href
}
]
}
}
]
};
}
Work Item Updates in Teams
Tracking work item changes (bugs assigned, stories moved to active, tasks completed) keeps the team aligned without requiring everyone to monitor boards. Filter aggressively here β you do not want every field change on every work item flooding your channel.
function formatWorkItemEvent(event) {
var resource = event.resource;
var fields = resource.fields;
var workItemType = fields["System.WorkItemType"];
var title = fields["System.Title"];
var state = fields["System.State"];
var assignedTo = fields["System.AssignedTo"] || "Unassigned";
var changedFields = event.resource.revision
? Object.keys(event.resource.revision.fields || {})
: [];
return {
type: "message",
attachments: [
{
contentType: "application/vnd.microsoft.card.adaptive",
content: {
type: "AdaptiveCard",
version: "1.4",
body: [
{
type: "TextBlock",
text: "π " + workItemType + " Updated",
weight: "Bolder",
size: "Medium"
},
{
type: "TextBlock",
text: title,
wrap: true
},
{
type: "FactSet",
facts: [
{ title: "State", value: state },
{ title: "Assigned To", value: assignedTo },
{ title: "Changed Fields", value: changedFields.join(", ") || "N/A" }
]
}
],
actions: [
{
type: "Action.OpenUrl",
title: "View Work Item",
url: resource._links.html.href
}
]
}
}
]
};
}
Azure DevOps Connector for Teams
Microsoft provides a first-party Azure DevOps connector for Teams. It is worth mentioning because some teams start here before outgrowing it. The connector lives under Apps > Azure DevOps in Teams and supports basic event subscriptions for builds, releases, PRs, and work items.
The limitations are real though. You cannot customize the card format, you cannot filter by pipeline name (only by project), and the cards are the old MessageCard format, not Adaptive Cards. For anything beyond basic "something happened" notifications, you need the webhook middleware approach described in this article.
That said, the connector is useful for quick setup on small projects where you just want pipeline pass/fail in a channel and do not need rich formatting or routing logic.
Custom Bot for Azure DevOps Interactions
If you want bidirectional communication β not just notifications but the ability to query Azure DevOps from Teams β you need a bot. A bot can respond to messages, slash commands, or card button clicks.
Building a full Teams bot is outside the scope of this article, but here is the pattern for a lightweight command handler that queries Azure DevOps:
var express = require("express");
var axios = require("axios");
var PAT = process.env.AZURE_DEVOPS_PAT;
var ORG = process.env.AZURE_DEVOPS_ORG;
var PROJECT = process.env.AZURE_DEVOPS_PROJECT;
function getAuthHeader() {
var token = Buffer.from(":" + PAT).toString("base64");
return "Basic " + token;
}
function queryBuilds(pipelineName, callback) {
var url = "https://dev.azure.com/" + ORG + "/" + PROJECT + "/_apis/build/builds?api-version=7.0&$top=5";
if (pipelineName) {
url += "&definitions=" + pipelineName;
}
axios.get(url, {
headers: { Authorization: getAuthHeader() }
}).then(function (response) {
callback(null, response.data.value);
}).catch(function (err) {
callback(err);
});
}
function queryPullRequests(status, callback) {
var url = "https://dev.azure.com/" + ORG + "/" + PROJECT + "/_apis/git/pullrequests?api-version=7.0&searchCriteria.status=" + (status || "active");
axios.get(url, {
headers: { Authorization: getAuthHeader() }
}).then(function (response) {
callback(null, response.data.value);
}).catch(function (err) {
callback(err);
});
}
This gives you the building blocks to respond with formatted cards when someone types something like "show me active PRs" in a Teams channel.
Channel-Based Routing Strategies
Not every notification belongs in every channel. A mature notification setup routes events to the right audience. Here is a routing pattern I have used successfully on multiple projects:
| Channel | Events |
|---|---|
#deployments |
Release stage completions, deployment approvals |
#builds |
Pipeline failures only (not successes) |
#pull-requests |
PR created, reviewer votes, PR completed |
#incidents |
Production deployment failures, build breaks on main |
#team-updates |
Work item state changes for current sprint |
Implement this with a routing configuration:
var CHANNEL_ROUTES = {
"build.complete": {
succeeded: process.env.TEAMS_BUILDS_WEBHOOK,
failed: [process.env.TEAMS_BUILDS_WEBHOOK, process.env.TEAMS_INCIDENTS_WEBHOOK]
},
"git.pullrequest.created": process.env.TEAMS_PR_WEBHOOK,
"git.pullrequest.updated": process.env.TEAMS_PR_WEBHOOK,
"ms.vss-release.deployment-completed-event": {
succeeded: process.env.TEAMS_DEPLOYMENTS_WEBHOOK,
failed: [process.env.TEAMS_DEPLOYMENTS_WEBHOOK, process.env.TEAMS_INCIDENTS_WEBHOOK]
},
"workitem.updated": process.env.TEAMS_UPDATES_WEBHOOK
};
function getWebhookUrls(eventType, status) {
var route = CHANNEL_ROUTES[eventType];
if (!route) return [];
if (typeof route === "string") return [route];
if (Array.isArray(route)) return route;
var statusRoute = route[status] || route["default"];
if (!statusRoute) return [];
return Array.isArray(statusRoute) ? statusRoute : [statusRoute];
}
This approach scales well. When a production deployment fails, it posts to both #deployments and #incidents. Successful builds only go to #builds. You can add new channels and rules without changing the core notification logic.
Actionable Messages with Buttons
Adaptive Cards support Action.OpenUrl for linking out, but you can also use Action.Submit to post data back to your service. This enables in-channel actions like approving a deployment or re-running a failed build.
function createApprovalCard(release, environment) {
return {
type: "message",
attachments: [
{
contentType: "application/vnd.microsoft.card.adaptive",
content: {
type: "AdaptiveCard",
version: "1.4",
body: [
{
type: "TextBlock",
text: "π Deployment Approval Required",
weight: "Bolder",
size: "Large"
},
{
type: "FactSet",
facts: [
{ title: "Release", value: release.name },
{ title: "Environment", value: environment.name },
{ title: "Requested by", value: release.createdBy.displayName },
{ title: "Created", value: new Date(release.createdOn).toLocaleString() }
]
},
{
type: "TextBlock",
text: "Please review and approve or reject this deployment.",
wrap: true
}
],
actions: [
{
type: "Action.OpenUrl",
title: "Approve in Azure DevOps",
url: release._links.web.href
},
{
type: "Action.OpenUrl",
title: "View Changes",
url: release._links.web.href + "?environmentId=" + environment.id
}
]
}
}
]
};
}
For full Action.Submit support (where button clicks POST back to your service), you need to register your service as an actionable message provider with Microsoft. The setup involves registering in the Actionable Email Developer Dashboard and configuring your service URL. This is more complex but enables true in-channel approvals without leaving Teams.
Teams Approval Workflows
Azure DevOps release pipelines support pre-deployment approvals natively. The challenge is that approval notifications only go to email by default. By wiring up a release deployment approval pending event to your notification service, you can push approval requests directly into Teams.
function handleApprovalPending(event) {
var approval = event.resource.approval;
var release = event.resource.release;
var environment = event.resource.environment;
var card = {
type: "message",
attachments: [
{
contentType: "application/vnd.microsoft.card.adaptive",
content: {
type: "AdaptiveCard",
version: "1.4",
body: [
{
type: "TextBlock",
text: "β³ Approval Pending",
weight: "Bolder",
size: "Large",
color: "Warning"
},
{
type: "TextBlock",
text: release.name + " β " + environment.name,
size: "Medium",
weight: "Bolder"
},
{
type: "FactSet",
facts: [
{ title: "Approver", value: approval.approver.displayName },
{ title: "Release", value: release.name },
{ title: "Environment", value: environment.name },
{ title: "Status", value: "Waiting for approval" }
]
}
],
actions: [
{
type: "Action.OpenUrl",
title: "Review & Approve",
url: release._links.web.href
}
]
}
}
]
};
return card;
}
Building a Notification Service with Node.js
Here is where it all comes together. The notification service is an Express app that receives Azure DevOps service hook events, formats them into Adaptive Cards, routes them to the correct Teams channels, and handles retries.
var express = require("express");
var axios = require("axios");
var app = express();
app.use(express.json());
var WEBHOOK_URLS = {
builds: process.env.TEAMS_BUILDS_WEBHOOK,
pullRequests: process.env.TEAMS_PR_WEBHOOK,
deployments: process.env.TEAMS_DEPLOYMENTS_WEBHOOK,
incidents: process.env.TEAMS_INCIDENTS_WEBHOOK,
workItems: process.env.TEAMS_UPDATES_WEBHOOK
};
// Retry logic with exponential backoff
function sendToTeams(webhookUrl, card, retries) {
if (retries === undefined) retries = 3;
return axios.post(webhookUrl, card, {
headers: { "Content-Type": "application/json" },
timeout: 10000
}).catch(function (err) {
if (retries > 0 && err.response && err.response.status === 429) {
var delay = Math.pow(2, 3 - retries) * 1000;
console.log("Rate limited. Retrying in " + delay + "ms...");
return new Promise(function (resolve) {
setTimeout(function () {
resolve(sendToTeams(webhookUrl, card, retries - 1));
}, delay);
});
}
throw err;
});
}
// Send to multiple channels
function broadcast(channels, card) {
var promises = channels.map(function (channel) {
var url = WEBHOOK_URLS[channel];
if (!url) {
console.warn("No webhook URL configured for channel: " + channel);
return Promise.resolve();
}
return sendToTeams(url, card);
});
return Promise.all(promises);
}
// Event handler
app.post("/webhook/azuredevops", function (req, res) {
var event = req.body;
var eventType = event.eventType;
console.log("Received event: " + eventType);
var card;
var channels;
try {
switch (eventType) {
case "build.complete":
card = formatPipelineEvent(event);
channels = event.resource.result === "failed"
? ["builds", "incidents"]
: ["builds"];
break;
case "git.pullrequest.created":
case "git.pullrequest.updated":
case "git.pullrequest.merged":
card = formatPullRequestEvent(event);
channels = ["pullRequests"];
break;
case "ms.vss-release.deployment-completed-event":
card = formatDeploymentEvent(event);
var status = event.resource.environment.status;
channels = status === "failed"
? ["deployments", "incidents"]
: ["deployments"];
break;
case "ms.vss-release.deployment-approval-pending-event":
card = handleApprovalPending(event);
channels = ["deployments"];
break;
case "workitem.updated":
card = formatWorkItemEvent(event);
channels = ["workItems"];
break;
default:
console.log("Unhandled event type: " + eventType);
return res.status(200).json({ status: "ignored", eventType: eventType });
}
broadcast(channels, card).then(function () {
res.status(200).json({ status: "sent", eventType: eventType, channels: channels });
}).catch(function (err) {
console.error("Failed to send to Teams:", err.message);
res.status(500).json({ status: "error", message: err.message });
});
} catch (err) {
console.error("Error processing event:", err.message);
res.status(500).json({ status: "error", message: err.message });
}
});
// Health check
app.get("/health", function (req, res) {
res.json({
status: "ok",
webhooks: {
builds: !!WEBHOOK_URLS.builds,
pullRequests: !!WEBHOOK_URLS.pullRequests,
deployments: !!WEBHOOK_URLS.deployments,
incidents: !!WEBHOOK_URLS.incidents,
workItems: !!WEBHOOK_URLS.workItems
}
});
});
var PORT = process.env.PORT || 3000;
app.listen(PORT, function () {
console.log("Teams notification service running on port " + PORT);
});
Monitoring and Reliability
A notification service that silently fails is worse than no notification service at all. You need to know when messages are not getting through.
Add request logging with timestamps and response codes:
var notificationLog = [];
function logNotification(eventType, channels, status, error) {
var entry = {
timestamp: new Date().toISOString(),
eventType: eventType,
channels: channels,
status: status,
error: error || null
};
notificationLog.push(entry);
// Keep only the last 1000 entries in memory
if (notificationLog.length > 1000) {
notificationLog = notificationLog.slice(-1000);
}
}
app.get("/metrics", function (req, res) {
var total = notificationLog.length;
var failures = notificationLog.filter(function (e) { return e.status === "error"; }).length;
var lastHour = notificationLog.filter(function (e) {
return new Date(e.timestamp) > new Date(Date.now() - 3600000);
});
res.json({
total: total,
failures: failures,
successRate: total > 0 ? ((total - failures) / total * 100).toFixed(2) + "%" : "N/A",
lastHour: {
total: lastHour.length,
failures: lastHour.filter(function (e) { return e.status === "error"; }).length
},
recentErrors: notificationLog.filter(function (e) {
return e.status === "error";
}).slice(-10)
});
});
For production deployments, also consider:
- Dead letter queue: Store failed notifications in a database or queue so they can be retried later.
- Heartbeat monitoring: Send a test card to a dedicated channel every 15 minutes and alert if it stops arriving.
- Webhook URL rotation: Teams webhook URLs can be revoked when connectors are removed. Your service should detect
404responses and alert the team to reconfigure.
Complete Working Example
Here is the full notification service pulled together into a single, runnable file. Save this as teams-notifier.js:
var express = require("express");
var axios = require("axios");
var app = express();
app.use(express.json());
// ---- Configuration ----
var WEBHOOK_URLS = {
builds: process.env.TEAMS_BUILDS_WEBHOOK,
pullRequests: process.env.TEAMS_PR_WEBHOOK,
deployments: process.env.TEAMS_DEPLOYMENTS_WEBHOOK,
incidents: process.env.TEAMS_INCIDENTS_WEBHOOK,
workItems: process.env.TEAMS_UPDATES_WEBHOOK
};
var notificationLog = [];
// ---- Helpers ----
function sendToTeams(webhookUrl, card, retries) {
if (retries === undefined) retries = 3;
return axios.post(webhookUrl, card, {
headers: { "Content-Type": "application/json" },
timeout: 10000
}).then(function (response) {
return response;
}).catch(function (err) {
if (retries > 0 && err.response && err.response.status === 429) {
var delay = Math.pow(2, 3 - retries) * 1000;
console.log("Rate limited by Teams. Retrying in " + delay + "ms...");
return new Promise(function (resolve) {
setTimeout(function () {
resolve(sendToTeams(webhookUrl, card, retries - 1));
}, delay);
});
}
throw err;
});
}
function broadcast(channels, card) {
var promises = channels.map(function (channel) {
var url = WEBHOOK_URLS[channel];
if (!url) {
console.warn("No webhook configured for: " + channel);
return Promise.resolve();
}
return sendToTeams(url, card);
});
return Promise.all(promises);
}
function logNotification(eventType, channels, status, error) {
notificationLog.push({
timestamp: new Date().toISOString(),
eventType: eventType,
channels: channels,
status: status,
error: error || null
});
if (notificationLog.length > 1000) {
notificationLog = notificationLog.slice(-500);
}
}
function makeCard(title, facts, actionLabel, actionUrl, color) {
return {
type: "message",
attachments: [
{
contentType: "application/vnd.microsoft.card.adaptive",
content: {
type: "AdaptiveCard",
version: "1.4",
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
body: [
{
type: "TextBlock",
text: title,
weight: "Bolder",
size: "Large",
color: color || "Default"
},
{
type: "FactSet",
facts: facts
}
],
actions: actionUrl ? [
{ type: "Action.OpenUrl", title: actionLabel, url: actionUrl }
] : []
}
}
]
};
}
// ---- Formatters ----
function formatBuild(event) {
var r = event.resource;
var result = r.result || r.status;
var color = result === "succeeded" ? "Good" : result === "failed" ? "Attention" : "Default";
var icon = result === "succeeded" ? "β
" : result === "failed" ? "β" : "π";
return makeCard(
icon + " Build: " + r.definition.name,
[
{ title: "Result", value: result },
{ title: "Branch", value: r.sourceBranch },
{ title: "Build #", value: r.buildNumber },
{ title: "By", value: r.requestedFor.displayName },
{ title: "Finished", value: new Date(r.finishTime).toLocaleString() }
],
"View Build",
r._links.web.href,
color
);
}
function formatPR(event) {
var pr = event.resource;
var action = event.eventType.split(".").pop();
return makeCard(
"π PR " + action + ": " + pr.title,
[
{ title: "Author", value: pr.createdBy.displayName },
{ title: "Source", value: pr.sourceRefName.replace("refs/heads/", "") },
{ title: "Target", value: pr.targetRefName.replace("refs/heads/", "") },
{ title: "Status", value: pr.status },
{ title: "Reviewers", value: pr.reviewers.map(function (r) { return r.displayName; }).join(", ") || "None" }
],
"Review PR",
pr.url.replace("_apis/git/repositories", "_git").replace("/pullRequests/", "/pullrequest/")
);
}
function formatDeployment(event) {
var env = event.resource.environment;
var release = event.resource.release;
var status = env.status;
var isProd = env.name.toLowerCase().indexOf("prod") !== -1;
var color = status === "succeeded" ? "Good" : "Attention";
var prefix = isProd && status === "failed" ? "π¨ PROD FAILURE: " : "π Deploy: ";
return makeCard(
prefix + env.name,
[
{ title: "Release", value: release.name },
{ title: "Environment", value: env.name },
{ title: "Status", value: status },
{ title: "Deployed by", value: env.deploySteps[0].requestedFor.displayName }
],
"View Release",
release._links.web.href,
color
);
}
function formatApproval(event) {
var approval = event.resource.approval;
var release = event.resource.release;
var env = event.resource.environment;
return makeCard(
"β³ Approval Required: " + env.name,
[
{ title: "Release", value: release.name },
{ title: "Environment", value: env.name },
{ title: "Approver", value: approval.approver.displayName },
{ title: "Status", value: "Waiting" }
],
"Review & Approve",
release._links.web.href,
"Warning"
);
}
function formatWorkItem(event) {
var fields = event.resource.fields;
return makeCard(
"π " + (fields["System.WorkItemType"] || "Work Item") + " Updated",
[
{ title: "Title", value: fields["System.Title"] },
{ title: "State", value: fields["System.State"] },
{ title: "Assigned To", value: fields["System.AssignedTo"] || "Unassigned" }
],
"View Item",
event.resource._links.html.href
);
}
// ---- Routes ----
app.post("/webhook/azuredevops", function (req, res) {
var event = req.body;
var eventType = event.eventType;
var card, channels;
try {
switch (eventType) {
case "build.complete":
card = formatBuild(event);
channels = event.resource.result === "failed" ? ["builds", "incidents"] : ["builds"];
break;
case "git.pullrequest.created":
case "git.pullrequest.updated":
case "git.pullrequest.merged":
card = formatPR(event);
channels = ["pullRequests"];
break;
case "ms.vss-release.deployment-completed-event":
card = formatDeployment(event);
channels = event.resource.environment.status === "failed" ? ["deployments", "incidents"] : ["deployments"];
break;
case "ms.vss-release.deployment-approval-pending-event":
card = formatApproval(event);
channels = ["deployments"];
break;
case "workitem.updated":
card = formatWorkItem(event);
channels = ["workItems"];
break;
default:
return res.status(200).json({ status: "ignored" });
}
broadcast(channels, card).then(function () {
logNotification(eventType, channels, "sent");
res.status(200).json({ status: "sent", channels: channels });
}).catch(function (err) {
logNotification(eventType, channels, "error", err.message);
console.error("Send failed:", err.message);
res.status(500).json({ status: "error", message: err.message });
});
} catch (err) {
logNotification(eventType, [], "error", err.message);
res.status(500).json({ status: "error", message: err.message });
}
});
app.get("/health", function (req, res) {
var configured = Object.keys(WEBHOOK_URLS).filter(function (k) { return !!WEBHOOK_URLS[k]; });
res.json({ status: "ok", configuredChannels: configured });
});
app.get("/metrics", function (req, res) {
var total = notificationLog.length;
var errors = notificationLog.filter(function (e) { return e.status === "error"; }).length;
res.json({
total: total,
errors: errors,
successRate: total ? ((total - errors) / total * 100).toFixed(1) + "%" : "N/A",
recent: notificationLog.slice(-20)
});
});
var PORT = process.env.PORT || 3000;
app.listen(PORT, function () {
console.log("Teams notifier listening on port " + PORT);
});
Run it with:
npm init -y
npm install express axios
TEAMS_BUILDS_WEBHOOK="https://..." TEAMS_PR_WEBHOOK="https://..." node teams-notifier.js
Then point your Azure DevOps service hooks at https://your-server.com/webhook/azuredevops.
Common Issues and Troubleshooting
1. Webhook returns 400 Bad Request
This almost always means your JSON payload is malformed. Teams is picky about Adaptive Card structure. The most common mistake is sending the card content directly instead of wrapping it in the attachments array with the correct contentType. Always wrap your card in { type: "message", attachments: [{ contentType: "application/vnd.microsoft.card.adaptive", content: { ... } }] }.
2. Cards render as plain text or show raw JSON
This happens when you send a payload formatted as a legacy MessageCard (@type: "MessageCard") to a webhook that expects Adaptive Cards, or vice versa. Newer Teams webhooks created through Workflows expect Adaptive Cards. Older connector-based webhooks expect MessageCards. Check which type your webhook is and format accordingly.
3. Rate limiting (HTTP 429)
Teams webhooks enforce rate limits. If you send more than approximately 4 messages per second to a single webhook, you will get throttled. The retry logic with exponential backoff shown above handles this, but the real fix is to batch notifications. If multiple builds finish within seconds of each other, aggregate them into a single card instead of sending one per build.
4. Webhook URL stops working after connector removal
When someone removes or reconfigures the Teams connector, the webhook URL is invalidated and you will get 404 responses. There is no event notification for this β your service just starts failing. This is why the /health endpoint and monitoring are critical. Periodically send a test message and alert the team if it fails. Also, store webhook URLs in a configuration service rather than environment variables so they can be updated without redeploying.
5. Azure DevOps service hooks not firing
If your service is not receiving events, check the service hook subscription in Azure DevOps under Project Settings > Service hooks. Each subscription shows a history of recent deliveries with status codes and response bodies. Common causes are the target URL being unreachable (firewall rules, HTTPS certificate issues) or filters being too restrictive (e.g., filtering to a pipeline that no longer exists).
6. Adaptive Card actions not appearing
Teams has a limit on the number of actions in a single card (typically 6). If you exceed this, actions silently disappear. Also, Action.Submit only works with bot-registered applications, not incoming webhooks. Stick to Action.OpenUrl for webhook-based notifications.
Best Practices
- Filter aggressively at the source. Configure Azure DevOps service hooks with specific pipeline, repository, and status filters. It is far better to receive only the events you need than to receive everything and filter in your middleware.
- Use separate webhooks per channel. Do not try to route all notifications through a single webhook URL. Create one webhook per Teams channel and use the routing configuration pattern to direct events appropriately.
- Always include a direct link. Every notification card should have an
Action.OpenUrlbutton that takes the user directly to the relevant page in Azure DevOps. Notifications without links are frustrating. - Color-code by severity. Use Adaptive Card colors consistently:
Good(green) for success,Attention(red) for failures,Warning(yellow) for pending actions. Your team will learn to scan channels by color. - Rate limit your sends. Implement a queue or throttle in your notification service. Teams will throttle you if you send too fast, and queued retries consume resources. A simple in-memory queue with a 250ms delay between sends prevents most throttling issues.
- Log every notification. Record the event type, destination channel, HTTP status code, and timestamp for every notification sent. When someone asks "did we get a notification for that failed build?" you need to be able to answer definitively.
- Test with real events. Azure DevOps lets you re-send service hook events from the subscription history. Use this to test your formatting without triggering actual builds or deployments.
- Version your card templates. As your card designs evolve, version them so you can roll back if a new format causes rendering issues in older Teams clients.
- Handle graceful degradation. If a webhook URL is not configured, skip that channel silently rather than crashing the service. Not every deployment needs every channel configured.