Teams webhooks moved to Workflows: migration steps and limits
Teams connector webhooks stopped working in May 2026. How to rebuild one in Workflows, what the new URL accepts, and the limit that hits bursts.
The short read
Teams Workflows webhooks are limited to 25 Flow bot posts per 5 minutes; old connectors allowed 60 in 30 seconds.
- Microsoft switched off Teams connector webhooks from May 18 to 22, 2026. Workflows webhooks replace them, take a different payload and belong to a person.
- Our converter turned 7 of 11 sample MessageCards into cards that passed the official schemas; we did not post them to a real Teams tenant.
What you can do with this
Search your code and secrets for webhook.office.com and logic.azure.com, rebuild each as a Workflows webhook with a co-owner, and group alert bursts into one card.
In this article
- The shutdown slipped four times, then happened
- Find every caller that still uses an old URL
- Build the replacement workflow
- Decide who may call the URL
- The new URL wants a different body
- A converter for old MessageCard payloads
- What our test on 11 sample cards showed
- The test caught a flaw in our first draft
- The new limits hit bursts, not daily volume
- A throttled alert can arrive late or not at all
- A workflow belongs to a person, so give it a second owner
- Private channels: Microsoft’s pages disagree
- When Workflows is the wrong replacement
- Move one webhook today
- How we made this
Microsoft switched off Office 365 connectors in Teams between May 18 and May 22, 2026, and the Incoming Webhook connector went with them. A script, server or SaaS tool that still posts to an old connector URL now reaches nobody. Nothing tells the channel that a message is missing, so an alert that stopped in May may still not have been noticed.
The replacement is a webhook in the Teams Workflows app, which runs on Power Automate. It accepts a different payload, has tighter burst limits and belongs to a person rather than to the channel.
This guide covers each of those, with the exact steps and limits from Microsoft’s pages, read October 28, 2026. It includes a converter for old payloads that we wrote and tested on 11 sample cards.
The shutdown slipped four times, then happened
Microsoft announced the retirement on July 3, 2024 with a short timeline: new connectors blocked from August 15, 2024, and “all connectors within all clouds will stop working” on October 1, 2024. It tied the change to its Secure Future Initiative and named Power Automate workflows as the replacement.
Then the date moved. Every row below comes from the dated updates at the top of that same post.
| Update | What it said |
|---|---|
| July 23, 2024 | Retirement extended “through December 2025” |
| October 16, 2024 | Webhook connector URLs must move to a new URL format by January 31, 2025 to keep posting |
| October 28, 2025 | Migration deadline moved to March 31, 2026 |
| February 5, 2026 | MessageCard payloads now accepted by Workflows; deadline moved to April 30, 2026 |
| April 14, 2026 | Final schedule: rollout begins May 18, 2026, completes May 22, 2026 |
The April post leaves no room: “After these dates, Office 365 Connectors will no longer function.”
The extensions came because the replacement was missing features; in February 2026 Microsoft wrote that “these capabilities took longer than expected”. Its April 2025 and September 2025 updates had promised MessageCard support, private channel posting and a custom bot name and icon. MessageCard support arrived in February 2026. The custom name and icon were still “not available” in the final April 2026 update.
Find every caller that still uses an old URL
Two kinds of URL now fail. Connector webhook URLs look like the one in Microsoft’s own sample code, https://xxxxx.webhook.office.com/....
The second kind is easy to miss: early Workflows webhooks. Power Automate flows with HTTP or Teams webhook triggers whose URL contains logic.azure.com moved to a new URL, and from November 30, 2025 “the old URLs stop working and flows don’t trigger”. Microsoft says this affects only flows in environments on the Logic Apps architecture. If you moved to Workflows early, check your URL anyway.
Search code, CI settings and config files for both hosts:
grep -rniE "webhook\.office\.com|logic\.azure\.com" . --exclude-dir=node_modules --exclude-dir=.git
Webhook URLs are secrets, so many of them live outside the repository. Check CI secret names, monitoring tools (uptime checkers, backup software, ticket systems), and any SaaS integration settings page that mentions Teams. A secret’s value is usually hidden, so the name is what you search.
Build the replacement workflow
Microsoft’s Learn page gives these steps for a channel:
- In Teams, go to the channel that should receive the messages.
- Select More options (…) next to the channel, then Workflows.
- Search for and select the template Send webhook alerts to a channel.
- Set the team and channel, then select Save.
- Copy the webhook link the workflow shows. This is the new URL for your script.
Microsoft’s support article lists six templates: alerts to a chat or a channel, from anyone, from specific people or from people in your organization. It also says the templates and the webhook trigger do “not require a premium license”, so an ordinary Microsoft 365 seat is enough. Our Power Automate comparison covers what else that seat includes.
Decide who may call the URL
The choice of template sets who can post. The trigger behind all six, When a Teams webhook request is received, lets the owner allow “anyone, any user in the tenant, or only a specific subset of users”, according to the Teams connector reference. Microsoft presents this as a difference from the old webhooks, where the URL alone was enough.
The trade-off is code. With Anyone, the reference says not to send an authorization header at all, or “POST requests to the trigger will fail”.
With the two tenant options, every request needs an OAuth token from Microsoft Entra ID, which Microsoft’s OAuth page suggests getting with its MSAL library. That is a token request in your script on every run. For a cron job that posts a nightly report, Anyone plus a URL kept in a secret store is the simpler choice. For a URL that might leak, such as one baked into a desktop tool, pay the token cost.
The new URL wants a different body
The old Incoming Webhook took a MessageCard, an older JSON format that Microsoft now documents as the legacy actionable message card. The Workflows trigger is documented with a different envelope. The Teams connector reference requires type set to "message" and an attachments array. Each attachment carries contentType "application/vnd.microsoft.card.adaptive", contentUrl null and the card itself in content:
{
"type": "message",
"attachments": [{
"contentType": "application/vnd.microsoft.card.adaptive",
"contentUrl": null,
"content": {
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.2",
"body": [{ "type": "TextBlock", "text": "Nightly backup finished", "wrap": true }]
}
}]
}
Since February 2026 Microsoft also accepts MessageCard payloads, so in principle you can paste the new URL into an old script and stop there.
The catch is buttons. The April 2026 update says MessageCard payloads “will render content but will not support HttpPost action buttons”. The Learn page is broader: Workflows support both formats, “button rendering won’t be supported”.
Read literally, that covers a plain “View build” link button too. We could not check it without a tenant. An Adaptive Card avoids the question, because its link buttons, Action.OpenUrl, are part of the format the trigger documents.
Three more differences matter:
- Plain text. The Learn page’s example posts
{"text": "..."}to a workflow, but the trigger reference documents only the card envelope. We convert text-only payloads to a one-line card rather than rely on the example. - Markdown. Adaptive Card text supports bold, italic, lists and links, and Microsoft’s formatting page lists headers, tables, images, preformatted text and blockquotes as unsupported. A MessageCard that used Markdown tables loses them.
- Size. Posts are capped at about 28 KB, and a larger one fails with “Request Entity too large”, per the connector reference.
A converter for old MessageCard payloads
The script below takes a MessageCard and prints the Workflows envelope with an Adaptive Card inside. It runs on Node.js 22 with no packages. It maps titles, text, sections, facts, images and link buttons, and it prints a warning for every button and color it drops.
// card2adaptive.mjs: turn a MessageCard (old Teams connector payload) into the
// body a Teams Workflows webhook expects. Node.js 22, no packages.
// Usage: node card2adaptive.mjs old.json > new.json (or pipe the JSON in on stdin)
import { readFileSync } from 'node:fs';
const LIMIT = 28 * 1000; // Microsoft says "approximately 28 KB"; stay under the low reading
const warn = (msg) => console.error(`warning: ${msg}`);
const raw = readFileSync(process.argv[2] ?? 0, 'utf8');
if (!raw.trim()) throw new Error('empty input');
const card = JSON.parse(raw);
if (card['@type'] !== 'MessageCard' && !card.text && !card.sections) {
throw new Error('not a MessageCard: no @type, text or sections');
}
const text = (t, extra = {}) => ({ type: 'TextBlock', text: String(t), wrap: true, ...extra });
const body = [];
const actions = [];
function openUrl(a) {
// OpenUri can hold one link per operating system; Adaptive Cards take one.
const targets = a.targets ?? [];
const pick = targets.find((t) => t.os === 'default') ?? targets[0];
if (!pick?.uri) return warn(`OpenUri "${a.name}" has no target, dropped`);
if (targets.length > 1) warn(`OpenUri "${a.name}": kept ${pick.uri}, dropped ${targets.length - 1} per-OS link(s)`);
return { type: 'Action.OpenUrl', title: a.name ?? 'Open', url: pick.uri };
}
function convertActions(list, where) {
const out = [];
for (const a of list ?? []) {
if (a['@type'] === 'OpenUri') {
const x = openUrl(a);
if (x) out.push(x);
} else {
// HttpPOST and ActionCard post back to your server; a Workflows webhook
// cannot do that, so the button would be dead. Say so instead of faking it.
warn(`${where}: ${a['@type']} "${a.name}" dropped (needs a workflow that handles the response)`);
}
}
return out;
}
if (card.themeColor) warn(`themeColor ${card.themeColor} dropped (Adaptive Cards have no free accent color)`);
if (card.title) body.push(text(card.title, { size: 'Large', weight: 'Bolder' }));
if (card.text) body.push(text(card.text));
if (!card.title && !card.text && card.summary) body.push(text(card.summary, { weight: 'Bolder' }));
for (const [i, s] of (card.sections ?? []).entries()) {
const items = [];
if (s.title) items.push(text(s.title, { weight: 'Bolder' }));
const activity = [
s.activityTitle && text(s.activityTitle, { weight: 'Bolder' }),
s.activitySubtitle && text(s.activitySubtitle, { isSubtle: true, spacing: 'None' }),
s.activityText && text(s.activityText),
].filter(Boolean);
if (s.activityImage && activity.length) {
items.push({
type: 'ColumnSet',
columns: [
{ type: 'Column', width: 'auto', items: [{ type: 'Image', url: s.activityImage, size: 'Small', style: 'Person' }] },
{ type: 'Column', width: 'stretch', items: activity },
],
});
} else if (s.activityImage) {
items.push({ type: 'Image', url: s.activityImage, size: 'Small' });
} else items.push(...activity);
if (s.heroImage?.image) items.push({ type: 'Image', url: s.heroImage.image, altText: s.heroImage.title ?? '' });
if (s.text) items.push(text(s.text));
if (s.facts?.length) {
items.push({ type: 'FactSet', facts: s.facts.map((f) => ({ title: String(f.name ?? ''), value: String(f.value ?? '') })) });
}
const imgs = (s.images ?? []).filter((im) => im.image);
if (imgs.length) {
items.push({ type: 'ImageSet', images: imgs.map((im) => ({ type: 'Image', url: im.image, altText: im.title ?? '' })) });
}
// A section's buttons stay under that section, so a digest keeps each link with its item.
const own = convertActions(s.potentialAction, `section ${i + 1}`);
if (own.length) items.push({ type: 'ActionSet', actions: own });
if (items.length) body.push({ type: 'Container', separator: i > 0 || body.length > 0, items });
}
actions.push(...convertActions(card.potentialAction, 'card'));
if (!body.length) throw new Error('nothing to show: the card has no title, text, summary or sections');
const content = {
$schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
type: 'AdaptiveCard',
version: '1.2',
body,
...(actions.length && { actions }),
...(card.summary && { fallbackText: card.summary }),
};
const payload = {
type: 'message',
attachments: [{ contentType: 'application/vnd.microsoft.card.adaptive', contentUrl: null, content }],
};
const json = JSON.stringify(payload);
const bytes = Buffer.byteLength(json);
if (bytes > LIMIT) throw new Error(`card is ${bytes} bytes; Teams rejects messages over about 28 KB`);
process.stdout.write(JSON.stringify(payload, null, 2) + '\n');
Run it on a saved payload, then send the result to the new URL with curl:
node card2adaptive.mjs old-card.json > card.json
curl -sS -X POST -H "Content-Type: application/json" --data @card.json "$TEAMS_WORKFLOW_URL"
Three choices in the script deserve a word. It sets card version 1.2, the version in Microsoft’s own trigger example; every element it writes exists in that version, and Teams mobile handles up to 1.6. It keeps one link per button because a MessageCard OpenUri action can hold a different link per operating system and an Adaptive Card link holds one. And it refuses a card over 28,000 bytes, the low reading of Microsoft’s “approximately 28 KB”, rather than let Teams reject it later.
If your script builds the MessageCard in code rather than reading a file, use the converter once to see the target shape, then change the code that builds the payload. Converting at send time adds a step that can break.
What our test on 11 sample cards showed
We wrote 11 inputs on October 28, 2026. One is Microsoft’s own sample MessageCard from its connector documentation. The other 10 we made up to cover common shapes and failure cases.
We then checked every output against the official Adaptive Card JSON schemas for versions 1.2 and 1.5 from Microsoft’s AdaptiveCards repository, using the ajv 8.17.1 validator. As a control, we fed the validator two cards with deliberate faults, and it rejected both.
| Input | Result | Warnings |
|---|---|---|
| Microsoft’s sample (activity, 3 facts, 4 actions) | Converted; 1 link button kept | 3 ActionCards dropped, accent color dropped |
| Text only | Converted to a one-line card | None |
| CI alert with facts and a link for 3 platforms | Converted | 2 per-platform links dropped, accent color dropped |
| Digest, 3 sections with images and a link | Converted; link stays in its section | None |
| Summary only | Converted | None |
| Link button with no target | Converted, button removed | 1 |
| Images with a missing URL | Converted, bad image skipped | None |
| 30,000-character text | Refused: 30,386 bytes | |
| Empty file | Refused | |
| JSON that is not a card | Refused | |
| Malformed JSON (trailing comma) | Refused with the parse position |
All 7 converted cards passed both schemas and had the envelope the trigger reference requires. Microsoft’s sample shows the main loss: three of its four buttons were forms that posted back to a server (add a comment, set a due date, change status). No Workflows webhook can serve those, so they are gone, and the converter says so.
The test caught a flaw in our first draft
Our first draft moved section buttons to the bottom of the card. The digest test showed it, and it breaks a digest where each item has its own link. The version above keeps them in place. A code review found two more gaps, an image-only section and an image with no URL, which we fixed before the final run.
What the test does not show: we had no Teams tenant, so we did not post any card to a real Workflows URL or look at how Teams draws it. Passing the schema means the card is well formed, not that it renders exactly as the old one did.
The new limits hit bursts, not daily volume
The old Incoming Webhook had its own rate table. Microsoft’s connector page lists 4 requests per second, 60 per 30 seconds, 100 per hour, 150 per 2 hours and 1,800 per day. Workflows replaces that with Power Automate’s limits, and the tightest one is easy to miss.
| Limit | Old connector | Workflows webhook |
|---|---|---|
| Posts in a burst | 60 per 30 seconds | 25 per 5 minutes per connection for Flow bot posts |
| Posts per hour | 100 | 300, our arithmetic from 25 per 5 minutes |
| Daily cap | 1,800 posts | 6,000 Power Platform requests per user per 24 hours on a Microsoft 365 license (10,000 per flow during Microsoft’s transition period) |
| Message size | 28 KB | About 28 KB |
The 25 comes from the Teams connector reference. It lists 25 “non-Get requests per connection” per 300 seconds for “Post an Adaptive Card as Flow bot, and any other Flow bot operations”. Microsoft says webhook posts show the Flow bot identity, so in our reading every alert counts.
The daily figures come from Microsoft’s request limits page. Every connector call and built-in action in a run counts toward them, so the number of posts is lower than 6,000.
So a nightly job that posts once is fine, and so is a monitor that posts a few times an hour. A CI pipeline that posts one message per failed test, or a monitoring tool that fires 40 alerts when a network link drops, is not.
A throttled alert can arrive late or not at all
What happens to post number 26? Microsoft does not say directly.
A flow takes the plan of its owner, and Microsoft 365 plans get the Low performance profile, whose default retry policy “sends up to two retries” at intervals that grow from 5 minutes to about 10 minutes. In our reading, a throttled post arrives up to about 15 minutes late. If both retries are throttled too, the run fails and that message never appears. A script that posted it has no way to see that from its own log, so check the workflow’s run history after any burst.
The fix is in the sender. Group bursts into one card, one section per alert, as in the converter’s digest shape. Our three-item digest came to 1,540 bytes, so a dozen alerts stay well under 28 KB. Or keep a small queue in the script and send at most one message every 12 seconds, which is 25 in 5 minutes.
A workflow belongs to a person, so give it a second owner
A connector belonged to the channel. A workflow does not. Microsoft’s Learn page says workflows “are linked only to specific users” and “can become orphan flows in the absence of an owner if no co-owners assigned”.
Power Automate’s limits page adds what that costs. If the original owner leaves the organization, the flow drops to the Low performance profile.
Two more rules on that page catch alert webhooks in particular:
- 90 days without a call. “A cloud flow that isn’t triggered within a 90 day period might be turned off.” Owners and co-owners are told 30 days before. The rule skips owners with premium or Process licenses, so a workflow on a plain Microsoft 365 seat is exposed. A disk-full or certificate-expiry alert may go quiet for months and then not fire when it matters.
- 14 days of failures or throttling. A flow that fails continuously, or is “consistently throttled”, for 14 days is turned off.
The response is cheap. Add at least one co-owner from the same team to every webhook workflow, which admins can also do after the fact under Microsoft’s orphan flow steps.
For rare alerts, send a weekly heartbeat post from the same script; it keeps the flow triggered and proves the path still works. That is our suggestion, not a Microsoft rule. Our guide to small automations in code shows a dead man’s switch you can pair with it, so you also hear when the script itself stops.
Private channels: Microsoft’s pages disagree
If your old webhook posted to a private channel, test before you rely on the new one. Microsoft’s pages do not agree on whether Workflows can post there.
- The April 14, 2026 update says private channels “are already supported for Workflows webhooks through the Power Automate portal”, with selection in the Teams Workflows app “rolling out fully by April 20, 2026”.
- The Learn webhook page, updated August 3, 2026, says Flow bot support in private channels “is under development” and that workflows can post on behalf of a user.
- The Teams connector reference, updated October 2, 2026, still lists “Posting a message or adaptive card to a private channel currently isn’t supported.”
Shared channels are supported, according to the February 2026 update. If a private channel is a must, the fallback is posting as a user, which makes the alerts appear under that person’s name.
When Workflows is the wrong replacement
Workflows suits low-volume alerts and reports from a script. Microsoft’s July 2024 update named Microsoft Graph and a Teams app as the other routes. They take more work but fit some cases better:
- You need your own name and icon on posts. Workflows posts appear as the Flow bot, and custom icons and names “are not supported for Adaptive Card payloads”, per the April 2026 update. For this, Microsoft’s Learn page points to a notification bot, which it says has “more functionalities” than a webhook.
- You need buttons that do something. Approve, assign or comment buttons need code that receives the click. In Workflows that means a custom flow that handles the card response, not a webhook template. A Teams app’s bot can post to channels through proactive messages and handle the click in its own code.
- You post more than a few dozen messages in a burst. The 25 per 5 minutes Flow bot limit applies per connection. A Teams app or Graph integration has its own limits, which you would check before building.
A Teams app is code you host and someone has to maintain. For a five-person team with three alert scripts, that cost buys little. Workflows plus a co-owner and a digest card is enough.
Move one webhook today
- Search code, CI secrets and SaaS settings for
webhook.office.comandlogic.azure.com. - Create a workflow from Send webhook alerts to a channel in the target channel and copy its URL into your secret store.
- Pick who may call it: Anyone with a secret URL for scripts, a tenant option with OAuth tokens for anything that could leak.
- Convert the payload with
card2adaptive.mjs, read the warnings, and rebuild any button that posted back to a server. - Send one test with curl and check the card in the channel on desktop and phone.
- Add a co-owner to the workflow, and a weekly heartbeat for alerts that rarely fire.
- Cap bursts at 25 posts per 5 minutes, or group them into one digest card.
How we made this
We read Microsoft’s retirement post with all its dated updates, the Teams Learn pages on webhooks, connectors, cards and formatting, the Teams connector reference, and Power Automate’s limits, request-limit and trigger troubleshooting pages on October 28, 2026. We wrote the converter and ran it on 11 sample cards, one from Microsoft’s documentation and 10 we made ourselves, then checked the outputs against the official Adaptive Card 1.2 and 1.5 schemas with ajv. We had no Microsoft 365 tenant, so we did not create a workflow, post to a real webhook URL, see how Teams renders the cards or observe the throttling; those parts come from Microsoft’s documentation.
Sources
- Microsoft 365 Developer Blog, Retirement of Office 365 connectors within Microsoft Teams (posted July 3, 2024, last updated April 14, 2026)
- Microsoft Learn, Create an Incoming Webhook (updated August 3, 2026)
- Microsoft Learn, Microsoft Teams connector reference (updated October 2, 2026)
- Microsoft Support, Send messages in Teams using incoming webhooks (read October 28, 2026)
- Microsoft Learn, Create and send messages (updated October 1, 2025)
- Microsoft Learn, Troubleshooting a cloud flow trigger: changes to HTTP or Teams Webhook trigger flows (updated August 11, 2026)
- Microsoft Learn, Limits of automated, scheduled, and instant flows (updated July 18, 2026)
- Microsoft Learn, Requests limits and allocations (updated October 15, 2026)
- Microsoft Learn, Manage orphan flows when the owner leaves the organization (updated June 30, 2026)
- Microsoft Learn, Types of cards and Format cards in Microsoft Teams (both updated October 23, 2026)
- Microsoft Learn, Legacy actionable message card reference (updated October 9, 2024)
- Microsoft, Adaptive Card JSON schemas, versions 1.2.0 and 1.5.0 (downloaded October 28, 2026)