Post an Excel Report to Microsoft Teams with Python
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.
Prerequisites
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:
export TEAMS_WEBHOOK_URL="https://…"
The simplest possible post
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
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.
Getting a link people can actually open
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.
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.
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.
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.
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
| Symptom | Cause | Fix |
|---|---|---|
400 Bad Request on a valid-looking card | Missing contentType or $schema | Send the full message envelope shown above |
| The card renders as plain text | Posted {"text": ...} rather than an attachment | Use the attachment structure |
| The link works for you, not for the channel | A personal OneDrive link, or an expired presigned URL | Upload to the channel's own library |
| Nothing posts and no error | The webhook was recreated and the old URL retired | Read the URL from configuration, not a constant |
| The card is truncated | Payload over the size limit | Summarise and link; do not inline the table |
| Failures are never announced | The notifier only runs on success | Post 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.
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
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.
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.
Related
- Up one level: Publishing Excel Reports to Cloud Storage — where the file the card links to should live.
- Post an Excel Report to a Slack Channel with Python — the same pattern with a real file upload.
- Upload an Excel Report to SharePoint with Python — putting the workbook where the channel can already reach it.
- Validate an Excel Report Before Sending It — deciding which card to post.
- Send an Excel Report with the Microsoft Graph API — the email half of the same tenant integration.