Runbook: Slack "Add to Slack" OAuth Install (per environment)
Scope. Stand up the Slack OAuth 2.0 install flow (issue #1159, shipped in PR #1426) for one environment, then run an end-to-end install for one org. Worked example uses dev. Implementation:
api/src/channels/slack/slack-oauth.service.ts,slack-oauth.controller.ts,slack-channel-binding.service.ts. Architecture: channels.md.
This replaces the old "paste a bot token into env" flow with a standard Add to Slack authorize/callback cycle. After install, the bot token is stored encrypted per org in integration_credentials (Org Γ slack), and inbound + outbound Slack work immediately. Pulling the bot into a channel auto-binds that channel to the org's primary Specialist (no @mention needed thereafter).
Hard rule: one Slack app per environment. A Slack app's Events request_url is single-valued, so local / dev / staging / prod each need their own Slack app and their own SLACK_CLIENT_ID / SLACK_CLIENT_SECRET. Do not share one app across environments.
Part A β δΊε (one-time, per environment)β
Do this once per environment. For dev, do it against the dev API/frontend hosts.
A1. Know your URLsβ
For dev you need two public HTTPS origins:
| Thing | dev value | Used for |
|---|---|---|
| API origin | https://api.h852.work (derived from HWORK_DOMAIN, see getPublicApiUrl()) | Slack Events request_url, OAuth callback |
| Frontend origin | https://dev.h852.work (FRONTEND_URL) | post-install redirect target |
Two derived URLs you'll register on the Slack app:
- Events request URL β
https://api.{HWORK_DOMAIN}/channels/slack/events(seeslack.controller.ts:69) - OAuth redirect URL β
https://api.{HWORK_DOMAIN}/channels/slack/oauth/callback(derived bygetPublicApiUrl()inslack-oauth.service.ts; never the raw deployment host)
A2. Create the Slack app from the manifestβ
Create the app from the manifest below β it already carries the redirect URL, the full scope set, and the channel-bind events, so there is nothing to hand-fix afterwards (the in-app GET /orgs/:orgId/channels/slack/manifest generator and manifest.ts are incomplete β they omit redirect_urls, the extra scopes, and member_joined_channel/member_left_channel β so prefer this file).
- https://api.slack.com/apps β Create New App β From a manifest β pick the workspace β YAML β paste the manifest below β Create.
This is the dev manifest, ready to use as-is. For another environment, change only: display_information.name, bot_user.display_name, and the two hostnames in redirect_urls / event_subscriptions.request_url (β that env's API origin from A1).
display_information:
name: Humanwork (Dev)
description: AI specialist for your team β dev/test app
background_color: "#1a1a2e"
features:
app_home:
home_tab_enabled: false
messages_tab_enabled: true
messages_tab_read_only_enabled: false
bot_user:
display_name: HumanworkDev
always_online: true
oauth_config:
redirect_urls:
- https://api.h852.work/channels/slack/oauth/callback
scopes:
bot:
- app_mentions:read
- channels:history
- channels:read
- groups:history
- groups:read
- mpim:history
- mpim:read
- im:history
- im:read
- im:write
- chat:write
- chat:write.customize
- chat:write.public
- files:read
- files:write
- users:read
- users:read.email
- users.profile:read
- team:read
- reactions:read
- reactions:write
pkce_enabled: false
settings:
event_subscriptions:
request_url: https://api.h852.work/channels/slack/events
bot_events:
- app_mention
- app_uninstalled
- member_joined_channel
- member_left_channel
- message.channels
- message.groups
- message.im
- message.mpim
org_deploy_enabled: false
socket_mode_enabled: false
token_rotation_enabled: false
is_mcp_enabled: false
The
botscopes above matchSLACK_OAUTH_SCOPESexactly (all 21) β that's required, since the authorize call requests precisely that set and Slack rejects any scope the app doesn't have.
A3. Verify the app + one optional addβ
Creating from A2's manifest leaves nothing mandatory to fix. Just confirm:
- Event Subscriptions request URL shows "Verified" (the API must already be deployed and reachable β see A7). If it doesn't verify, fix the API origin/reachability first, then re-paste/re-verify.
- App Home β Messages tab enabled (already in the manifest).
Optional β read receipts (#1362): the manifest does not subscribe
reaction_added, so the delivery "read" receipt path (handleReactionAdded) stays dormant even thoughreactions:read/writeare granted. To enable it, addreaction_addedunder Event Subscriptions β bot events and reinstall. Not needed for the core install/binding flow.
A4. Collect credentials from the appβ
From Settings β Basic Information β App Credentials:
- Client ID β
SLACK_CLIENT_ID - Client Secret β
SLACK_CLIENT_SECRET - Signing Secret β
SLACK_SIGNING_SECRET(used for inbound webhook signature verification; also stored per-install so prod can verify per-org β seecompleteInstall)
Generate the OAuth state secret:
openssl rand -hex 32 # β SLACK_OAUTH_STATE_SECRET
A5. Set environment variablesβ
Set on the dev api service (and ensure FRONTEND_URL points at the dev frontend so the callback lands on a real page β see appBaseUrl()). Reference: .env.example:85-102.
SLACK_CLIENT_ID=... # A4
SLACK_CLIENT_SECRET=... # A4
SLACK_SIGNING_SECRET=... # A4
SLACK_OAUTH_STATE_SECRET=... # A4 (openssl rand -hex 32)
PUBLIC_API_URL=https://api.h852.work # canonical API origin (also used by Telegram, webhook-urls)
# FRONTEND_URL / APP_BASE_URL must resolve to the dev frontend (not the API host)
Notes:
- The OAuth
redirect_uriis derived fromPUBLIC_API_URL, orhttps://api.{apex of HWORK_DOMAIN}wheneverHWORK_DOMAINis set. This includes the dev env, whoseAPP_ENV=developmentandHWORK_DOMAIN=dev.h852.work: the derivation is gated onHWORK_DOMAIN(notIS_DEPLOYED) and strips thedev./staging.portal-subdomain prefix to the apex, so dev resolves tohttps://api.h852.workβ neverhttp://localhost:3000and neverhttps://api.dev.h852.work. There is no separateSLACK_OAUTH_REDIRECT_URLenv var. The Slack app's redirect_urls allowlist (A2) must include the canonical URL produced bygetPublicApiUrl()(#3923). SLACK_OAUTH_STATE_SECRETis validated lazily at call time, not at boot. In dev it has an insecure fallback if unset, so the app still boots; prod throws if missing (getStateSecret). Set a real one in dev anyway to mirror prod.SLACK_BOT_TOKENin.env.exampleis the legacy single-token path. With OAuth install you don't set it β the per-org token is written by the install flow. Leave it empty.MASTER_ENCRYPTION_KEY: if set, the stored bot token is AES-256-GCM encrypted; if unset (typical dev),encryptConfigis a passthrough. Either works for dev.
A6. Database tableβ
The flow needs slack_channel_bindings.
-
dev / local / test: TypeORM
synchronize: true(non-prod) auto-creates it from theSlackChannelBindingentity β no action needed. -
staging / prod: run the migration (
synchronizeis off):cd api && npm run migrations:run # applies 1780600000000-AddSlackChannelBindingsThe data source already registers the entity (
typeorm-data-source.config.ts); CI'sapi-postgres-migrationsjob verifies the forward migration applies cleanly.
A7. Restart & sanity-checkβ
Restart the dev api so the new env vars load. Then verify the Events URL is reachable and the OAuth endpoints exist:
# Slack URL verification handshake (the controller answers the challenge):
curl -sS -X POST https://<API_ORIGIN>/channels/slack/events \
-H 'Content-Type: application/json' \
-d '{"type":"url_verification","challenge":"ping123"}'
# expect 200 with JSON: {"challenge":"ping123"} (see slack.controller.ts:81)
Part B β δΊδΈ (per org, the actual install + verification)β
You're an org member/admin on the dev frontend. (The start endpoint is gated by JwtAuthGuard + org-membership, so you must be logged in to that org β controller.)
B1. Kick off the installβ
- Log in to the dev frontend as a member of the target org.
- Go to Settings β Channels (
/client/settings/channels). - Click Add to Slack. The frontend calls
GET /channels/slack/oauth/start?orgId=β¦with your Bearer token, gets{ url }, and navigates to Slack's authorize screen (startSlackInstall,page.tsx:164). - On Slack, pick the workspace and Allow.
B2. Confirm the callback succeededβ
Slack redirects to β¦/oauth/callback, which exchanges the code and redirects to /client/settings/channels?slack=connected&team=β¦. The page shows a post-install banner and a π¬ Message on Slack deep link.
Possible banner states (callback):
| Query param | Meaning | Likely cause |
|---|---|---|
?slack=connected | success | β |
?slack=denied | user clicked Cancel on consent | retry |
?slack=error | token exchange failed | bad SLACK_CLIENT_SECRET, redirect URL mismatch, or missing scope (check api logs: Slack oauth.v2.access not ok: <error>) |
Invalid state parameter (plain 400) | CSRF state failed/expired (>10 min) | restart from B1; check SLACK_OAUTH_STATE_SECRET didn't change mid-flow |
DB check:
SELECT org_id, integration_type, status, metadata
FROM integration_credentials
WHERE integration_type = 'slack' AND org_id = '<org_id>';
-- expect status='active', metadata has team_id / team_name / app_id / bot_user_id
B3. Verify inbound + channel bindingβ
-
In the connected Slack workspace, invite the bot to a channel (
/invite @<bot>). This firesmember_joined_channelβ the bot auto-binds the channel to the org's primary Specialist and posts a welcome message (slack.service.ts:106).If you see no welcome message, the org has no assigned Specialist β the bind is skipped with a warn (
Slack channel bind skipped: org=β¦ has no assigned Specialist). Assign a primary Specialist to the org first (AM Setup), then re-invite.SELECT org_id, external_channel_id, specialist_id, bound_by_slack_user
FROM slack_channel_bindings WHERE org_id = '<org_id>'; -
Post a plain message in the bound channel (no
@mention). It should route to the Specialist and produce a draft β Expert queue item (defaultautoRespondThreshold = 101, always HITL). -
DM the bot (Messages tab). DMs route directly too.
-
Remove the bot from the channel (
/remove @<bot>) βmember_left_channelβ binding row is deleted (slack.service.ts:130).
B4. Outboundβ
Have an Expert send a reply from the workspace queue. The dispatcher reads the per-org bot_token from the stored credential and posts back to the Slack thread (three-layer protection: withCircuit β withRetry β channel_failures DLQ).
Quick failure mapβ
| Symptom | Check |
|---|---|
Add to Slack β 403 | You're not a member of orgId in your JWT; log in to the right org. |
| Authorize screen errors on scopes | App's bot scopes don't match SLACK_OAUTH_SCOPES β recreate from the A2 manifest (or add the missing scope + reinstall). |
?slack=error after Allow | SLACK_CLIENT_SECRET wrong, or the derived redirect URI (getPublicApiUrl() + /channels/slack/oauth/callback) isn't an exact match for a redirect_urls entry on the app (A2). Check api log line Slack oauth.v2.access not ok. |
| Callback lands on API 404, no banner | FRONTEND_URL not set / points at API host β fix A5 (appBaseUrl). |
| Events URL won't verify on Slack | API not publicly reachable, or signature/raw-body issue; confirm A7 handshake returns the challenge. |
| Inbound messages ignored in a channel | Channel not bound and no @mention; either invite the bot (auto-bind) or @mention it. |
| No welcome on invite | Org has no primary Specialist β assign one, re-invite. |
| Works in dev, breaks in prod boot | SLACK_OAUTH_STATE_SECRET unset (prod throws); set a real secret. |
Promoting to staging / prodβ
Repeat Part A with a separate Slack app and that environment's URLs/secrets, and run the migration (A6) since synchronize is off. Everything else is identical.
Troubleshooting an existing install (prod)β
Once installed, when a specific org's Slack misbehaves in prod ("installed but nothing happens", connect fails, replies don't return), use the companion runbook β concrete log keywords + DB queries + Sentry filters keyed on org_id / team_id: slack-prod-troubleshooting.md.