Post an Excel Report to a Slack Channel with Python
Email is not where teams read reports any more. Posting the workbook into the channel where the conversation already happens — with a two-line summary of the numbers above it — is often the whole difference between a report that gets read and one that sits in an inbox. Slack's API does this in a few lines, and the operational details are the familiar ones: scopes, channel membership, secrets and retries. This guide uploads a workbook, adds a summary message, and makes the delivery reliable in a scheduled job. It belongs to Publishing Excel Reports to Cloud Storage.
Prerequisites
pip install slack-sdk pandas xlsxwriter
Create a Slack app, give its bot token the files:write and chat:write scopes, install it to the workspace, and invite it to the target channel with /invite @yourbot. Scopes and membership are separate — having the first without the second produces not_in_channel, which is the most common first failure.
Upload the workbook
"""Post a report file to a channel."""
import os
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
client = WebClient(token=os.environ["SLACK_BOT_TOKEN"])
try:
response = client.files_upload_v2(
channel="C0123456789", # channel id, not name
file="weekly_sales.xlsx",
filename="weekly_sales_2026-08-27.xlsx",
title="Weekly sales — week ending 27 Aug 2026",
initial_comment="Weekly sales summary attached.",
)
print("uploaded:", response["file"]["permalink"])
except SlackApiError as exc:
print("upload failed:", exc.response["error"])
Use the channel id (the C… string from the channel's details) rather than the name. Names change; ids do not, and a renamed channel silently breaks a job that referenced #sales-reports.
Upload from memory
Since the workbook is usually built in the same process, skip the disk entirely:
import io
import pandas as pd
from slack_sdk import WebClient
df = pd.DataFrame({"region": ["North", "South"], "revenue": [128_400.5, 96_220.0]})
buffer = io.BytesIO()
with pd.ExcelWriter(buffer, engine="xlsxwriter") as writer:
df.to_excel(writer, index=False, sheet_name="Summary")
buffer.seek(0)
client.files_upload_v2(
channel=os.environ["SLACK_CHANNEL_ID"],
file=buffer.getvalue(),
filename="weekly_sales.xlsx",
title="Weekly sales",
)
The buffer rules are the same as anywhere else — close the writer before reading, and rewind — as covered in Build an Excel workbook in memory with BytesIO.
Put the numbers in the message
An attachment nobody opens has delivered nothing. Compute the headline figures and post them as the message, with the file underneath as the detail:
def summary_text(df: pd.DataFrame, previous_total: float) -> str:
total = float(df["revenue"].sum())
change = (total - previous_total) / previous_total if previous_total else 0.0
leader = df.loc[df["revenue"].idxmax(), "region"]
below = int((df["revenue"] < df["target"]).sum())
return (
f"*Weekly sales — week ending {run_date:%d %b %Y}*\n"
f"Revenue *{total:,.0f}* ({change:+.1%} on last week) · "
f"{leader} leads · {below} region(s) below target"
)
client.files_upload_v2(
channel=channel_id,
file=buffer.getvalue(),
filename=f"weekly_sales_{run_date:%Y-%m-%d}.xlsx",
initial_comment=summary_text(df, previous_total),
)
Slack's mrkdwn uses single asterisks for bold and backticks for code, not Markdown's conventions. A number formatted as 350,656 reads far better in a channel than 350656.4499999999, so format deliberately.
Thread the detail under the summary
For a report with several files — one per region, say — post the summary first and attach the rest to its thread, so the channel shows one item instead of nine:
posted = client.chat_postMessage(channel=channel_id, text=summary_text(df, previous_total))
thread_ts = posted["ts"]
for region, region_df in df.groupby("region"):
client.files_upload_v2(
channel=channel_id,
thread_ts=thread_ts,
file=build_workbook(region_df),
filename=f"{region.lower()}_{run_date:%Y-%m-%d}.xlsx",
title=f"{region} detail",
)
Threading keeps a busy channel readable and gives the report a single place for questions. The per-group generation pattern is in Generate one Excel report per region in a loop.
Handle rate limits and failures
Slack rate-limits and occasionally returns transient errors, so a scheduled job needs a retry:
from slack_sdk import WebClient
from slack_sdk.http_retry.builtin_handlers import RateLimitErrorRetryHandler
client = WebClient(token=os.environ["SLACK_BOT_TOKEN"])
client.retry_handlers.append(RateLimitErrorRetryHandler(max_retry_count=3))
The SDK's handler reads the Retry-After header and waits the interval Slack asks for, which is better behaved than a fixed backoff. Wrap the whole delivery so a Slack outage does not lose a report that was successfully generated — write the file to storage first, then post; if the post fails, the report still exists and the retry is cheap.
Keep the token safe
A bot token grants everything its scopes allow, to anyone holding it:
import os
token = os.environ.get("SLACK_BOT_TOKEN")
if not token:
raise SystemExit("SLACK_BOT_TOKEN is not set")
Read it from the environment, keep it out of logs — never interpolate it into a message or an error string — and rotate it on the same cadence as any other credential. If the job runs in CI, use the platform's secret store rather than a repository variable.
Post a link instead of a file when the workbook is large
Slack enforces a per-file size limit that depends on the plan, and a large workbook is unpleasant to download in a chat client anyway. Upload it to storage and post the link:
url = upload_to_s3(buffer.getvalue(), key=f"reports/weekly_{run_date:%Y-%m-%d}.xlsx")
client.chat_postMessage(
channel=channel_id,
text=f"{summary_text(df, previous_total)}\n<{url}|Download the full workbook>",
)
Use a signed, time-limited URL so the link expires — the details are in Upload an Excel report to Amazon S3 with boto3.
Put the delivery behind one function
Slack is one destination among several, and a reporting job is easier to change when the delivery step has a single shape regardless of where the file goes:
"""delivery.py — one interface, several destinations."""
import os
from dataclasses import dataclass
from slack_sdk import WebClient
@dataclass
class Report:
filename: str
payload: bytes
summary: str
def deliver_to_slack(report: Report, channel: str) -> str:
client = WebClient(token=os.environ["SLACK_BOT_TOKEN"])
response = client.files_upload_v2(
channel=channel,
file=report.payload,
filename=report.filename,
initial_comment=report.summary,
)
return response["file"]["permalink"]
With the report reduced to bytes plus a summary, adding an email or an S3 destination is another function of the same shape, and the job's main flow reads as generate, validate, deliver. That structure also makes the delivery step trivial to stub in a test, so a change to the report's contents can be tested without posting to a real channel.
Common pitfalls and gotchas
not_in_channel. Invite the bot; scopes are not membership.- Channel names instead of ids. A rename breaks the job silently.
- Markdown that is not
mrkdwn. Slack uses*bold*, not**bold**. - Tokens in logs. Never include the token in an error message or a debug print.
- Posting before the file exists. Generate and store first, then announce — a message linking to a file that failed to upload is worse than no message.
Performance and scale notes
The upload is I/O-bound and small: a typical report workbook posts in well under a second. What needs care is fan-out — posting to twenty channels sequentially runs into rate limits, so batch with the SDK's retry handler in place and accept that the whole delivery takes a minute rather than firing everything at once. If several teams need the same report, prefer one post to a shared channel plus links over twenty separate uploads; it is faster, cheaper and leaves one place for the discussion. For alerting on a failed run rather than delivering a report, a lightweight message is enough — see Retry a failed Excel report job in Python.
Conclusion
Posting a report to Slack is one API call, but the version worth shipping does three things: it computes the headline numbers and puts them in the message, it attaches the workbook for whoever wants the detail, and it fails safely — storing the file before announcing it, retrying on rate limits, and keeping the token in the environment. Use channel ids, invite the bot, and switch to a signed link once the workbook outgrows a chat attachment.
Frequently asked questions
Which Slack scopes does the bot need?files:write to upload, and chat:write to post the accompanying message. Add the bot to the target channel as well — scopes alone do not grant access to a private channel.
Why does my upload return not_in_channel?
The bot is not a member of the channel. Invite it with /invite @yourbot, or post to a channel it already belongs to. This is separate from the OAuth scopes.
Should I use a webhook or the API? A webhook can post messages but cannot upload files. Use a bot token with the Web API for anything that attaches a workbook.
Can I upload from memory rather than a file?
Yes. The upload call accepts a bytes object or a file-like object, so a workbook built in BytesIO never has to touch disk.
What happens if the report is large? Slack enforces a per-file size limit that varies by plan. For a large workbook, upload it to object storage and post a link instead of the file itself.
Related
- Up: Publishing Excel Reports to Cloud Storage — the other destinations a finished workbook reaches.
- Upload an Excel report to Amazon S3 with boto3 — where a large workbook should live before you link to it.
- Email an Excel report with an HTML summary body — the same summary-plus-attachment idea over email.
- Build an Excel workbook in memory with BytesIO — producing the bytes this upload sends.
- Validate an Excel report before sending it — the check that belongs immediately before the post.