Events API transport: a Plug that receives Slack's HTTP event callbacks.
It verifies Slack's request signature, answers the url_verification
challenge, responds 200 immediately, and dispatches the event off-process
(so your handler can't blow Slack's ~3s response budget).
Mount it in a router:
forward "/slack/events", to: Slink.EventsApi.Plug,
init_opts: [
module: MyBot,
signing_secret: System.fetch_env!("SLACK_SIGNING_SECRET"),
bot_token: System.fetch_env!("SLACK_BOT_TOKEN")
]Or run it standalone with Bandit:
Bandit.start_link(
plug: {Slink.EventsApi.Plug,
module: MyBot,
signing_secret: System.fetch_env!("SLACK_SIGNING_SECRET"),
bot_token: System.fetch_env!("SLACK_BOT_TOKEN")},
port: 4000
)Options:
:module(required) — a module implementing theSlinkbehaviour.:signing_secret(required) — the app's Signing Secret, for request verification. A string, or a 0-arity function returning one (resolved per request — see Mounting in Phoenix).:bot_token— bot token (xoxb-…) passed to handlers for Web API calls. A string, a 0-arity function, or — for a multi-workspace app — a 1-arity function called with the event's team id (see Multiple workspaces).
Multiple workspaces
One app can serve many workspaces from a single endpoint. The signing secret
is per-app, so it stays one value; only the bot token differs per install.
Pass a 1-arity :bot_token — it's called with the event's team id
(Slink.Event.team_id/1), so you look the right token up from your own store:
forward "/slack/events", to: Slink.EventsApi.Plug,
init_opts: [
module: MyBot,
signing_secret: fn -> System.fetch_env!("SLACK_SIGNING_SECRET") end,
bot_token: fn team_id -> MyApp.Installs.bot_token(team_id) end
]Acquiring those per-team tokens is handled by Slink.OAuth (the "Add to
Slack" consent URL, code exchange, and callback plug); storing them stays
yours — Slink routes to whatever your :bot_token resolver returns.
Mounting in Phoenix
Two pitfalls when mounting inside a Phoenix app:
forwardoptions are evaluated at compile time in production, soSystem.fetch_env!/1in the router would read the env var on the build machine (or fail there). Pass functions instead — they're resolved per request:forward "/slack/events", to: Slink.EventsApi.Plug, init_opts: [ module: MyBot, signing_secret: fn -> System.fetch_env!("SLACK_SIGNING_SECRET") end, bot_token: fn -> System.fetch_env!("SLACK_BOT_TOKEN") end ]The raw body must still be readable. Signature verification hashes the raw request body, but
Plug.Parsers(in every generated endpoint) consumes it before the router runs — mounted after it, every request 401s. Mount this plug inendpoint.exbeforeplug Plug.Parsers, or run it as a standalone listener (see above).
Slash commands & interactivity
Slack delivers those as application/x-www-form-urlencoded, not JSON; this
plug decodes both. Point the app's Request URL for Interactivity and
Slash Commands at this same endpoint. Slash commands and most interactions
are handled off-process like events, and your handler replies via the
response_url (see Slink.reply/3).
The one exception is view_submission (a modal submit): Slack expects a
response_action in the immediate reply. For that type the handler runs
synchronously and its {:ack, map} return is sent back as the response — so
keep it fast (Slack's window is ~3s). Returning anything else closes the modal.