Simulate Slack events locally. Signed payloads, no workspace, no tunnel.
npx slack-sim send message --text "hello" --url http://localhost:3000/slack/eventsTo test a Slack app you run ngrok, install into a sandbox workspace, and then click things in Slack until it produces the event you need.
That works right up until you need an event Slack will not produce on demand:
- A message from 400 days ago. Slack will not let you post one. So retention jobs, SLA timers, stale-thread reminders and purge sweeps are untestable.
- A tombstone. What a thread parent becomes when deleted with replies still attached. Arrives as
message_changedwith no text. - A retry. Slack retries any non-2xx up to 3 times with
x-slack-retry-num. Your idempotency handling has probably never run. - A workspace you do not have. Enterprise Grid, a shared channel, a second team id.
- CI. No tunnel, no workspace, no clicking.
slack-sim builds those payloads and signs them the way Slack does, so they hit your real endpoint through your real verification middleware.
npm install --save-dev slack-sim # or: npx slack-simNode 18+. One dependency (yaml).
Print a payload:
slack-sim send message --prettySend it to your app:
export SLACK_SIGNING_SECRET=your-secret
slack-sim send app_mention --text "deploy prod" --url http://localhost:3000/slack/eventsok 200 14ms
The events that are hard to get any other way:
# A message from 400 days ago
slack-sim send message --age-days 400 --url $URL
# An edit — outer ts is the edit time, inner message.ts is the original id
slack-sim send message_changed --text "after" --previous-text "before" --url $URL
# A deleted thread parent
slack-sim send tombstone --url $URL
# Slack's 2nd retry of an event you already processed
slack-sim send message --retry 2 --retry-reason http_timeout --url $URL
# Another app posting: bot_id set, no user field. The classic echo-loop bug.
slack-sim send bot_message --url $URL
# A signature 10 minutes stale — a correct verifier must reject this
slack-sim send message --old-timestamp --url $URLInteractivity and slash commands are form-encoded, not JSON. That difference is where handlers quietly break, and slack-sim encodes them the way Slack really does:
slack-sim send block_actions --action-id approve_button --url $URL/slack/interactive
slack-sim send view_submission --callback-id ticket_modal --url $URL/slack/interactive
slack-sim send command --command /deploy --text "prod" --url $URL/slack/commandsslack-sim list prints everything it can build. Add --json to any command for a machine-readable result on stdout — see For coding agents.
Pass a seed and you get the same team, channel and user ids on every run, on every machine. That is what makes committed fixtures diffable.
slack-sim send message --seed acme-corp # same ids forever
slack-sim send message --team T123 --channel C456 --user U789signedRequest gives you the exact bytes and headers Slack would send, so you can drive your handler in-process. No network, no port.
import { createContext, message, signedRequest } from 'slack-sim';
const ctx = createContext({ seed: 'test' });
it('ignores messages older than a year', async () => {
const request = signedRequest(message(ctx, { ageDays: 400 }), {
signingSecret: process.env.SLACK_SIGNING_SECRET
});
const response = await app.inject({
method: 'POST',
url: '/slack/events',
headers: request.headers,
payload: request.body // send this verbatim — re-serialising breaks the signature
});
expect(response.statusCode).toBe(200);
expect(archiveJob).not.toHaveBeenCalled();
});Or go over the wire with deliver, which reports elapsed time against Slack's 3-second ack budget:
import { deliver, SLACK_ACK_BUDGET_MS } from 'slack-sim';
const result = await deliver('http://localhost:3000/slack/events', message(ctx));
expect(result.durationMs).toBeLessThan(SLACK_ACK_BUDGET_MS); // else Slack retries in prodverifySignature is exported too, so you can unit-test your own verifier against the same bytes it will see in production.
An agent writing a Slack handler hits a wall the moment it wants to check its work. It cannot click a button in Slack, complete an OAuth flow, start a tunnel, or look at a thread. The normal way to test a Slack app is precisely the part an agent cannot do — so it writes the handler, never exercises it, and reports "this should work".
slack-sim closes that loop. One command, no browser, no workspace, no human in the middle:
slack-sim send message --age-days 400 --url http://localhost:3000/slack/events --json{
"ok": false,
"status": 500,
"durationMs": 41,
"exceededAckBudget": false,
"ackBudgetMs": 3000,
"responseBody": "Cannot read properties of undefined (reading 'ts')",
"event": "message",
"sent": { "...": "the exact payload that caused it" }
}That is a bug the agent can now actually see, with the payload that triggered it attached.
The pieces that make it work unattended:
--jsonputs a machine-readable result on stdout. Human progress goes to stderr, so stdout stays clean to pipe or parse.- Exit codes mean something.
0pass,1fail, on non-2xx responses, failed scenario steps, and unreachable servers alike. Drop it into a verification step and trust the result. slack-sim list --jsonenumerates every event, interactivity type and flag, so an agent discovers the surface in one call rather than guessing at option names.--seedmakes runs reproducible. A failure seen once reproduces byte-for-byte, on the next run and on someone else's machine.- Errors say what to do.
Could not reach http://localhost:3000: fetch failed. Is the server running and the path correct?rather thanfetch failed. - Scenarios are a single verification command covering a whole flow, with
failuresbroken out in the JSON report so nothing has to be filtered out of a step list.
slack-sim scenario examples/support-thread.yml --url $URL --json{ "passed": false, "total": 5, "failedCount": 1, "failures": [{ "step": 4, "event": "message_changed", "status": 500, "passed": false }], "steps": ["..."] }Worth pasting into your AGENTS.md or CLAUDE.md:
## Testing Slack handlers
Do not ask me to click in Slack. Exercise handlers with slack-sim:
npx slack-sim list --json # every event and flag
npx slack-sim send <event> --url $URL --json # one event, structured result
npx slack-sim scenario <file.yml> --url $URL --json # a full flow
Exit 0 pass, 1 fail. Always pass --seed so a failure reproduces.
Reach for --age-days, tombstone, --retry and bot_message: those are the
payloads a real workspace will not produce, and where the bugs live.The flows worth testing are never one event. Post, reply, react, edit, delete — reproducing that by hand takes minutes and is never quite the same twice.
# support-thread.yml
context:
seed: support-flow
steps:
- event: message
text: "the printer is on fire again"
as: parent # remember this message's ts
- event: message
text: "looking into it"
threadTs: $parent # reply in the thread
expect: 200
- event: reaction_added
ts: $parent
emoji: eyes
- event: message_changed
ts: $parent
text: "the printer is fine now"
previousText: "the printer is on fire again"
- event: message_deleted
ts: $parentslack-sim scenario support-thread.yml --url http://localhost:3000/slack/eventsok 1. message → 200 12ms
ok 2. message → 200 (expected 200) 9ms
ok 3. reaction_added → 200 7ms
ok 4. message_changed → 200 11ms
ok 5. message_deleted → 200 8ms
5/5 steps passed
Exits non-zero on failure, so it drops straight into CI. More in examples/.
Generated payloads are close. Payloads your workspace actually produced are exact — but you cannot commit those, because they carry real user ids, channel names, message text and tokens.
# capture a real payload however you like (a log line, a debug endpoint), then:
cat captured.json | slack-sim redact --seed acme --age-days 400 > test/fixtures/stale.json
slack-sim replay test/fixtures/stale.json --url $URLredact swaps every Slack id for a stable fake, so the same real id always becomes the same fake id and the payload stays internally consistent: a thread_ts still matches its parent's ts afterwards. Tokens, emails and workspace URLs are stripped. Message text is replaced unless you pass --keep-text.
--age-days re-anchors the timestamps so the fixture reads as that old at replay time, preserving the gaps between messages. Without it a committed fixture ages a day per day and any age-sensitive branch eventually flips on its own.
| Messages | message, message_changed, message_deleted, tombstone, bot_message, file_share, threaded replies, thread_broadcast |
| Events | app_mention, reaction_added, reaction_removed, member_joined_channel, member_left_channel, channel_archive, channel_unarchive, channel_rename, channel_deleted, app_home_opened |
| Interactivity | block_actions, view_submission, view_closed, message_action |
| Other | slash commands, url_verification, app_uninstalled, tokens_revoked |
| Delivery | v0 request signing, retry headers, stale-timestamp signing, ack-budget timing |
Every payload carries the fields that are easy to omit by hand and load-bearing in practice: authorizations (Bolt uses it to resolve the installation), event_context, channel_type, client_msg_id.
Not a Slack Web API mock. slack-sim sends events to your app; it does not answer the chat.postMessage calls your app makes back. Pair it with nock or @slack/web-api's own test helpers for the outbound half.
Not a replacement for a sandbox workspace. Use a real workspace to discover what a payload looks like. Use slack-sim to replay it a thousand times, backdated, in CI.
Payload shapes drift, and the ones here come from real traffic. If you have a payload slack-sim gets wrong, open an issue with a redacted sample (slack-sim redact will do it) and it will get fixed.
npm install && npm testMIT.