How to Send Experiment Results (Text and Images) to Slack with Python

Learn how to send text messages and images to Slack using Python with Incoming Webhooks, including rate limits, payload size limits, and webhook URL security.

When long-running tasks such as machine learning experiments finish, it is very useful to send notifications with the results to Slack. This article explains how to send text messages and images to Slack using Python, and also covers edge cases that are easy to overlook in production use — rate limiting, payload size limits, and the security risk of leaking a webhook URL — based on Slack’s official documentation.

On the scope of verification in this article: the setup steps and screenshots in Sections 1 and 2 (fig1–9) were captured by actually sending notifications to a real Slack workspace. The rate-limiting behavior (Section 3.1) and oversized-payload behavior (Section 3.2), however, are quoted from Slack’s official documentation rather than measured, because no test Slack workspace or webhook URL is available in the environment this article was written in. This distinction is called out explicitly below.

How Incoming Webhooks Work at the Protocol Level

An Incoming Webhook lets you post a message simply by sending an HTTP POST request to a unique URL that Slack issues in advance (in the form https://hooks.slack.com/services/T000000/B000000/XXXXXXXXXXXXXXXXXXXXXXXX). The critical detail is that this URL itself is the credential. Unlike a regular Slack Web API call such as chat.postMessage, you don’t attach a separate token in an Authorization header — simply knowing the random string at the end of the URL path is treated by Slack as proof that you’re authorized to post to that channel. In other words, a webhook URL is “a bearer token embedded directly in an endpoint,” and leaking it lets a third party impersonate that integration and post arbitrary content (see Section 3.3).

The request/response shape is as follows:

  • Request: POST / Content-Type: application/json / a JSON body whose top level has text (a plain-text fallback), blocks (Block Kit layout array), attachments (legacy secondary-content array), or a combination of these
  • Success response: HTTP 200 with a body that is plain text ok (not JSON)
  • Error response: an HTTP 4xx status with a plain-text error code such as invalid_payload, channel_not_found, or no_service
  • Rate-limit response: HTTP 429 Too Many Requests with a Retry-After header (detailed in Section 3.1)

Incoming Webhook request/response flow

1. Sending Text (Incoming Webhook)

Incoming Webhooks are the simplest way to post messages to Slack from external applications. By sending an HTTP request to a specific URL, you can post messages to a designated channel.

Slack Configuration

  1. In Slack, go to “Settings & administration” and select “Manage apps.” Slack settings and administration menu

  2. Search for “Incoming Webhooks” in the App Directory and add it to Slack. Searching for Incoming Webhooks in App Directory Adding Incoming Webhooks to Slack

  3. After clicking “Add to Slack,” select the channel where you want to post messages and click “Add Incoming Webhooks integration.” Selecting the target channel for webhook

  4. Copy the generated “Webhook URL.” Be careful not to expose this URL publicly. Generated Webhook URL display

  5. On the configuration page, you can also customize the notification icon and bot name. Customizing webhook icon and bot name

Python Code

The slackweb library makes it easy to implement notifications.

pip install slackweb
import slackweb

# Set the Webhook URL copied from the configuration
slack = slackweb.Slack(url="YOUR_WEBHOOK_URL")

def notify_text(title, text, color):
    """
    Sends a text notification to Slack.

    :param title: Message title
    :param text: Message body
    :param color: Color of the left border ('good', 'warning', 'danger', or hex color code)
    """
    attachments = [{
        "title": title,
        "text": text,
        "color": color,
        "footer": "Sent from Python Script",
    }]
    slack.notify(attachments=attachments)

# --- Examples ---
notify_text("Experiment Complete", "Model A training finished.", "good")
notify_text("Warning", "Disk space is running low.", "warning")
notify_text("Error", "An exception occurred during training.", "danger")
  • Note: Slack’s official documentation now positions attachments-based formatting as “legacy.” It has not been formally declared deprecated, but the docs explicitly state that “these legacy options may be subject to reductions in visibility or functionality” going forward, and recommend Block Kit for new implementations (see Section 4).

Result

Slack text notification result

The attachments example above is kept in its legacy form so it matches the fig7 screenshot, but for new implementations it’s better to skip slackweb and POST a Block Kit JSON payload directly with requests — this is less exposed to future changes in the legacy format.

import requests

def notify_block_kit(webhook_url, title, text, emoji=":white_check_mark:"):
    """
    Sends a text notification to Slack using Block Kit (currently recommended).

    :param webhook_url: The Incoming Webhook URL
    :param title: Title shown as a header block
    :param text: Body text (mrkdwn format — supports *bold* and `code`)
    :param emoji: Slack emoji code prefixed to the text
    """
    payload = {
        "blocks": [
            {
                "type": "header",
                "text": {"type": "plain_text", "text": f"{title}", "emoji": True},
            },
            {
                "type": "section",
                "text": {"type": "mrkdwn", "text": f"{emoji} {text}"},
            },
        ]
    }
    response = requests.post(webhook_url, json=payload, timeout=5)
    response.raise_for_status()  # non-200 responses (e.g. 429) raise here
    return response

# --- Example ---
WEBHOOK_URL = "YOUR_WEBHOOK_URL"
notify_block_kit(WEBHOOK_URL, "Experiment Complete", "Model A training finished.")

2. Sending Images (files.upload API → files_upload_v2)

To send image files such as experiment result graphs, you need a different authentication credential (an API token) than Incoming Webhooks, together with Slack’s file-upload functionality.

Important API change (2024–2025): The files.upload method originally shown in this article, according to Slack’s official changelog, could no longer be used by newly-created Slack apps as of May 16, 2024, and was fully retired for all apps, including existing ones, on November 12, 2025. That means the earlier requests.post(url="https://slack.com/api/files.upload", ...) implementation no longer works as of the time this article was updated (2026). The official replacement is a two-step call to files.getUploadURLExternal and files.completeUploadExternal, and the Python SDK (slack_sdk) provides a convenience wrapper for this called files_upload_v2. The code below has been updated to this current approach.

Slack Configuration

  1. Go to the Slack API site and create a new app under “Create New App.”
  2. Navigate to “OAuth & Permissions” in the app management page.
  3. In the “Scopes” section under “Bot Token Scopes,” add the “files:write” scope. This grants the app permission to upload files.
  4. Click “Install to Workspace” at the top of the page to install the app and authorize it.
  5. After installation, the “Bot User OAuth Token” will be displayed. Copy this token (it usually starts with xoxb-). Keep this token secure and do not expose it publicly.
  6. Note the channel ID (e.g. C0123456789, not a name like #general) of the target channel ahead of time. files_upload_v2 only accepts channel IDs, not channel names.

Python Code

Install and use the official Python SDK (slack_sdk).

pip install slack_sdk
import os
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError

# Load the Bot User OAuth Token from an environment variable — never hardcode it (see Section 3.3)
client = WebClient(token=os.environ["SLACK_BOT_TOKEN"])

def notify_image(channel_id, title, image_path):
    """
    Uploads an image to Slack (files_upload_v2 version).

    :param channel_id: Target channel ID (e.g., 'C0123456789'; '#name' is not accepted)
    :param title: Image title
    :param image_path: File path of the image to upload
    """
    try:
        response = client.files_upload_v2(
            channel=channel_id,
            file=image_path,
            title=title,
        )
        return response["file"]
    except SlackApiError as e:
        raise RuntimeError(f"Upload failed: {e.response['error']}") from e

# --- Example ---
CHANNEL_ID = "C0123456789"  # Target channel ID
IMAGE_FILE_PATH = "test.png"  # Image to send

notify_image(CHANNEL_ID, "Training Results Graph", IMAGE_FILE_PATH)
  • Note: Internally, files_upload_v2 automates three steps: files.getUploadURLExternal (issue an upload URL) → PUT the file contents → files.completeUploadExternal (signal completion). This means more round trips than the old single-request files.upload, but it’s designed to upload large files more reliably.

Result

Slack image upload result 1 Slack image upload result 2

(The screenshots above were captured using the old files.upload flow, but the appearance in the Slack channel is unchanged when using files_upload_v2.)

3. Operational Edge Cases

Notifications at the frequency of a single “experiment finished” ping rarely hit any limits, but for CI/CD pipelines or monitoring systems that send notifications more frequently or in bulk, the following constraints matter. The figures in this section are based on Slack’s official documentation as of July 2026; this article’s working environment has no test Slack workspace or webhook URL available, so the rate-limiting and oversized-payload behavior below has not been measured against a live Slack API. Always check the official documentation for current figures before implementing.

3.1 Rate Limiting

According to Slack’s official rate-limits documentation, Incoming Webhooks are restricted to “1 request per second” per channel, with short bursts of more than one request tolerated to some degree. Exceeding this limit produces a response like:

HTTP/1.1 429 Too Many Requests
Retry-After: 30

The Retry-After header carries the number of seconds to wait before the next request. Code that sends bulk notifications should honor this header as its standard retry strategy.

import time
import requests

def notify_with_retry(webhook_url, payload, max_retries=3):
    """
    Sends a notification to an Incoming Webhook with retries.
    On HTTP 429, waits for the number of seconds in the Retry-After header,
    plus an additional exponential-backoff factor based on the attempt count,
    before retrying.

    :param webhook_url: The Incoming Webhook URL
    :param payload: The JSON payload to send (Block Kit/attachments)
    :param max_retries: Maximum number of retry attempts
    """
    for attempt in range(max_retries):
        response = requests.post(webhook_url, json=payload, timeout=5)
        if response.status_code == 200:
            return response
        if response.status_code == 429:
            retry_after = int(response.headers.get("Retry-After", "1"))
            wait_sec = retry_after * (2**attempt)
            time.sleep(wait_sec)
            continue
        response.raise_for_status()  # any other non-200 raises here
    raise RuntimeError(f"Failed to notify Slack after {max_retries} retries.")

3.2 Payload Size Limits

  • The text field of a message is truncated by Slack beyond 40,000 characters. This is the current limit after a series of reductions rolled out through 2018 (originally 500,000, then 200,000, then 100,000, finally settling at 40,000 characters). In practice, staying to a few thousand characters is safer to avoid rendering issues.
  • When using legacy attachments, a single message is capped at 20 attachments, the footer field is capped at 300 characters, and attached images are automatically shrunk once they exceed 360px width or 500px height.
  • Message text beyond roughly 700 characters or 5+ line breaks gets collapsed behind a “Show more” link in the Slack UI.

Exceeding these limits generally does not produce an HTTP error — Slack silently truncates or collapses the content instead. As noted above, this article has not verified the actual response for an over-limit payload; treat the figures above as documented specification, not measured behavior.

3.3 Webhook URL Leakage as a Security Risk

An Incoming Webhook URL is, by itself, the credential. If this URL is embedded in front-end JavaScript or accidentally committed hardcoded into a public GitHub repository, anyone who obtains it can post arbitrary content to that channel (they cannot read channel content, but impersonation and phishing abuse are both possible).

This is not a purely theoretical risk. In April 2024, GitHub added a “validity check” (verifying whether a leaked URL is still live) to its Secret Scanning detection for Slack webhooks. The fact that a dedicated detection-and-validation feature exists reflects that accidental commits of Slack webhook URLs to public repositories are a recognized, recurring real-world problem.

Standard mitigations:

  1. Store it in an environment variable or secret manager, never hardcoded in source or committed to a repository (in production, replace the "YOUR_WEBHOOK_URL" placeholders in this article’s code with something like os.environ["SLACK_WEBHOOK_URL"])
  2. Never embed it in client-side (browser) code. If the URL is reachable from code that runs in a browser, it is effectively public
  3. Call it through a server-side proxy. The front end calls your own backend API, and the backend holds the webhook URL and forwards the request to Slack
  4. Revoke and regenerate immediately if leaked. Regenerating the URL from Slack’s “Incoming Webhooks” configuration screen instantly invalidates the old one

4. Recent Developments: Block Kit vs. Legacy Attachments

The attachments-based formatting used in Section 1 is explicitly labeled “legacy” on Slack’s official “Legacy secondary message attachments” documentation page. It has not been formally deprecated, but the docs state plainly that “these legacy options may be subject to reductions in visibility or functionality” in the future, and strongly recommend Block Kit layout blocks (blocks) for new implementations. attachments and blocks can be combined in the same message — in that case, the blocks nested inside attachments are treated as secondary supplementary content.

This article’s code intentionally keeps the legacy format to match the real screenshot (fig7), but for new implementations, use the Block Kit version of notify_block_kit introduced in Section 1.

References

Official Documentation

Other