Guide
Microsoft Teams webhooks with Workflows: send messages from Node.js
By Aman Singh · Published · 8 min read
For years the quick way to post alerts into Microsoft Teams was an incoming webhook connector: paste a URL into your app and send JSON. Microsoft is retiring those Microsoft 365 connectors. The replacement is a webhook created with the Workflows app, which runs on Power Automate. This guide shows how to create one, the message format it accepts, and Node.js code to send messages reliably.
Facts here follow Microsoft's incoming webhook documentation (updated August 2026). The Node.js code was tested on Node.js 20 against a local stand-in for the webhook, not a real Teams tenant.
Want the alerts wired into your app for you? Get it set up →
What changed with Teams webhooks
Microsoft's documentation says Microsoft 365 connectors (previously called Office 365 connectors) are nearing deprecation and that creating new ones will soon be blocked. Its recommendation is the Workflows app: a workflow that starts when a webhook request arrives and posts to a channel or chat. If you already rely on an old connector URL, plan the move now rather than when it stops working.
Create a Workflows webhook
- 1In Teams, open the channel that should receive messages and choose More options (...) next to it, then Workflows.
- 2Pick a template such as Send webhook alerts to a channel, or build your own with the When a Teams webhook request is received trigger.
- 3Set the team and channel, then Save.
- 4Copy the webhook URL the workflow shows. Treat it like a password: anyone with it can post to the channel.
Check it works before writing any code:
curl -X POST "$TEAMS_WEBHOOK_URL" \
-H "Content-Type: application/json" \
-d '{"type":"message","attachments":[{"contentType":"application/vnd.microsoft.card.adaptive","content":{"type":"AdaptiveCard","version":"1.4","body":[{"type":"TextBlock","text":"Hello from a webhook"}]}}]}'The message format
Workflows webhooks accept Adaptive Cards (and the older Message Card format, without button rendering). The card goes inside an attachments array with the content type application/vnd.microsoft.card.adaptive. Text fields support basic Markdown; HTML is shown as plain text.
| Field | Value |
|---|---|
type | "message" |
attachments[0].contentType | application/vnd.microsoft.card.adaptive |
attachments[0].content | The Adaptive Card: type AdaptiveCard, a version, body and actions |
Send from Node.js, with retries
No SDK is needed: Node.js 18 and later have fetch. This module builds a card from a title, text, optional facts and a link, refuses messages over the size limit, and retries throttled or failed requests with exponential backoff, honouring Retry-After.
// Post a message to Microsoft Teams through a Workflows webhook URL
// ("When a Teams webhook request is received"), with Node.js 18+ fetch.
const MAX_BYTES = 28 * 1024; // Teams rejects messages over 28 KB
export type TeamsAlert = {
title: string;
text: string;
facts?: Record<string, string>;
linkUrl?: string;
linkTitle?: string;
};
/** Builds the Adaptive Card message shape that Teams webhooks accept. */
export function buildTeamsMessage(alert: TeamsAlert) {
const body: object[] = [
{ type: "TextBlock", text: alert.title, weight: "Bolder", size: "Medium", wrap: true },
{ type: "TextBlock", text: alert.text, wrap: true },
];
if (alert.facts) {
body.push({
type: "FactSet",
facts: Object.entries(alert.facts).map(([title, value]) => ({ title, value })),
});
}
return {
type: "message",
attachments: [
{
contentType: "application/vnd.microsoft.card.adaptive",
content: {
$schema: "http://adaptivecards.io/schemas/adaptive-card.json",
type: "AdaptiveCard",
version: "1.4",
body,
actions: alert.linkUrl
? [{ type: "Action.OpenUrl", title: alert.linkTitle ?? "Open", url: alert.linkUrl }]
: [],
},
},
],
};
}
/** Sends with retries on 429 and 5xx (exponential backoff, honours Retry-After). */
export async function sendToTeams(webhookUrl: string, message: object, retries = 4): Promise<number> {
const body = JSON.stringify(message);
if (Buffer.byteLength(body) > MAX_BYTES) {
throw new Error(`Message is ${Buffer.byteLength(body)} bytes; Teams allows 28 KB`);
}
for (let attempt = 0; ; attempt++) {
const res = await fetch(webhookUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body,
signal: AbortSignal.timeout(10_000),
});
if (res.ok) return res.status;
const retryable = res.status === 429 || res.status >= 500;
if (!retryable || attempt >= retries) {
throw new Error(`Teams webhook failed: HTTP ${res.status} ${await res.text()}`);
}
const retryAfter = Number(res.headers.get("retry-after"));
const waitMs = Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter * 1000 : 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
}import { buildTeamsMessage, sendToTeams } from "./teams";
await sendToTeams(
process.env.TEAMS_WEBHOOK_URL!,
buildTeamsMessage({
title: "Payment failed",
text: "A renewal charge was declined.",
facts: { Customer: "Acme Ltd", Amount: "$49.00" },
linkUrl: "https://dashboard.example.com/billing",
linkTitle: "Open billing",
}),
);Show the tests (teams.test.ts)
import { test } from "node:test";
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { buildTeamsMessage, sendToTeams } from "./teams.ts";
// A stand-in for the Workflows webhook: throttles twice, then accepts.
function mockWebhook(responses: number[]) {
const received: unknown[] = [];
const server = createServer((req, res) => {
let data = "";
req.on("data", (c) => (data += c));
req.on("end", () => {
received.push(JSON.parse(data));
const status = responses.shift() ?? 202;
res.writeHead(status, status === 429 ? { "retry-after": "0" } : {}).end(status === 429 ? "throttled" : "");
});
});
return new Promise<{ url: string; received: unknown[]; close: () => void }>((resolve) =>
server.listen(0, () => {
const { port } = server.address() as { port: number };
resolve({ url: `http://127.0.0.1:${port}/workflow`, received, close: () => server.close() });
}),
);
}
test("card shape matches the Teams Adaptive Card message format", () => {
const msg = buildTeamsMessage({ title: "Deploy failed", text: "main @ 2c2f714", facts: { Service: "api" }, linkUrl: "https://example.com" });
const att = msg.attachments[0];
assert.equal(msg.type, "message");
assert.equal(att.contentType, "application/vnd.microsoft.card.adaptive");
assert.equal(att.content.type, "AdaptiveCard");
assert.equal(att.content.actions.length, 1);
});
test("retries 429 then succeeds", async () => {
const hook = await mockWebhook([429, 429, 202]);
const status = await sendToTeams(hook.url, buildTeamsMessage({ title: "t", text: "x" }));
hook.close();
assert.equal(status, 202);
assert.equal(hook.received.length, 3);
});
test("does not retry a 400", async () => {
const hook = await mockWebhook([400]);
await assert.rejects(sendToTeams(hook.url, { type: "message" }), /HTTP 400/);
hook.close();
assert.equal(hook.received.length, 1);
});
test("refuses messages over 28 KB before sending", async () => {
const big = buildTeamsMessage({ title: "big", text: "x".repeat(30_000) });
await assert.rejects(sendToTeams("http://127.0.0.1:9/never", big), /28 KB/);
});ok 1 - card shape matches the Teams Adaptive Card message format
ok 2 - retries 429 then succeeds
ok 3 - does not retry a 400
ok 4 - refuses messages over 28 KB before sending
# pass 4
# fail 0Limits that bite
- 28 KB per message. Larger messages are rejected. Link to details instead of pasting logs.
- About four requests a second. Above that, requests are throttled. Batch alerts, or let the retry logic spread them out.
- Workflows belong to a person. If the owner leaves, the flow can be orphaned and stop. Add co-owners when you create it.
- Channel types. Microsoft lists posting as the flow bot in private channels as still in development, so test the exact channel you plan to use.
Migrating from an old connector URL
- 1Find every place that posts to an old connector URL (they usually contain
webhook.office.com). - 2Create a Workflows webhook for the same channel and send a test card to it.
- 3If your old payload was a Message Card, consider switching to an Adaptive Card now; buttons in Message Cards are not rendered by Workflows.
- 4Replace the URL in configuration (never in code), deploy, and remove the old connector.
Common questions
Are Microsoft Teams incoming webhooks being retired?+
How do I create a webhook in Microsoft Teams now?+
What JSON does a Teams Workflows webhook accept?+
Why are some of my Teams webhook messages missing?+
Need alerts and integrations wired up?
Sending the right events from your app to Teams, Slack or email is a typical automation and integrations job. For payment alerts specifically, pair this with Stripe webhooks in Next.js.
Next steps
Keep reading
Need a hand with this?
Tell me what you are working on. Free scoping call, fixed quote within 48 hours.
Book a call