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 →

The old connector URL went straight to a channel. A Workflows URL goes through a flow that a person owns, so give it co-owners.

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

  1. 1
    In Teams, open the channel that should receive messages and choose More options (...) next to it, then Workflows.
  2. 2
    Pick a template such as Send webhook alerts to a channel, or build your own with the When a Teams webhook request is received trigger.
  3. 3
    Set the team and channel, then Save.
  4. 4
    Copy 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:

Terminal
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.

FieldValue
type"message"
attachments[0].contentTypeapplication/vnd.microsoft.card.adaptive
attachments[0].contentThe 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.

teams.ts
// 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));
  }
}
Usage
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)
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/);
});
Output
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 0

Limits 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

  1. 1
    Find every place that posts to an old connector URL (they usually contain webhook.office.com).
  2. 2
    Create a Workflows webhook for the same channel and send a test card to it.
  3. 3
    If your old payload was a Message Card, consider switching to an Adaptive Card now; buttons in Message Cards are not rendered by Workflows.
  4. 4
    Replace the URL in configuration (never in code), deploy, and remove the old connector.

Common questions

Are Microsoft Teams incoming webhooks being retired?+
Microsoft 365 connectors, which provided the classic incoming webhook, are nearing deprecation according to Microsoft's documentation, and creating new ones will soon be blocked. Workflows webhooks are the recommended replacement.
How do I create a webhook in Microsoft Teams now?+
Open the channel's More options menu, choose Workflows, and use a template such as 'Send webhook alerts to a channel' or the 'When a Teams webhook request is received' trigger. Save it and copy the URL.
What JSON does a Teams Workflows webhook accept?+
A message with an attachments array containing an Adaptive Card (content type application/vnd.microsoft.card.adaptive). The older Message Card format also works, but its buttons are not rendered.
Why are some of my Teams webhook messages missing?+
Common causes are messages over 28 KB, more than about four requests a second, or a workflow that was turned off or lost its owner. Check the workflow's run history in Power Automate.

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

← All guides