Microsoft Teams Webhooks and Azure DevOps
A practical guide to integrating Microsoft Teams with Azure DevOps using incoming webhooks, connector cards, Adaptive Cards, pipeline notifications, work item alerts, and Power Automate flows for advanced automation scenarios.
Microsoft Teams Webhooks and Azure DevOps
Overview
Microsoft Teams is where many organizations do their daily communication, and Azure DevOps is where they build software. Connecting the two means pipeline failures, PR reviews, and deployment updates appear in the channels where the team is already working. Azure DevOps has built-in Teams integration through connectors and service hooks, and Teams supports incoming webhooks and Adaptive Cards for rich, interactive messages. The combination lets you build notification flows that go beyond simple alerts -- actionable cards where a reviewer can approve a PR or a manager can approve a deployment directly from the Teams message.
I have set up Teams integrations for organizations that are all-in on the Microsoft ecosystem -- Azure DevOps, Teams, and Office 365. The native integration is tighter than with third-party chat tools because both products share the same identity platform and connector infrastructure. This article covers incoming webhooks for custom notifications, Adaptive Cards for interactive messages, and Power Automate for complex workflows.
Prerequisites
- An Azure DevOps organization with Azure Pipelines
- A Microsoft Teams workspace with permission to add connectors to channels
- Node.js 18+ for custom integration scripts
- Familiarity with Azure DevOps service hooks
- Basic understanding of JSON payloads
- Power Automate license (optional, for advanced workflows)
Incoming Webhooks in Teams
Incoming webhooks are the simplest way to post messages to a Teams channel from external systems. Each webhook provides a URL that accepts JSON payloads and posts them as messages.
Creating an Incoming Webhook
- Open the Teams channel where you want notifications
- Click the three-dot menu > Connectors (or "Manage channel" > Connectors)
- Search for "Incoming Webhook" and click "Configure"
- Name the webhook (e.g., "Azure DevOps Builds") and optionally upload an icon
- Click "Create" and copy the webhook URL
The webhook URL format: https://outlook.office.com/webhook/...
Sending Messages
Teams webhooks accept both simple text and Adaptive Card payloads:
// teams/send-message.js
var https = require("https");
var url = require("url");
function sendTeamsMessage(webhookUrl, payload) {
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("Teams webhook failed: " + res.statusCode + " " + data));
}
});
});
req.on("error", reject);
req.write(JSON.stringify(payload));
req.end();
});
}
// Simple text message
function sendText(webhookUrl, text) {
return sendTeamsMessage(webhookUrl, { text: text });
}
// Adaptive Card message
function sendCard(webhookUrl, card) {
return sendTeamsMessage(webhookUrl, {
type: "message",
attachments: [{
contentType: "application/vnd.microsoft.card.adaptive",
contentUrl: null,
content: card,
}],
});
}
module.exports = {
sendTeamsMessage: sendTeamsMessage,
sendText: sendText,
sendCard: sendCard,
};
Adaptive Cards for Rich Notifications
Adaptive Cards are the rich message format for Teams. They support structured layouts, colors, buttons, and data input -- far more capable than plain text messages.
Build Status Card
// teams/build-card.js
var teams = require("./send-message");
function buildStatusCard(buildData) {
var isSuccess = buildData.result === "succeeded";
var color = isSuccess ? "good" : "attention";
var icon = isSuccess ? "β
" : "β";
var card = {
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
type: "AdaptiveCard",
version: "1.4",
body: [
{
type: "ColumnSet",
columns: [
{
type: "Column",
width: "auto",
items: [{
type: "TextBlock",
text: icon,
size: "large",
}],
},
{
type: "Column",
width: "stretch",
items: [
{
type: "TextBlock",
text: "Build " + buildData.result.toUpperCase(),
weight: "bolder",
size: "medium",
color: color,
},
{
type: "TextBlock",
text: buildData.definitionName,
spacing: "none",
isSubtle: true,
},
],
},
],
},
{
type: "FactSet",
facts: [
{ title: "Build", value: "#" + buildData.buildNumber },
{ title: "Branch", value: buildData.branch },
{ title: "Triggered by", value: buildData.requestedBy },
{ title: "Duration", value: buildData.duration },
],
},
],
actions: [
{
type: "Action.OpenUrl",
title: "View Build",
url: buildData.buildUrl,
},
{
type: "Action.OpenUrl",
title: "View Changes",
url: buildData.changesUrl || buildData.buildUrl,
},
],
};
if (!isSuccess && buildData.errorMessage) {
card.body.push({
type: "Container",
style: "attention",
items: [{
type: "TextBlock",
text: "Error: " + buildData.errorMessage,
wrap: true,
color: "attention",
}],
});
}
return card;
}
// Pipeline usage
var WEBHOOK_URL = process.env.TEAMS_WEBHOOK_URL;
var BUILD_RESULT = process.env.AGENT_JOBSTATUS || "Unknown";
var BUILD_NUMBER = process.env.BUILD_BUILDNUMBER || "local";
var BUILD_URL = (process.env.SYSTEM_TEAMFOUNDATIONCOLLECTIONURI || "") +
(process.env.SYSTEM_TEAMPROJECT || "") +
"/_build/results?buildId=" + (process.env.BUILD_BUILDID || "0");
var card = buildStatusCard({
result: BUILD_RESULT === "Succeeded" ? "succeeded" : "failed",
definitionName: process.env.BUILD_DEFINITIONNAME || "Local Build",
buildNumber: BUILD_NUMBER,
branch: (process.env.BUILD_SOURCEBRANCH || "").replace("refs/heads/", ""),
requestedBy: process.env.BUILD_REQUESTEDFOR || "local",
duration: "calculating...",
buildUrl: BUILD_URL,
});
if (WEBHOOK_URL) {
teams.sendCard(WEBHOOK_URL, card)
.then(function () { console.log("Teams notification sent"); })
.catch(function (err) { console.error("Teams notification failed: " + err.message); });
}
Pull Request Review Card
// teams/pr-card.js
function prReviewCard(prData) {
var card = {
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
type: "AdaptiveCard",
version: "1.4",
body: [
{
type: "TextBlock",
text: "π Pull Request Review Requested",
weight: "bolder",
size: "medium",
},
{
type: "TextBlock",
text: prData.title,
wrap: true,
size: "default",
weight: "bolder",
},
{
type: "FactSet",
facts: [
{ title: "Author", value: prData.author },
{ title: "Branch", value: prData.sourceBranch + " β " + prData.targetBranch },
{ title: "Reviewers", value: prData.reviewers.join(", ") },
{ title: "Files changed", value: String(prData.fileCount) },
],
},
{
type: "TextBlock",
text: prData.description || "No description provided.",
wrap: true,
isSubtle: true,
maxLines: 3,
},
],
actions: [
{
type: "Action.OpenUrl",
title: "Review PR",
url: prData.url,
},
{
type: "Action.OpenUrl",
title: "View Files",
url: prData.url + "/files",
},
],
};
return card;
}
module.exports = { prReviewCard: prReviewCard };
Deployment Approval Card
// teams/deployment-card.js
function deploymentApprovalCard(deployData) {
var card = {
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
type: "AdaptiveCard",
version: "1.4",
body: [
{
type: "TextBlock",
text: "π Deployment Approval Required",
weight: "bolder",
size: "medium",
color: "warning",
},
{
type: "FactSet",
facts: [
{ title: "Environment", value: deployData.environment },
{ title: "Release", value: deployData.releaseName },
{ title: "Build", value: "#" + deployData.buildNumber },
{ title: "Requested by", value: deployData.requestedBy },
{ title: "Changes", value: deployData.changesSummary || "See release notes" },
],
},
{
type: "TextBlock",
text: "β οΈ This deployment requires manual approval before proceeding.",
wrap: true,
color: "warning",
},
],
actions: [
{
type: "Action.OpenUrl",
title: "Approve in Azure DevOps",
url: deployData.approvalUrl,
},
{
type: "Action.OpenUrl",
title: "View Release",
url: deployData.releaseUrl,
},
],
};
return card;
}
module.exports = { deploymentApprovalCard: deploymentApprovalCard };
Azure DevOps Service Hooks for Teams
Direct Service Hook to Teams Webhook
Configure Azure DevOps to post directly to a Teams webhook:
- Project Settings > Service Hooks > Create subscription
- Service: Web Hooks
- Event: Build completed (or other event)
- URL: Your Teams incoming webhook URL
- Resource details: All
- Test the connection
The raw webhook payload from Azure DevOps is verbose -- Teams will display it as a JSON block. For readable messages, use a middleware service that transforms the payload into an Adaptive Card.
Notification Middleware Service
// teams/notification-middleware.js
var http = require("http");
var teams = require("./send-message");
var PORT = process.env.PORT || 8092;
var CHANNEL_WEBHOOKS = {
builds: process.env.TEAMS_BUILDS_WEBHOOK,
deployments: process.env.TEAMS_DEPLOYS_WEBHOOK,
pullRequests: process.env.TEAMS_PR_WEBHOOK,
};
function formatBuildCard(payload) {
var resource = payload.resource;
var result = resource.result || "unknown";
var isSuccess = result === "succeeded";
return {
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
type: "AdaptiveCard",
version: "1.4",
body: [
{
type: "ColumnSet",
columns: [
{
type: "Column",
width: "auto",
items: [{
type: "TextBlock",
text: isSuccess ? "β
" : "β",
size: "large",
}],
},
{
type: "Column",
width: "stretch",
items: [{
type: "TextBlock",
text: resource.definition.name + " β " + result,
weight: "bolder",
color: isSuccess ? "good" : "attention",
}, {
type: "TextBlock",
text: "#" + resource.buildNumber + " | " +
(resource.sourceBranch || "").replace("refs/heads/", "") + " | " +
(resource.requestedFor ? resource.requestedFor.displayName : "unknown"),
isSubtle: true,
spacing: "none",
}],
},
],
},
],
actions: [{
type: "Action.OpenUrl",
title: "View Build",
url: resource._links && resource._links.web ? resource._links.web.href : "#",
}],
};
}
var server = http.createServer(function (req, res) {
if (req.method !== "POST") {
res.writeHead(405);
res.end();
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("Bad JSON");
return;
}
var eventType = payload.eventType || "";
console.log("Event: " + eventType);
var webhook;
var card;
if (eventType === "build.complete") {
webhook = CHANNEL_WEBHOOKS.builds;
card = formatBuildCard(payload);
} else {
console.log("Unhandled: " + eventType);
res.writeHead(200);
res.end("OK");
return;
}
if (!webhook) {
console.log("No webhook configured for " + eventType);
res.writeHead(200);
res.end("OK");
return;
}
teams.sendCard(webhook, card)
.then(function () {
console.log("Card sent to Teams");
res.writeHead(200);
res.end("OK");
})
.catch(function (err) {
console.error("Send failed: " + err.message);
res.writeHead(200);
res.end("OK");
});
});
});
server.listen(PORT, function () {
console.log("Teams notification middleware on port " + PORT);
});
Power Automate Integration
For complex workflows without custom code, Power Automate (formerly Flow) connects Azure DevOps triggers to Teams actions.
Build Failure Flow
- Create a new Power Automate flow
- Trigger: "When a build completes" (Azure DevOps connector)
- Condition: Build result equals "failed"
- Action: "Post adaptive card in a chat or channel" (Teams connector)
- Configure the Adaptive Card template with dynamic content from the build
Approval Workflow
- Trigger: "When a deployment's manual intervention is pending" (Azure DevOps)
- Action: "Post adaptive card and wait for a response" (Teams)
- Condition: If response equals "Approve"
- Action: "Update a release deployment" (Azure DevOps) -- set approval to "Approve"
This creates a complete approval loop where the approver acts directly in Teams without opening Azure DevOps.
Complete Working Example
A pipeline that sends formatted Teams notifications at each stage:
trigger:
branches:
include:
- main
pool:
vmImage: ubuntu-latest
variables:
TEAMS_WEBHOOK_URL: $(TEAMS_BUILD_WEBHOOK)
stages:
- stage: Build
jobs:
- job: BuildAndTest
steps:
- task: NodeTool@0
inputs:
versionSpec: "20.x"
- script: npm ci && npm test
displayName: Build and test
continueOnError: true
- task: PublishTestResults@2
inputs:
testResultsFormat: JUnit
testResultsFiles: "**/junit.xml"
condition: always()
- script: node teams/build-card.js
displayName: Notify Teams
condition: always()
env:
TEAMS_WEBHOOK_URL: $(TEAMS_WEBHOOK_URL)
- stage: Deploy
dependsOn: Build
condition: succeeded()
jobs:
- deployment: Production
environment: production
strategy:
runOnce:
deploy:
steps:
- script: echo "Deploying..."
on:
success:
steps:
- script: |
node -e "
var t = require('./teams/send-message');
t.sendText(process.env.TEAMS_WEBHOOK_URL, 'π Deployment to production SUCCEEDED for build #' + process.env.BUILD_BUILDNUMBER);
"
env:
TEAMS_WEBHOOK_URL: $(TEAMS_WEBHOOK_URL)
failure:
steps:
- script: |
node -e "
var t = require('./teams/send-message');
t.sendText(process.env.TEAMS_WEBHOOK_URL, 'π₯ Deployment to production FAILED for build #' + process.env.BUILD_BUILDNUMBER);
"
env:
TEAMS_WEBHOOK_URL: $(TEAMS_WEBHOOK_URL)
Webhook Management and Channel Routing
In larger organizations you end up with dozens of webhooks across multiple channels. Managing these URLs manually is error-prone. Build a configuration layer that maps events to channels:
// teams/webhook-config.js
var webhookRegistry = {
"team-alpha": {
builds: process.env.TEAMS_ALPHA_BUILDS,
deployments: process.env.TEAMS_ALPHA_DEPLOYS,
pullRequests: process.env.TEAMS_ALPHA_PR,
alerts: process.env.TEAMS_ALPHA_ALERTS
},
"team-beta": {
builds: process.env.TEAMS_BETA_BUILDS,
deployments: process.env.TEAMS_BETA_DEPLOYS,
pullRequests: process.env.TEAMS_BETA_PR,
alerts: process.env.TEAMS_BETA_ALERTS
},
"platform": {
infrastructure: process.env.TEAMS_PLATFORM_INFRA,
incidents: process.env.TEAMS_PLATFORM_INCIDENTS
}
};
function getWebhookUrl(team, eventCategory) {
var teamConfig = webhookRegistry[team];
if (!teamConfig) {
console.warn("No webhook config for team: " + team);
return null;
}
var url = teamConfig[eventCategory];
if (!url) {
console.warn("No webhook for " + team + "/" + eventCategory);
return null;
}
return url;
}
function resolveTeamFromProject(projectName) {
var mapping = {
"frontend-app": "team-alpha",
"mobile-app": "team-alpha",
"payment-service": "team-beta",
"user-service": "team-beta",
"infrastructure": "platform",
"monitoring": "platform"
};
return mapping[projectName] || null;
}
module.exports = {
getWebhookUrl: getWebhookUrl,
resolveTeamFromProject: resolveTeamFromProject
};
Store webhook URLs in Azure DevOps variable groups organized by team. When a service hook fires, the middleware looks up the correct channel based on the project name and event type. This prevents the common mistake of hard-coding webhook URLs in pipeline YAML where they get copy-pasted across repositories and nobody knows which URL goes to which channel.
Rotate webhook URLs periodically by creating a new incoming webhook connector in Teams, updating the variable group, and removing the old connector. Since webhook URLs contain a secret token, treat them like credentials. Never commit them to source control.
Rate Limits and Throttling
Teams incoming webhooks have rate limits β Microsoft documents a limit of approximately 4 messages per second per connector. If your CI system triggers dozens of builds simultaneously, notifications will be throttled or dropped. Implement queuing in your middleware:
// teams/rate-limiter.js
var queue = [];
var processing = false;
var DELAY_MS = 300; // ~3 messages per second to stay under limit
function enqueue(webhookUrl, card) {
return new Promise(function (resolve, reject) {
queue.push({ url: webhookUrl, card: card, resolve: resolve, reject: reject });
if (!processing) { processQueue(); }
});
}
function processQueue() {
if (queue.length === 0) {
processing = false;
return;
}
processing = true;
var item = queue.shift();
var teams = require("./send-message");
teams.sendCard(item.url, item.card)
.then(function (result) {
item.resolve(result);
setTimeout(processQueue, DELAY_MS);
})
.catch(function (err) {
item.reject(err);
setTimeout(processQueue, DELAY_MS);
});
}
module.exports = { enqueue: enqueue };
This simple in-memory queue spaces out webhook calls. For production systems with multiple middleware instances, use a shared queue like Azure Queue Storage or Redis.
Common Issues and Troubleshooting
Webhook Returns 400 Bad Request
Teams webhooks require specific JSON structure for Adaptive Cards. The payload must have type: "message" with an attachments array containing the card. A bare Adaptive Card JSON without the wrapper will fail. Also verify that the $schema URL and version field are present in the card. The most common mistake I see is sending the Adaptive Card directly without the message envelope:
// WRONG β bare card will be rejected
var payload = { type: "AdaptiveCard", version: "1.4", body: [/*...*/] };
// CORRECT β card wrapped in message envelope
var payload = {
type: "message",
attachments: [{
contentType: "application/vnd.microsoft.card.adaptive",
content: { type: "AdaptiveCard", version: "1.4", body: [/*...*/] }
}]
};
Adaptive Card Not Rendering
Teams supports Adaptive Cards version 1.4 and below. Features from version 1.5+ (like Action.Execute) are not supported in incoming webhooks. Check the Adaptive Card version and remove unsupported elements. Use the Adaptive Cards Designer (https://adaptivecards.io/designer/) with the "Microsoft Teams" host to preview rendering. If the card appears as a gray block with no content, check for malformed JSON β missing commas, extra trailing commas, or unescaped characters in text fields.
Webhook URL Expires or Stops Working
Teams incoming webhook URLs do not expire, but they stop working if the channel is deleted, the connector is removed, or the Teams tenant enforces connector restrictions. If notifications suddenly stop, verify the connector still exists in the channel settings. Organization-level policies can disable third-party connectors β check with your Teams admin. You can test a webhook URL with a simple curl:
curl -H "Content-Type: application/json" \
-d '{"text": "Webhook test from CLI"}' \
"YOUR_WEBHOOK_URL"
If you get a 200 response with a 1 body, the webhook is working. Any other response means the connector is broken.
Messages Arriving Out of Order
When multiple pipeline stages send notifications concurrently, messages may arrive out of chronological order. Teams does not guarantee delivery order for webhook messages. Add timestamps to your messages and consider using a single notification at pipeline completion rather than per-stage notifications.
Connector Deprecated Warnings in Teams Admin
Microsoft has announced that Office 365 Connectors (including incoming webhooks created through the connector framework) are being retired in favor of Workflows-based webhooks using Power Automate. If you see deprecation warnings, plan migration to Workflows app webhooks which use the same incoming webhook pattern but are created through the Workflows app in Teams rather than the Connectors menu.
Best Practices
Use Adaptive Cards instead of plain text. Adaptive Cards render with structure, colors, and buttons. Plain text messages blend into the channel noise. A red-highlighted build failure card catches attention; a text message saying "build failed" does not.
Include action buttons in every card. Every notification should have a "View in Azure DevOps" button. Reduce the friction from "I see a notification" to "I'm looking at the problem" to a single click.
Route notifications to dedicated channels. Create channels like "Build Notifications" and "Deployment Alerts" rather than posting everything to the team's general channel. People can mute noisy channels while keeping alert channels at full volume.
Suppress success notifications for routine builds. Only notify on failures or on deployment completions. Hundreds of "build succeeded" messages per day train the team to ignore the channel entirely.
Use Power Automate for approval workflows. Interactive approval cards in Teams save context-switching time. The approver sees the deployment details and approves without leaving Teams.
Handle webhook failures gracefully. Never fail a pipeline because a Teams notification could not be sent. Log the error and continue. Notification delivery is best-effort, not mission-critical.