Nov 14, 2023 | 7 min read
Slack Connect And The One Event Problem When Your App Is Installed Twice
In a Slack Connect channel, installing the same app on both workspaces does not mean you will receive two Events API deliveries. The fix starts with the event envelope, not the business logic.
One Slack Connect behavior took longer to understand than it should have.
The setup: workspace A had the app installed, workspace B had the same app installed, the channel was shared between the two, and both bot copies were sitting in it. I assumed a new message in that channel would fire two Events API deliveries, one per installation. It fired one.
Slack does document this, just not where I first went looking. The Slack Connect guide says Events API handling has "No duplicated event triggering between shared channels." The Events API docs explain that the outer payload carries only one authorizations entry even when the event is visible to multiple installations, and that getting the full set means calling apps.event.authorizations.list.
That matters once your app is built around workspace-scoped processing.
A lot of Slack apps are modeled like this:
- receive event
- use the outer
team_idto load the installation - resolve workspace-specific config, users, and tokens
- run the rest of the app logic in that workspace context
Fine in ordinary channels. In a Slack Connect channel where the app exists on both sides, that model leaks: you still get one event, but its business meaning may belong to more than one installation of your app.
In testing, the delivery consistently landed for whichever bot copy had most recently been added to the shared channel. I have not found a doc that guarantees that, so treat it as an observation rather than a contract. What Slack does commit to is narrower: one delivery is normal, that delivery may be visible to more than one installation, and finding out which ones takes an extra call.
What The Event Looks Like
For a normal channel message event, Slack documents an envelope like this:
{
"type": "event_callback",
"team_id": "T123ABC456",
"api_app_id": "A123ABC456",
"event": {
"type": "message",
"channel": "C123ABC456",
"user": "U123ABC456",
"text": "Live long and prospect.",
"ts": "1355517523.000005",
"event_ts": "1355517523.000005",
"channel_type": "channel"
},
"event_context": "EC123ABC456",
"event_id": "Ev123ABC456",
"event_time": 1355517523,
"authorizations": [
{
"team_id": "T123ABC456",
"user_id": "U123ABC456",
"is_bot": false,
"is_enterprise_install": false
}
]
}
Once Slack Connect is involved, the outer envelope becomes more important than the inner event. The Events API docs include these top-level fields:
authorizations: one installation that can see the event, not the whole listevent_context: the handle you pass toapps.event.authorizations.listis_ext_shared_channel: whether this happened in an externally shared channelcontext_team_id: the workspace context Slack used for delivery
The shift that helped: stop reading the delivered event as "the event for one workspace" and start reading it as one delivery that may be visible to more than one installation.
The Docs Are Slightly Inconsistent
The Slack Connect guide still refers to authorized_users in a few places. The current Events API envelope uses authorizations instead. Same underlying idea, but the practical path today is:
- read the single delivered
authorizationsentry on the event - use
event_context - call
apps.event.authorizations.listfor the complete set
So if you see authorized_users in older Slack Connect examples, map that mentally to the newer authorizations plus follow-up lookup flow.
The API Calls That Actually Help
There are two API calls that matter here, and they answer two different questions.
1. Which app installations can see this event?
Use apps.event.authorizations.list.
curl -X POST https://slack.com/api/apps.event.authorizations.list \
-H "Authorization: Bearer xapp-***" \
-H "Content-Type: application/json" \
-d '{
"event_context": "EC123ABC456"
}'
That call requires an app-level token and the authorizations:read scope. Slack is explicit about that in the method docs.
The useful response shape looks like this:
{
"ok": true,
"authorizations": [
{
"enterprise_id": null,
"team_id": "T_WORKSPACE_A",
"user_id": "U_INSTALLER_A",
"is_bot": true
},
{
"enterprise_id": null,
"team_id": "T_WORKSPACE_B",
"user_id": "U_INSTALLER_B",
"is_bot": true
}
]
}
This is the answer to "which installations of my app are expected to care about this event?"
2. Which workspaces are connected to this channel?
Use conversations.info with the channel id from the event.
The conversation object docs are the useful reference here. For shared channels, Slack includes fields like:
is_ext_sharedshared_team_idsconversation_host_id
That tells you about the channel topology, which is related but not identical to event visibility. A channel may be shared across multiple workspaces, but your app may only be installed on some of them.
That distinction matters:
conversations.infotells you who is connected to the channelapps.event.authorizations.listtells you which app installations can see this event
If the goal is correct event fan-out inside your app, the second call is the important one.
The Processing Shape I Would Use
I would not push this special case down into the core app logic.
The cleaner place to handle it is right at the edge of the event processor:
- verify the Slack signature
- persist or dedupe the raw event by
event_id - if
is_ext_shared_channelis false, process normally - if
is_ext_shared_channelis true andevent_contextis present, callapps.event.authorizations.list - expand the single delivered event into one internal work item per relevant authorization
- feed those derived work items into the existing workspace-scoped pipeline
That keeps the special handling local to ingestion.
The rest of the app can continue to believe it is processing one event inside one workspace context, because by the time the event reaches business logic, that context has already been resolved.
A Concrete Expansion Step
The start of the processor can normalize one Slack delivery into several internal events:
type SlackEnvelope = {
event_id: string;
event_context?: string;
team_id: string;
is_ext_shared_channel?: boolean;
event: {
type: string;
channel?: string;
user?: string;
ts?: string;
};
};
async function expandForAuthorizedInstalls(envelope: SlackEnvelope) {
if (!envelope.is_ext_shared_channel || !envelope.event_context) {
return [{ workspaceTeamId: envelope.team_id, envelope }];
}
const auths = await slackClient.apps.event.authorizations.list({
event_context: envelope.event_context,
});
return auths.authorizations
.filter((auth) => auth.team_id)
.map((auth) => ({
workspaceTeamId: auth.team_id!,
envelope,
}));
}
Then each expanded work item can load the correct installation record, token set, and workspace-specific settings before handing off to the normal processing path.
That is a better fit than trying to teach every downstream module that one Slack event may imply multiple workspace contexts.
A Few Practical Details To Get Right
There are a few details here that are easy to miss:
- Dedupe external work items by
(event_id, workspaceTeamId), not justevent_id, because you are intentionally fanning one Slack delivery out into multiple internal tasks. - Treat the outer
team_idas the delivery context Slack chose, not as the full set of workspaces that care about the event. - Use the Conversations API for channel inspection. Slack Connect docs are pretty direct that shared-channel work should use
conversations.*, not the older channel APIs. - Expect shared-channel oddities beyond delivery. Slack also documents channel ID changes during sharing, different privacy settings per workspace, and file events that arrive with
"file_access": "check_file_info"in Slack Connect channels.
What I Would Test Before Trusting It
If I were implementing this from scratch, I would test this path very deliberately:
- install the app on both workspaces
- add both bot copies to the same Slack Connect channel
- send a plain message from workspace A and workspace B
- confirm you still receive only one webhook delivery per message
- inspect
is_ext_shared_channel,event_context, and the single returnedauthorizationsentry - call
apps.event.authorizations.listand confirm both workspace installations are returned - run your internal expansion step and verify both workspace-scoped pipelines process the message
- remove and re-add one bot copy if you want to test which installation Slack chooses for the single external delivery
That last step is where I saw the "most recently added bot gets the event" pattern, still not something I'd call an API contract.
Slack Connect doesn't hide information so much as it delivers one event while most apps are built assuming delivery and workspace context are the same thing. Once I stopped treating the webhook as a fully resolved workspace event and started treating it as a delivery envelope that might need expanding, the rest of the design fell out on its own: Slack keeps sending one event, authorization discovery happens explicitly at the edge, and the core app logic never finds out any of this happened.