Guide
Automating Reporting WorkflowsDeep dive

Post an Excel Report to Microsoft Teams with Python

Post an Adaptive Card carrying the report's headline figures and a link to the workbook — with outcome colouring, failure cards from the exception handler, and Retry-After handling.

A report that lands in a channel is read; one that lands in an inbox competes with everything else. Posting to Teams is a single HTTP request, and the useful version of it does more than announce that a file exists — it carries the headline numbers, so most people never need to open the workbook. This guide is part of Publishing Excel Reports to Cloud Storage.

Announce, or carry the answer A plain text notification tells people a file exists and they must open it to learn anything, while a card carrying the headline figures answers the question in the channel and links to the detail. a text notification the file exists open it to learn anything ignored within a week a summary card headline figures inline link for the detail read at a glance card the link is the fallback, not the message

Prerequisites

Bash
pip install requests pandas

An incoming webhook URL for the target channel, or a Power Automate workflow that accepts an HTTP request and posts to it. Both take the same Adaptive Card payload. Keep the URL in the environment — it is a credential, in the sense that anyone holding it can post to your channel:

Bash
export TEAMS_WEBHOOK_URL="https://…"

The simplest possible post

Python
import os
import requests

def notify(text: str) -> None:
    response = requests.post(
        os.environ["TEAMS_WEBHOOK_URL"],
        json={"text": text},
        timeout=30,
    )
    response.raise_for_status()

notify("Regional revenue report for 2026-09-01 is ready.")

Useful for a first test and inadequate as a deliverable. What a channel actually wants is the numbers and a link, which means an Adaptive Card.

An Adaptive Card carrying the report's figures

What the job posts, and where the file lives The workbook is uploaded once to the channel's own document library, and the card posted to the channel carries the summary numbers plus a link back to that single copy. publish then announce build the workbook one artefact upload to the library one copy, inherited access post the card figures plus a link one file, one link — never a copy per recipient
Python
import os
import requests
import pandas as pd

def report_card(summary: pd.DataFrame, run_date: str, link: str) -> dict:
    total = summary["Revenue"].sum()
    facts = [
        {"title": row.Region, "value": f"{row.Revenue:,.0f}"}
        for row in summary.itertuples(index=False)
    ]
    return {
        "type": "message",
        "attachments": [{
            "contentType": "application/vnd.microsoft.card.adaptive",
            "content": {
                "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
                "type": "AdaptiveCard",
                "version": "1.5",
                "body": [
                    {"type": "TextBlock", "size": "Large", "weight": "Bolder",
                     "text": f"Regional revenue — {run_date}"},
                    {"type": "TextBlock", "spacing": "None", "isSubtle": True,
                     "text": f"Total {total:,.0f} across {len(summary)} regions"},
                    {"type": "FactSet", "facts": facts},
                ],
                "actions": [
                    {"type": "Action.OpenUrl", "title": "Open the workbook", "url": link},
                ],
            },
        }],
    }

def post_card(card: dict) -> None:
    response = requests.post(os.environ["TEAMS_WEBHOOK_URL"], json=card, timeout=30)
    response.raise_for_status()

A FactSet renders as an aligned label-and-value list, which is exactly the shape of a small summary. Keeping it to a handful of rows is deliberate — a card is a notification, and a card that tries to be the report defeats the purpose of the link beneath it.

The card needs a URL, and which one depends on where the file lives. A SharePoint or OneDrive link is best for a Teams audience because permissions are already understood; a presigned S3 URL works but expires and bypasses the organisation's access controls.

Python
from datetime import timedelta

# SharePoint / OneDrive: a stable link the channel members already have access to
link = "https://contoso.sharepoint.com/sites/Finance/Shared%20Documents/regional-2026-09-01.xlsx"

# S3 alternative, if that is where reports live
import boto3
presigned = boto3.client("s3").generate_presigned_url(
    "get_object",
    Params={"Bucket": "reporting-artifacts", "Key": "reports/regional-2026-09-01.xlsx"},
    ExpiresIn=int(timedelta(days=7).total_seconds()),
)

Uploading to the channel's own document library is covered in Upload an Excel Report to SharePoint with Python, and it is the arrangement worth preferring: one copy, inherited permissions, and a link that does not expire in the middle of a quarter.

Colouring the card by outcome

A card that looks the same whether the report succeeded or failed trains people to ignore it. An Container with a style, or a leading emoji, makes the state readable at a glance.

Python
def status_card(title: str, detail: str, ok: bool) -> dict:
    return {
        "type": "message",
        "attachments": [{
            "contentType": "application/vnd.microsoft.card.adaptive",
            "content": {
                "$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
                "type": "AdaptiveCard", "version": "1.5",
                "body": [{
                    "type": "Container",
                    "style": "good" if ok else "attention",
                    "bleed": True,
                    "items": [
                        {"type": "TextBlock", "weight": "Bolder",
                         "text": f"{'✅' if ok else '⚠️'} {title}"},
                        {"type": "TextBlock", "text": detail, "wrap": True},
                    ],
                }],
            },
        }],
    }

post_card(status_card("Regional revenue", "3 of 4 regions reported; West is missing.", ok=False))

Sending the failure card from the job's exception handler is what makes this worth building — a report that quietly does not run is the failure mode a channel post is best at catching.

Building the card from the report, not by hand

The cards that stay useful are the ones generated from the same frame that produced the workbook, so that they cannot drift out of step with it. Writing one small function that turns a summary frame into card body elements keeps that guarantee.

Python
def facts_from(frame: pd.DataFrame, label: str, value: str, limit: int = 6) -> list[dict]:
    top = frame.nlargest(limit, value)
    facts = [{"title": str(row[label]), "value": f"{row[value]:,.0f}"}
             for _, row in top.iterrows()]
    remainder = len(frame) - len(top)
    if remainder > 0:
        others = frame[value].sum() - top[value].sum()
        facts.append({"title": f"{remainder} others", "value": f"{others:,.0f}"})
    return facts

Collapsing everything past the top six into an "others" row is what keeps a card readable when the number of regions grows from four to forty — and it keeps the total honest, which a simple truncation would not. The ranking behind it is the same nlargest described in RANK and PERCENTILE Formulas in pandas.

Showing movement, not just levels

A number in a channel is much more useful with a comparison beside it. Adding the change against the previous run turns the card from a statement into something a reader can react to.

Python
def facts_with_change(current: pd.DataFrame, prior: pd.DataFrame, key: str, value: str) -> list[dict]:
    merged = current.merge(prior, on=key, how="left", suffixes=("", "_prior"))
    facts = []
    for _, row in merged.iterrows():
        now, before = row[value], row.get(f"{value}_prior")
        if pd.isna(before) or not before:
            facts.append({"title": str(row[key]), "value": f"{now:,.0f} (new)"})
        else:
            change = (now - before) / before
            arrow = "▲" if change >= 0 else "▼"
            facts.append({"title": str(row[key]), "value": f"{now:,.0f}  {arrow} {abs(change):.1%}"})
    return facts

Handling the missing prior value explicitly is what stops the card showing a percentage against nothing, which is the first thing that happens when a new region appears. Reading the prior figures from the previous run's stored summary — rather than recomputing them — also makes the comparison reproducible, which matters the first time somebody asks why a number moved.

Common pitfalls

SymptomCauseFix
400 Bad Request on a valid-looking cardMissing contentType or $schemaSend the full message envelope shown above
The card renders as plain textPosted {"text": ...} rather than an attachmentUse the attachment structure
The link works for you, not for the channelA personal OneDrive link, or an expired presigned URLUpload to the channel's own library
Nothing posts and no errorThe webhook was recreated and the old URL retiredRead the URL from configuration, not a constant
The card is truncatedPayload over the size limitSummarise and link; do not inline the table
Failures are never announcedThe notifier only runs on successPost from the exception handler too

Keeping the channel usable

The main risk with automated posting is not technical. A channel that receives four identical cards every morning becomes one that nobody reads, and the report is then less visible than it was by email. Two conventions keep it useful: post the exceptional and summarise the routine, and give scheduled runs a single daily card rather than one per artefact.

Python
if failures or total_change > 0.2:
    post_card(status_card(...))          # something happened
else:
    post_card(report_card(...))          # one quiet daily summary

That distinction — a routine card and an exception card, never both — is what keeps people reading the channel six months later, which is the entire point of posting there.

Performance and scale

Posting volume against how much the channel is read A single daily summary is read consistently, a card per region is tolerated for a while, and a card per artefact is muted quickly and also runs into the connector's rate limit. one summary a day read one card per region tolerated one card per artefact muted, and throttled relative cost the technical limit and the human one point the same way

Each post is one HTTPS request and costs nothing, but Teams throttles per connector: sustained posting returns 429 with a Retry-After header, and the practical ceiling is a handful of messages a minute per channel. A job that posts per region will hit it; one that posts a single summary will never come close.

Python
import time

def post_with_retry(card: dict, attempts: int = 4) -> None:
    for attempt in range(attempts):
        response = requests.post(os.environ["TEAMS_WEBHOOK_URL"], json=card, timeout=30)
        if response.status_code != 429:
            response.raise_for_status()
            return
        time.sleep(int(response.headers.get("Retry-After", 2 ** attempt)))
    raise RuntimeError("Teams throttled the post after several attempts")

Honouring Retry-After rather than retrying immediately is the difference between recovering and being throttled harder, which is the same rule that applies to Post an Excel Report to a Slack Channel with Python.

Conclusion

Post an Adaptive Card rather than a line of text: a title, the headline figures as a FactSet, and a button linking to the workbook where it already lives. Colour the card by outcome and send one from the exception handler as well as the success path, keep the payload small by linking rather than inlining, and honour Retry-After when Teams throttles. One summary card a day stays read; four identical ones do not.

Frequently asked questions

Can I attach a file to a Teams message? Not through an incoming webhook — a webhook posts a card, not an attachment. Upload the workbook to the channel's SharePoint folder or to blob storage, then post a card containing a link to it. That is also better practice, since the file then has one location rather than many copies.

Are Office 365 connectors being retired? Microsoft has been steering integrations towards Power Automate workflows and Graph. A Workflows-based endpoint accepts the same Adaptive Card payload, so the card you build here survives the migration even though the URL changes.

What is the message size limit? Around 28 KB for an Adaptive Card payload. A card summarising a report will not approach that; a card that embeds a table of a thousand rows will, which is a good reason to link rather than inline.

How do I mention someone? Adaptive Cards support mentions through an msteams entities block naming the user's Entra ID. It is worth reserving for genuine exceptions — a card that mentions the whole team every morning becomes noise within a week.