Microsoft Graph in Practice #6: Teams — Reading Messages, and the Three Routes for Sending Notifications

6 min read

The Teams part is structured differently from the others. The reading side follows the familiar pattern — but the writing side (sending notifications) contains the most famous wall in this series. “Our server automation posts announcements to a channel” — the first scenario everyone imagines — cannot be done with Graph’s application permissions. Why not, and what to use instead, is this part’s center.

Reading — the same pattern as before #

The working side first. Browsing team and channel structure and reading messages uses part 3’s skeleton unchanged.

reading teams, channels, messages
async def read_channel(client, team_id: str, channel_id: str):
    # teams visible to me (delegated: me.joined_teams)
    channels = await client.teams.by_team_id(team_id).channels.get()

    messages = await (
        client.teams.by_team_id(team_id)
        .channels.by_channel_id(channel_id)
        .messages.get()
    )
    for m in messages.value or []:
        who = m.from_.user.display_name if m.from_ and m.from_.user else "(app)"
        print(who, ":", (m.body.content or "")[:80])

Read permissions exist on both sides (ChannelMessage.Read.All and friends; note that org-wide channel-message reading is classified as a protected API with extra approval steps in some cases — check the doc’s table). Two working notes: message bodies arrive as HTML, and bulk collection properly belongs to part 7’s delta queries.

The wall — channel message sends and application permissions #

Now for the trouble spot. The permissions table for sending a channel message looks like this (per the official docs):

Permission typeSending channel messages
Delegated (work account)ChannelMessage.Send
ApplicationTeamwork.Migrate.Allmigration only

That Teamwork.Migrate.All in the application row is exactly what its name says: reserved for importing another system’s historical conversations into Teams. It’s a special procedure that pours past-timestamped messages into a channel switched into migration mode — not a way to post live notifications to an operating channel. So calling POST /teams/{id}/channels/{id}/messages with a client credentials token returns 403 no matter how many permissions you grant. That’s the “reads work, writes 403” symptom that fills the search results.

It helps to understand this as intended design. The right for an unattended app to write into any channel in an organization is an easy conduit for spam and impersonation, and Microsoft chose to keep that door closed from the start (the docs even carry a terms-of-use warning: don’t use Teams as a log file). So the correct move is choosing one of the three routes below at design time; spending hours trying to outflank the 403 is a dead end.

Route ① delegated + an automation account — staying within Graph #

If you want to stay in Graph, the first route is switching the send to delegated: create a notification account (say bot-notify@contoso.com), invite it to the target teams, and send with that account’s delegated token.

sending a channel message via delegated
from msgraph.generated.models.chat_message import ChatMessage
from msgraph.generated.models.item_body import ItemBody
from msgraph.generated.models.body_type import BodyType

async def post_notice(client, team_id: str, channel_id: str, html: str):
    msg = ChatMessage(body=ItemBody(content_type=BodyType.Html, content=html))
    await (
        client.teams.by_team_id(team_id)
        .channels.by_channel_id(channel_id)
        .messages.post(msg)
    )

The catch is maintaining a delegated token unattended. Avoid the discouraged workaround of automating a sign-in (ROPC-style flows); the realistic shape is one interactive sign-in followed by safely storing and refreshing the token (azure-identity’s token cache helps). The costs of this route: messages appear under that account’s name, and you own account and token operations. If the requirement is close to two-way — composing message content from Graph data, replying into threads — those costs are worth paying.

Route ② Workflows incoming webhooks — the shortest path for one-way alerts #

If “just post a notification” is the entire requirement, the right answer is not using Graph at all. Teams’ Workflows (Power Automate) incoming webhooks are the shortest path: add a “post when a webhook request is received” workflow to the channel, receive a URL, and the server just POSTs to it. No app registration, no tokens, no permission approvals.

notify via webhook — no auth
import httpx

async def notify(webhook_url: str, title: str, text: str):
    card = {
        "type": "message",
        "attachments": [{
            "contentType": "application/vnd.microsoft.card.adaptive",
            "content": {
                "type": "AdaptiveCard", "version": "1.4",
                "body": [
                    {"type": "TextBlock", "size": "Large", "weight": "Bolder", "text": title},
                    {"type": "TextBlock", "wrap": True, "text": text},
                ],
            },
        }],
    }
    async with httpx.AsyncClient() as http:
        (await http.post(webhook_url, json=card)).raise_for_status()

Note that the “Office 365 Connectors (Incoming Webhook)” found in older articles is a retired route; anything new should be built on Workflows. Formatting uses Adaptive Cards, a JSON card format — the skeleton above plus a few fields covers deployment and incident alerts. One caution: the webhook URL itself is a secret. Anyone holding it can post to the channel, so manage it at secret grade. For CI deploy notices and monitoring alerts, this route covers the majority of real one-way notification needs — and it is in fact the most widely used form.

Route ③ bots (Bot Framework) — when you need conversation #

Responding to mentions, receiving button input, sending proactive personal DMs — that level belongs to neither Graph nor webhooks but to bots: Bot Framework (Azure Bot Service), installed as a Teams app. It’s an independent topic with its own development model (activities, turn contexts), beyond this series’ scope. The signpost to plant: when the requirement turns conversational, it’s time to move to a bot.

Selection criteria #

RequirementRouteCost
One-way alerts (deploys, alarms, reports)② Workflows webhookURL management only — shortest
Combined with Graph data, thread replies, named account① delegated automation accountAccount and token operations
Mentions, buttons, DMs — conversational③ botA separate development model

One more note: chat (1:1 and group) sending is also delegated-centric, same as channels. The frequent ask — sending a specific user a DM notification — is properly a bot’s proactive message; as a simple substitute, part 4’s mail sending remains a perfectly valid answer in practice.

Summary #

  • Teams reading (teams, channels, messages) works with the existing skeleton; bulk collection belongs to part 7’s delta.
  • The application permission for channel sends (Teamwork.Migrate.All) is for importing historical conversations only. Live notifications over client credentials are blocked by design — a 403 here is a signal to change routes, not an obstacle to bypass.
  • One-way alerts: Workflows incoming webhooks — one POST, no app registration; guard the URL as a secret.
  • Sends entangled with Graph data: delegated plus a dedicated notification account. Conversational needs: move to Bot Framework.
  • The Office 365 Connector webhooks in older articles are the legacy route. Build new on Workflows.

The final part is operations: throttling (429), pagination, and the polling replacements — delta queries and change notification subscriptions.

X