
INTEGRATION GUIDE
Slack OAuth in Python: Bot vs User Tokens
The full v2 flow in runnable slack_sdk code, bot versus user tokens decided with a table, token rotation, the scope mistakes that cost a day, and the real rate-limit numbers.
TL;DR
The decision that shapes a Slack integration is not the OAuth flow. It is bot token or user token, and you make it by writing the scope list, before any code runs. Get it wrong and the fix is a reinstall across every workspace you have.
The rule underneath it is simple. A bot token is your app. A user token "represent[s] the same access a user has to a workspace", so it sees private channels and DMs that person can see, and it dies with their account. Slack's own guidance is that "using bot tokens is usually for the best."
The flow itself is four steps and slack_sdk ships helpers for three of them. What breaks is procedural:
a state value never checked, a code exchanged after its ten-minute window, chat:write requested when
the bot needs chat:write.public, or channels:history requested for a private channel that wanted
groups:history.
None of that is cryptography. It is reading the scope reference before writing the authorize URL, and
handling three error strings: missing_scope, not_in_channel, and a 429 with Retry-After.
Overview
Take a release-notes bot. It reads a private engineering channel, posts a summary into a public channel it
has never joined, and renames itself per repo. Three sentences, and each one is a different scope:
groups:history for the private channel, chat:write.public for the channel it is not in,
chat:write.customize for the name. Ask for chat:write and channels:history instead, which is what
most first drafts do, and all three fail with errors that do not obviously point at scopes.
That is the real difficulty with Slack. The Web API is forgiving and the Python SDK is thin. The cost sits in decisions made before the first request: which token type, which scopes, which transport, and whether the app opted into rotation. We walk the exchange once in full, then spend the rest of the post on the decisions, because the decisions are what send you back to a reinstall.
Refresh mechanics and invalid_grant diagnosis are covered separately in
OAuth refresh tokens, and storage in
how to store OAuth tokens securely.
The code is not the token. What lands on your redirect URI is good for exactly one exchange, and Slack's docs put the window at ten minutes. Treat it like a claim ticket.
The Exchange, Step by Step
Slack uses the standard OAuth 2.0 authorization code grant. Four things happen in order.
First, send the user to https://slack.com/oauth/v2/authorize with client_id, scope (a comma
separated list of bot scopes), optionally user_scope for user-token scopes, redirect_uri, and a
state value you generated. Bot scopes and user scopes are separate parameters, and that split is the
first place people lose an afternoon.
Second, Slack redirects back to your redirect_uri with code and the state echoed. Slack's guidance
on that comparison is blunt: "If it doesn't match what you sent, consider the authorization a forgery."
The redirect_uri has rules too. It must be HTTPS, must match a Redirect URL configured in App
Management, must be identical in both the authorize and the access steps when you have more than one
configured, and cannot contain an anchor.
Third, your backend POSTs to https://slack.com/api/oauth.v2.access with code, client_id,
client_secret and redirect_uri. This is server to server, because it carries the client secret.
Fourth, you store what comes back. The response carries access_token, token_type, scope,
bot_user_id, app_id, team, authed_user, is_enterprise_install, and, if your app enabled
rotation, refresh_token and expires_in. The user token, when you asked for user scopes, is nested
inside authed_user, not at the top level.
Figure 1 — The v2 flow. The two lines that get skipped are issuing and consuming state, and the last one, persisting the installation under a key that survives an Enterprise Grid install.
If the exchange fails, Slack names the reason: invalid_code for a code that is wrong or already spent,
bad_redirect_uri for a mismatch, bad_client_secret, invalid_grant_type, and
invalid_refresh_token. The one that confuses people is invalid_code on a code that looks fine. Almost
always the handler queued the exchange, retried after a timeout, or sat behind a slow page render.
The Redirect Handler in Python
slack_sdk ships the pieces for this, and they are worth using rather than reimplementing. Start with the
authorize URL and a state store.
import os
from flask import Flask, request, redirect
from slack_sdk.oauth import AuthorizeUrlGenerator
from slack_sdk.oauth.state_store import FileOAuthStateStore
from slack_sdk.oauth.installation_store import FileInstallationStore, Installation
from slack_sdk.web import WebClient
app = Flask(__name__)
state_store = FileOAuthStateStore(expiration_seconds=300, base_dir="./data")
installation_store = FileInstallationStore(base_dir="./data")
authorize_url_generator = AuthorizeUrlGenerator(
client_id=os.environ["SLACK_CLIENT_ID"],
scopes=["chat:write", "chat:write.public", "groups:history"],
user_scopes=[],
)
@app.route("/slack/install")
def install():
return redirect(authorize_url_generator.generate(state_store.issue()))FileOAuthStateStore is fine for a single box and the wrong answer for anything with more than one
process; swap the base class for a Redis or Postgres implementation when you scale out. The
expiration_seconds=300 is doing real work: an unbounded state store is a replay surface.
Then the callback. Note that consume is called before anything touches the code.
@app.route("/slack/oauth/callback")
def callback():
if "error" in request.args:
return f"denied: {request.args['error']}", 400
if not state_store.consume(request.args.get("state", "")):
return "state mismatch", 401 # treat as a forgery, per Slack
client = WebClient() # no token yet, by design
resp = client.oauth_v2_access(
client_id=os.environ["SLACK_CLIENT_ID"],
client_secret=os.environ["SLACK_CLIENT_SECRET"],
code=request.args["code"],
redirect_uri=os.environ.get("SLACK_REDIRECT_URI"),
)
installation_store.save(_to_installation(resp))
return "installed", 200The last piece is turning the response into something storable. Two fields here are easy to get wrong:
the bot token is the top-level access_token, and the user token, if you asked for user scopes, lives
under authed_user.
def _to_installation(resp) -> Installation:
authed = resp.get("authed_user") or {}
return Installation(
app_id=resp.get("app_id"),
enterprise_id=(resp.get("enterprise") or {}).get("id"),
team_id=(resp.get("team") or {}).get("id"),
bot_token=resp.get("access_token"),
bot_id=resp.get("bot_user_id"),
bot_scopes=(resp.get("scope") or "").split(","),
user_id=authed.get("id"),
user_token=authed.get("access_token"),
user_scopes=(authed.get("scope") or "").split(","),
is_enterprise_install=resp.get("is_enterprise_install", False),
)Key the record on team_id plus enterprise_id, not on the installing user. An Enterprise Grid install
can return is_enterprise_install: true with no single team, and a user-keyed row has nowhere to put it.
Slack's security guidance on the stored result is the obvious one, stated because people do not follow it:
"Never hardcode them directly into your application's source code or store them in non-secure locations,
like in public repositories."
Bot Tokens and User Tokens
This is the decision that matters, and it is not a preference.
Bot token (xoxb-) | User token (xoxp-) | |
|---|---|---|
| Represents | The app. Slack's docs say bot tokens are "not tied to a user's identity" | A workspace member, "issued for the user who installed the app and for users who authenticate the app" |
| Sees | Only conversations the bot has been added to | "the same access a user has to a workspace", including their DMs and private channels |
| Message author | Your app's name and icon | The person, as if they typed it |
| Requested via | scope on the authorize URL | user_scope on the authorize URL |
| Found in the response at | access_token | authed_user.access_token |
| When the installing user leaves | Survives. Slack notes the app "stay[s] installed even when an installing user is deactivated" | Goes with them |
| Good for | Almost everything | Acting as a specific human, or reading what only they can read |
Table 1 — Bot versus user tokens. The bottom two rows decide most cases on their own.
Slack's own recommendation is unambiguous: "using bot tokens is usually for the best." We would follow it and treat a user token as something you reach for with a reason you can write down, because a user token is the one that turns an ordinary offboarding into a broken integration.
The practical test is whether a human should appear to have done the thing. A bot posting a deploy summary
is a bot. A workflow that files a leave request on someone's behalf is that person, and a bot token cannot
impersonate them: chat:write.customize changes the display name and avatar on a message, but the message
is still from your app.
A user token also fails in a way nobody plans for. If the person who installed the app leaves and their account is deactivated, every call made with their token stops working, and the first symptom is usually a silent integration rather than an alert. Slack's phrasing of the advantage is the whole argument for the other column: a bot acting independently lets your app "stay installed even when an installing user is deactivated". If you do hold user tokens, treat the identity provider's offboarding event as an integration event too, because nothing on Slack's side will tell you.
There is a third type worth naming so you do not confuse it. App-level tokens start with xapp- and
"represent your app across organizations". They are not workspace credentials and they cannot call most
Web API methods. Socket Mode is what they are for.
Pick the token type from the audit question, not the API question. Ask who should be recorded as having done this. The scopes follow from that answer, and reversing it later means a reinstall.
Token Rotation
By default a Slack bot token does not expire, which is convenient until you notice it means a token leaked six months ago still works. Apps can opt into token rotation, and the shape changes completely.
With rotation on, the access token "expires every 12 hours", which is expires_in of 43200, and you get a
refresh_token alongside it. The refresh is the same endpoint with a different grant type.
def refresh(installation) -> dict:
return WebClient().oauth_v2_access(
client_id=os.environ["SLACK_CLIENT_ID"],
client_secret=os.environ["SLACK_CLIENT_SECRET"],
grant_type="refresh_token",
refresh_token=installation.bot_refresh_token,
).dataThree details from Slack's rotation docs are worth pinning down. Refresh tokens "are designed to be used
once" and the one you sent "is revoked after a short grace period", so persist the new pair before you use
it. Refreshing in a loop is throttled: "If you refresh your credentials repeatedly before expiration...we
will enforce a limit of 2 active tokens." And migrating an existing app uses oauth.v2.exchange, which is
one-way per token, since "You won't be able to exchange the same access token for a refresh token more
than once."
Schedule the refresh comfortably before the 12-hour mark rather than reacting to a failure. Reacting means discovering expiry in the middle of whatever the app was doing.
Scopes That Cost a Day
Three scope pairs account for most of the lost time.
chat:write vs chat:write.public vs chat:write.customize. chat:write posts to channels the bot
is already in. chat:write.public grants the ability to "Send messages to channels your Slack app isn't a
member of", which is the one you want for a notifier that should not need an invite to every channel. The
alternative is channels:join and a conversations.join call, which only works for public channels.
chat:write.customize is what lets chat.postMessage set username, icon_url and icon_emoji. Both
of the extended scopes are bot-token only, and both require chat:write as well. Without
chat:write.public you get not_in_channel, which reads like a membership problem rather than a scope
problem, and that is why it eats an afternoon.
History scopes are per conversation type. conversations.history requires channels:history,
groups:history, im:history and mpim:history, and they map to public channels, private channels, DMs
and group DMs respectively. Requesting channels:history and pointing the app at a private channel
returns missing_scope. The method name gives no hint that four scopes hide behind it.
Read scopes are coarser than you expect. users.list needs users:read and hands back the workspace
directory, not the one person you wanted to resolve. conversations.list needs channels:read,
groups:read, im:read and mpim:read. Ask for the narrowest set that covers the conversation types you
actually touch.
One habit removes most of this. The oauth.v2.access response includes a scope field listing what was
actually granted, and the user token's own scopes come back under authed_user. Compare both against
what you asked for at install time and store the result. A workspace that installed your app eight months
ago has the scope set from eight months ago, and a feature you shipped since then will fail there and
nowhere else, which is a miserable bug to reproduce from a support ticket.
When a call fails, Slack answers HTTP 200 with a JSON body whose ok is false, so a client that only
checks status codes will sail past it. missing_scope is the one to catch explicitly, because the fix is
never in your code: it is a new authorize round trip. Slack's install docs say scopes accumulate, and "any
new scopes you request will be added to that initial set", so you send the user through the authorize URL
again with the fuller list rather than uninstalling. The token you are already holding does not gain the
scope until they do.
from slack_sdk.errors import SlackApiError
try:
client.chat_postMessage(channel="C0123456789", text="build 4821 is green")
except SlackApiError as exc:
err = exc.response["error"]
if err == "missing_scope":
queue_reauthorization(team_id) # needs a new consent round trip
elif err in ("not_in_channel", "channel_not_found"):
client.conversations_join(channel="C0123456789") # needs channels:join
elif err == "ratelimited":
time.sleep(int(exc.response.headers.get("Retry-After", "30")))
else:
raiseRate Limits, and What Retry-After Obliges
Slack's limits are per method, so a burst on one does not spend another's budget. The tiers are published
with numbers: Tier 1 is "1+ per minute", Tier 2 "20+ per minute", Tier 3 "50+ per minute", Tier 4 "100+
per minute", and a Special tier where "Rate limiting conditions are unique." users.list and
conversations.list are Tier 2. conversations.history is Tier 3.
chat.postMessage sits in the Special tier, and the number is the one to design around: apps "may post no
more than one message per second per channel", with short bursts tolerated above that.
Exceed a limit and Slack returns "a HTTP 429 Too Many Requests error, and a Retry-After HTTP header
containing the number of seconds until you can retry." Honour that header exactly. Do not substitute your
own backoff curve, and do not retry sooner because the number looks large.
One change is easy to miss and it is severe. As of 29 May 2025, for new apps distributed commercially
outside the Slack Marketplace, conversations.history is rate limited to 1 request per minute, and the
limit parameter's maximum and default drop to 15 objects. That is a different product than Tier 3 with
1,000-message pages. If you are building a history-reading app today and you are not going through
Marketplace review, design for it now rather than discovering it at scale.
Socket Mode, Events API, or Plain Web API
Three ways to talk to Slack, and most confusion comes from treating them as alternatives when two of them are the same thing with different plumbing.
Figure 2 — Choosing a transport. The Marketplace restriction on Socket Mode is the constraint that most often decides it, and it decides it late if nobody checked.
The Web API is the outbound direction: you call Slack. Every example above uses it, and if your app only posts messages and reads on a schedule, you need nothing else.
The Events API is the inbound direction. As the docs put it, "When you use the Events API, Slack calls
you." Over HTTP that means a public Request URL, a verified signature using the X-Slack-Signature and
X-Slack-Request-Timestamp headers with a five-minute replay window, and an acknowledgement: "Your app
should respond to the event request with an HTTP 2xx within three seconds." Miss it and Slack retries
three times, immediately, after a minute, and after five minutes, tagging each attempt with
x-slack-retry-num. Do the work after you acknowledge, not before, or you will process the same event
four times.
Socket Mode is the same Events API delivered over a WebSocket, so you need no public URL. It uses an
app-level token and apps.connections.open, supports up to 10 concurrent connections, and carries one
restriction that decides the choice for commercial apps: "Apps using Socket Mode are not currently allowed
in the public Slack Marketplace." Use it for internal tools and local development. Do not build a product
on it and plan to list.
Where Fabriq Fits
Agentic Fabriq's Slack integration holds each user's credential in a vault and attaches it server-side at
the moment of the call, so an agent posting to a channel never handles the xoxb- or xoxp- string
itself. Connections are per user, refresh runs automatically with last-used tracking, and the tool list an
agent actually gets is the intersection of the user's scopes and the agent's.
What it does not do is revoke anything on Slack's side when someone disconnects. Clearing a vault copy is
not the same act as invalidating the install, so auth.revoke is still a call you make.
Frequently Asked Questions
Bot token or user token for a Slack app? Bot token, unless a human needs to appear as the author or you need to read something only that person can see. Slack's own docs say "using bot tokens is usually for the best", largely because the app stays installed when the installing user is deactivated.
Where is the user token in the oauth.v2.access response? Under authed_user.access_token, not at
the top level. The top-level access_token is the bot token. Reading the wrong one is the most common
first-install bug.
Why do I get not_in_channel when I have chat:write? Because chat:write only covers channels the
bot has joined. Either add chat:write.public, which grants sending "to channels your Slack app isn't a
member of", or add channels:join and call conversations.join first. chat:write.public also requires
chat:write.
Why does conversations.history return missing_scope? Because the scope depends on the conversation
type. Public channels need channels:history, private channels groups:history, DMs im:history, group
DMs mpim:history. The method needs whichever matches the channel you passed.
Do I need to reinstall the app to add a scope? No uninstall is needed. Slack's docs say "any new scopes you request will be added to that initial set", so you send the user through the authorize URL again with the wider list. Until they complete it, your existing token still lacks the scope.
How long is the Slack OAuth code valid? Ten minutes, and it is single use. If your handler queues the
exchange or retries after a timeout, expect invalid_code on the second attempt.
Do Slack tokens expire? Not by default. If your app enables token rotation, the access token expires
after 12 hours (expires_in 43200) and the refresh token is designed to be used once.
What does Retry-After oblige me to do? Wait that many seconds. Slack returns it on HTTP 429 as "the
number of seconds until you can retry", and it is not advisory. slack_sdk can handle this for you with a
rate-limit retry handler, but only if you install one.
Socket Mode or the Events API? Socket Mode if you cannot expose a public HTTPS endpoint, and the app is internal. The Events API over HTTP if you are building anything you intend to distribute, because apps using Socket Mode "are not currently allowed in the public Slack Marketplace".
Conclusion
The exchange is four steps and slack_sdk gives you three of them. What is left after that is a set of
decisions you make by reading reference pages: bot or user, which of the four history scopes, whether
chat:write.public applies, whether rotation is on, and which transport survives the Marketplace review
you may want in a year.
Write the scope list last, after you have written down what the app does in plain sentences and mapped
each sentence to a scope. That ordering catches chat:write.public and groups:history before an install
does, and it is the cheapest review step available.
We think the Slack-specific part of this is small and the transferable part is large. Separate bot
and user identity, name the scopes from the behaviour, honour Retry-After, and the next provider you
connect will look familiar.
Sources
All URLs read 2026-09-29.
- Installing with OAuth, Slack — the authorize URL and its parameters, the ten-minute code lifetime, redirect URI rules, state guidance, and scope accumulation.
oauth.v2.accessmethod reference — arguments, the full response schema includingauthed_user, and the error codes.- Access tokens, Slack —
xoxb-,xoxp-andxapp-prefixes, what each represents, and the deactivation behaviour. - OAuth with the Python Slack SDK —
AuthorizeUrlGenerator,FileOAuthStateStore,FileInstallationStoreandInstallation. - Using token rotation, Slack — the 12-hour expiry, single-use refresh tokens, the two-active-token limit, and
oauth.v2.exchange. chat:write.publicscope reference — posting to channels the app has not joined, and thechat:writedependency.chat:write.customizescope reference — custom username and avatar, bot tokens only.chat.postMessagemethod reference — required scopes, the special rate limit, and the customization parameters.conversations.historymethod reference — the four history scopes, Tier 3, and the May 2025 non-Marketplace limits.conversations.joinmethod reference —channels:joinfor bot tokens,channels:writefor user tokens.conversations.listmethod reference — the four read scopes and the tier.users.listmethod reference —users:readand the tier.- Rate limits, Slack Web API — the tier table, HTTP 429 and
Retry-After, and the one-message-per-second-per-channel rule. - Using Socket Mode — app-level tokens, the 10-connection limit, and the Marketplace restriction.
- The Events API — HTTP versus Socket Mode delivery, the three-second acknowledgement, and the retry schedule.
- Best practices for security, Slack — token storage, deleting tokens on account removal, and
auth.revoke.