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.
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.
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
- You are building an app that reacts to events, slash commands, or interactive components: this SDK deliberately does not route them, and slack-bolt is Slack's own framework that adds the listener layer on top of this package
- You only need to post to one channel: an incoming webhook plus httpx is about four lines, and you avoid managing a bot token and its scopes entirely
- You expect the SDK to shield you from Slack's own churn: the retirement of files.upload forced everyone onto files_upload_v2 with a different argument shape, and no client library can version away a server-side removal
- You want rate limiting handled by default: the only retry handler installed out of the box covers connection errors, so a 429 raises SlackApiError until you pass RateLimitErrorRetryHandler yourself
- You want static checking of endpoint arguments: the generated methods list common parameters explicitly but each one also accepts **kwargs, so a misspelled option sails past your type checker and comes back as an API error at runtime
- You are looking at slack_sdk.rtm: the RTM API is legacy, Slack points new apps at the Events API, and building on it now is building on something already on the way out
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
raiseThis 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
| Package | Registry | Pick it when |
|---|---|---|
| slack-bolt | PyPI | Your app listens as well as talks: events, slash commands, modals, and interactivity with routing and middleware built in |
| httpx | PyPI | All you do is POST JSON to an incoming webhook URL and a full SDK plus bot token is more setup than the task deserves |
| apprise | PyPI | You send the same notification to Slack plus email, Discord, or PagerDuty and want one interface across all of them |