mrkeyoor.com_
Thu 06 Aug 07:43 UTC
PyPIWeb Backendupdated 06 Aug 2026

slack-sdk

slack-sdk is Slack's official Python client, and it is really a bundle of small independent packages under one import root. slack_sdk.web gives you WebClient, a typed method per Slack Web API endpoint, so chat.postMessage becomes client.chat_postMessage(channel=..., text=...). slack_sdk.webhook posts to incoming webhook URLs and response_urls. slack_sdk.signature verifies that a request really came from Slack. slack_sdk.socket_mode opens a WebSocket so an app behind a firewall can receive events without a public URL. slack_sdk.oauth holds installation and state stores for multi-workspace distribution, slack_sdk.models builds Block Kit payloads as Python objects, and slack_sdk.audit_logs and slack_sdk.scim cover the enterprise admin APIs. Every piece works alone. The sync client uses urllib from the standard library and pulls in nothing; async support is a parallel AsyncWebClient that needs aiohttp.

Verdict

The default and correct choice for talking to Slack from Python: official, one method per endpoint, and small enough that the sync path has no dependencies at all. Add slack-bolt if your app also has to listen, and remember to install a rate-limit retry handler because the SDK does not add one for you.

API stability4/5The v3 surface has held since 2020 and releases are additive, tracking new Slack endpoints. Points come off because the SDK's stability is bounded by Slack's: files_upload_v2 replaced a retired endpoint with a different argument shape, and slack_sdk.rtm wraps an API Slack itself calls legacy.
Docs4/5docs.slack.dev carries per-module guides, a v2-to-v3 migration guide, and a ten-minute tutorial in the repo, and the README covers sync, async, SSL, and proxy setup with runnable code. The weak spot is that scope requirements and rate limit tiers live in the platform docs rather than next to the method you are calling.
Maintenance5/5Maintained by Slack, pushed August 2026, released 3.43.0 in June 2026, with 44 open issues (56 counting PRs) against a surface that covers every Slack API. Issues get triaged by Slack staff.
Ecosystem5/5Around 24.5M downloads a week, and it is the layer slack-bolt for Python is built on, so most Slack app code depends on it whether directly or not. Block Kit models, OAuth stores, SCIM and audit log clients mean there is rarely a reason to reach outside it.

Use it if

  • You are calling Slack Web API methods from a backend job, a cron task, or a deploy script and want one function per endpoint with real keyword arguments instead of hand-rolled requests calls
  • You need to verify inbound Slack requests: SignatureVerifier implements the v0 signature and timestamp check correctly, which is easy to get subtly wrong yourself
  • You are distributing an app across many workspaces and want the OAuth flow, installation store, and state store already written, with a SQLAlchemy backend included
  • You want async and sync from the same library: AsyncWebClient mirrors WebClient method for method, so the call sites look identical
  • You need Socket Mode to receive events without exposing a public HTTPS endpoint, and the built-in client does it with no extra dependency
Skip it if

Setup reality

pip install slack_sdk installs a pure-Python wheel with zero required dependencies, because the sync client is built on urllib. Everything else is an extra. Async needs aiohttp, the non-built-in Socket Mode clients need websocket-client or websockets, the SQLAlchemy installation store needs SQLAlchemy 2.0.49 or newer, the S3 stores need boto3, and faster async DNS needs aiodns; pip install slack_sdk[optional] pulls the lot. Package metadata still declares Python 3.7 and up, which is a long way behind what anyone should be running. The install is the easy half. The hard half is Slack: you create an app, add the specific OAuth scopes each method needs (chat:write, files:write, channels:read and so on), install it to a workspace, and invite the bot to every channel it posts in or add chat:write.public. Scope mistakes surface as a SlackApiError with error 'missing_scope' at runtime and require a reinstall of the app to fix. Rate limits are per method and per workspace, and Slack returns 429 with a Retry-After header the SDK will not honour unless you ask it to.

Patterns

Send a message as your botpost-a-message

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

client = WebClient(token=os.environ["SLACK_BOT_TOKEN"])

try:
    resp = client.chat_postMessage(channel="C0123456789", text="Deploy finished")
    print(resp["ts"])
except SlackApiError as e:
    print(e.response["error"], e.response.status_code)

Pass a channel ID, not a #name: names get resolved server side and break when a channel is renamed. Always send text even when you send blocks, because text is what appears in notifications and accessibility clients.

Read what actually went wronghandle-api-errors

try:
    client.chat_postMessage(channel=channel_id, text=body)
except SlackApiError as e:
    code = e.response["error"]
    if code == "missing_scope":
        needed = e.response.get("needed")
        raise RuntimeError(f"Reinstall the app with scope: {needed}")
    if code == "not_in_channel":
        client.conversations_join(channel=channel_id)
        client.chat_postMessage(channel=channel_id, text=body)

SlackApiError is raised whenever the response body has ok: false, and e.response behaves like a dict plus a status_code. missing_scope and not_in_channel are the two failures every new Slack integration hits first.

Install a rate-limit retry handlerretry-on-rate-limit

from slack_sdk import WebClient
from slack_sdk.http_retry.builtin_handlers import RateLimitErrorRetryHandler

client = WebClient(token=token)
client.retry_handlers.append(RateLimitErrorRetryHandler(max_retry_count=3))

By default the client installs only a connection-error handler, so a 429 raises straight through. This handler sleeps for the duration Slack sends in Retry-After. Retry counts multiply your worst-case latency, so keep the count low on request-handling paths.

Walk a cursor-paginated endpointpaginate-results

channels = []
for page in client.conversations_list(limit=200, exclude_archived=True):
    channels.extend(page["channels"])

SlackResponse is itself an iterator: each step follows response_metadata.next_cursor and refetches. Iterating stops with StopIteration when the cursor is empty. Without the loop you silently get only the first page, which is the classic bug in scripts that audit a workspace.

Upload a file to a channelupload-a-file

resp = client.files_upload_v2(
    channel="C0123456789",
    file="./report.csv",
    title="Nightly report",
    initial_comment="Numbers for <!date^1735689600^{date}|today>",
)
print(resp["file"]["id"])

files_upload_v2 replaced the retired files.upload and takes a single channel, not the old channels list. It needs the files:write scope, and the bot must already be a member of the channel.

Check that a request really came from Slackverify-request-signature

import os
from slack_sdk.signature import SignatureVerifier

verifier = SignatureVerifier(os.environ["SLACK_SIGNING_SECRET"])

def handle(request):
    if not verifier.is_valid_request(request.get_data(), dict(request.headers)):
        return "invalid signature", 401
    ...

Pass the raw request body, before any JSON or form parsing, or the computed signature will not match. The verifier also rejects requests whose X-Slack-Request-Timestamp is more than five minutes old, which is what stops replay attacks.

Use an incoming webhook or a response_urlpost-to-webhook

from slack_sdk.webhook import WebhookClient

webhook = WebhookClient(os.environ["SLACK_WEBHOOK_URL"])
resp = webhook.send(text="Build #482 passed")
assert resp.status_code == 200 and resp.body == "ok"

Webhook responses are the plain string 'ok', not JSON, so check status_code and body rather than treating it like a WebClient response. The same client posts to the response_url from an interaction payload, where response_type='ephemeral' keeps the reply visible only to the person who clicked.

Compose Block Kit UI with typed objectsbuild-block-kit

from slack_sdk.models.blocks import SectionBlock, ActionsBlock, ButtonElement
from slack_sdk.models.blocks import MarkdownTextObject

blocks = [
    SectionBlock(text=MarkdownTextObject(text="*Deploy 4.2.0* is ready")),
    ActionsBlock(elements=[
        ButtonElement(text="Ship it", action_id="ship", value="4.2.0", style="primary"),
    ]),
]

client.chat_postMessage(channel=channel_id, blocks=blocks, text="Deploy 4.2.0 is ready")

The model classes validate lengths and required fields locally and raise SlackObjectFormationError before you spend a round trip. Raw dicts work equally well if you prototyped the layout in Block Kit Builder.

Call Slack from async codeasync-client

import asyncio
from slack_sdk.web.async_client import AsyncWebClient

client = AsyncWebClient(token=os.environ["SLACK_BOT_TOKEN"])

async def notify(channels: list[str], text: str):
    await asyncio.gather(*(
        client.chat_postMessage(channel=c, text=text) for c in channels
    ))

AsyncWebClient needs aiohttp, which is not a required dependency; install slack_sdk[optional] or add aiohttp yourself. Fanning out with gather is easy to overdo, because Slack's per-method rate limits still apply and you will hit 429 faster than with the sync client.

Receive events without a public URLsocket-mode

from slack_sdk.socket_mode import SocketModeClient
from slack_sdk.socket_mode.response import SocketModeResponse

sm = SocketModeClient(app_token=os.environ["SLACK_APP_TOKEN"], web_client=client)

def on_request(client, req):
    client.send_socket_mode_response(SocketModeResponse(envelope_id=req.envelope_id))
    if req.type == "events_api":
        handle_event(req.payload["event"])

sm.socket_mode_request_listeners.append(on_request)
sm.connect()

Acknowledge the envelope first and do the work after, because Slack retries anything not acknowledged within three seconds. The app-level token starts with xapp- and is separate from your bot token. The default client is pure Python; the aiohttp, websockets, and websocket_client variants need their libraries installed.

Map an email address to a Slack userlookup-user-by-email

def slack_id_for(email: str) -> str | None:
    try:
        return client.users_lookupByEmail(email=email)["user"]["id"]
    except SlackApiError as e:
        if e.response["error"] == "users_not_found":
            return None
        raise

This needs users:read.email, a scope that is separate from users:read and often forgotten. Do not fall back to scanning users_list for a match: on a large workspace that is dozens of paginated calls and will get you rate limited.

Reply in a thread and update the parentthread-a-reply

parent = client.chat_postMessage(channel=channel_id, text="Running migration...")

client.chat_postMessage(
    channel=channel_id, thread_ts=parent["ts"], text="Step 1/3 done",
)
client.chat_update(
    channel=channel_id, ts=parent["ts"], text="Migration complete",
)

thread_ts must be the ts of the top-level message; passing a reply's ts flattens the thread. chat_update needs the channel ID plus ts and rewrites the message in place, which is much quieter than posting a progress update every few seconds.

Alternatives

PackageRegistryPick it when
slack-boltPyPIYour app listens as well as talks: events, slash commands, modals, and interactivity with routing and middleware built in
httpxPyPIAll you do is POST JSON to an incoming webhook URL and a full SDK plus bot token is more setup than the task deserves
apprisePyPIYou send the same notification to Slack plus email, Discord, or PagerDuty and want one interface across all of them