Slack Integration
Slack integrations come in two directions, and mixing them up wastes an afternoon. Outbound: your code posts messages into a channel, which needs an incoming webhook URL and nothing else. Inbound: someone types /yourcommand in Slack and your server has to answer, which needs a public endpoint, request verification, and respect for one brutal deadline. Most projects want outbound first. It is ten lines of code and it makes your system feel alive: deploy finished, order came in, backup failed. Start there even if you came for the slash command.
The slash command is where the real engineering lives, and it lives in one number: three seconds. Slack sends your endpoint a request when someone runs the command, and if you have not responded in three seconds, the user sees a timeout error, no matter how well your code was doing. The pattern that survives is acknowledge-then-work: reply immediately with "on it," do the slow thing, then deliver the answer through the response_url Slack handed you, which stays valid for thirty minutes. Agents reliably write the naive version that does the work inline, and it even works in testing, because your test query was fast. Production queries are not.
The other thing agents skip is verifying that requests actually come from Slack. Your slash-command endpoint is a public URL; anyone who finds it can POST to it. Slack signs every request, and checking that signature is the difference between an integration and an open door. The spec below treats verification as a hard requirement, and for the general theory of receiving webhooks safely (dedupe, retries, idempotency), see the webhook-receiver template; this one bakes in the Slack-specific parts.
Prerequisites
- A Slack workspace where you can create apps (api.slack.com/apps), and a Slack app created in it.
- For outbound: an incoming webhook URL from the app's Incoming Webhooks page. For slash commands: the app's Signing Secret, and somewhere public to deploy an endpoint (Vercel or similar).
# Project: Slack integration — [what it does]
Build [outbound notifications / a slash command / both] for my Slack
workspace. Details below; the verification and timing rules are not
optional.
## Part 1 — Outbound: post messages via incoming webhook
- The webhook URL is a SECRET (anyone holding it can post to the
channel). It lives in the environment variable `SLACK_WEBHOOK_URL`,
never in code or logs.
- One function wraps all posting:
```typescript
async function postToSlack(text: string, blocks?: unknown[]) {
const res = await fetch(process.env.SLACK_WEBHOOK_URL!, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text, ...(blocks ? { blocks } : {}) }),
});
if (!res.ok) {
// Log and continue. A dead Slack message must never crash the
// main flow — notification is a side effect, not the job.
console.error("slack post failed", res.status);
}
}
```
- Always include plain `text` even when sending Block Kit `blocks`;
it is the fallback for notifications and screen readers.
- Events that trigger a post: [list them, e.g., "new order created",
"daily summary at 9am", "job failure"]. Nothing else posts.
## Part 2 — Inbound: slash command `/[command]`
User types `/[command] [args]`, Slack POSTs to my endpoint, the user
gets [what they should see].
### The three-second rule (design around this first)
- Respond within 3 seconds or Slack shows the user an error.
- If the work can exceed ~2 seconds (any external API call, any LLM
call, any real query): immediately return
`{"response_type": "ephemeral", "text": "Working on it..."}`,
run the work async, then POST the real result to the request's
`response_url` (valid 30 minutes).
- On serverless, "run the work async" must survive the platform:
use a queue or background function, not a dangling promise the
runtime freezes after the response.
### Verify every request (before parsing anything)
```typescript
import { createHmac, timingSafeEqual } from "crypto";
function verifySlack(req: { headers: Headers; rawBody: string }): boolean {
const ts = req.headers.get("x-slack-request-timestamp") ?? "";
// Reject old timestamps: blocks replay of captured requests.
if (Math.abs(Date.now() / 1000 - Number(ts)) > 60 * 5) return false;
const base = `v0:${ts}:${req.rawBody}`;
const expected = "v0=" + createHmac("sha256", process.env.SLACK_SIGNING_SECRET!)
.update(base).digest("hex");
const got = req.headers.get("x-slack-signature") ?? "";
return got.length === expected.length &&
timingSafeEqual(Buffer.from(got), Buffer.from(expected));
}
```
- Compute the signature over the RAW request body, before any JSON or
form parsing. Frameworks that auto-parse bodies break this; get the
raw bytes.
- Failed verification: respond 401, log it, process nothing.
### Command behavior
- `/[command]` with no args: return a short usage message
(ephemeral — visible only to the user who typed it).
- `/[command] [expected args]`: [what it does and what it returns].
- Default `response_type` is `ephemeral`; use `in_channel` only where
the whole channel genuinely needs the answer.
- Errors return a human sentence to the user, never a stack trace
into the channel.
## Configuration and scope
- Env vars: `SLACK_WEBHOOK_URL`, `SLACK_SIGNING_SECRET`, [others].
Provide `.env.example`. Nothing secret in the repo.
- Request only the OAuth scopes the features above need. No `admin`,
no broad read scopes "for later."
- The integration never DMs users and never posts to channels beyond
[the configured channel(s)] without me changing the config.
## Done means
- Outbound: triggering [event] posts the message; killing the webhook
URL makes it log an error and the main flow still completes.
- Inbound: `/[command]` answers in under 3 seconds every time, with
slow work arriving via response_url after the ack.
- A request with a bad signature gets a 401 and no side effects.
- A replayed request with a 10-minute-old timestamp is rejected.Adaptation notes:
- Notifications only: build Part 1, skip Part 2 entirely, and you never need a public endpoint or the signing secret. Do not create the slash command "while you're in there."
- Multiple slash commands: one endpoint, dispatch on the
commandfield of the payload. Separate endpoints per command multiplies the verification code and the deploy surface for nothing. - If the command triggers something that spends money or sends email, the command should return a confirmation prompt with buttons (Slack interactivity), not act immediately. Typing eight characters into a chat box is not consent to spend.
- Slack retries slash-command deliveries it thinks failed, so your handler can run twice for one user action. If the command has side effects, dedupe before acting: the webhook-receiver template covers the pattern.
- The mistake: testing with fast queries, shipping, and meeting the three-second timeout for the first time in front of your team. Add an artificial 5-second delay to the work in one test and confirm the ack-then-response_url path actually runs.