# Events API

Slack's Events API for your bot over Socket Mode or a request URL, with retries, app mentions and a way to wait for your bot's turn.

The simulator delivers [Events API](https://docs.slack.dev/apis/events-api/) events for the Web API calls it serves, so a bot built on Bolt or the raw protocol hears what happens in the workspace. Delivery is off until you choose a mode, but the simulator records every event its calls render either way, and `GET /_rystic/settle` tells you when your bot's turn is over. Post a message that mentions the bot as a person, wait for the simulator to settle, then read the events it rendered:

```bash
curl -s -X POST "$RYSTIC_URL/_rystic/scenario" -d '{"name":"design_studio"}' -o /dev/null
curl -s "$RYSTIC_URL/_rystic/settle?quiet_ms=100" | jq -c '{settled, last_call_seq}'
# -> {"settled":true,"last_call_seq":0}
curl -s -X POST "$RYSTIC_URL/api/chat.postMessage" -H 'Authorization: Bearer xoxp-anything' -H 'X-Rystic-Actor: UWDC0000020' -H 'X-Rystic-Surface: client' -d channel=CWDC0000004 --data-urlencode 'text=<@UWDCBOT0001> draft a reply' | jq -c '{ok, ts}'
# -> {"ok":true,"ts":"1772323200.000001"}
curl -s "$RYSTIC_URL/_rystic/settle?quiet_ms=100" | jq -c '{settled, last_call_seq}'
# -> {"settled":true,"last_call_seq":1}
curl -s "$RYSTIC_URL/_rystic/egress" | jq -c '[.events[] | .type]'
# -> ["message","app_mention"]
```

### Socket Mode

Choose one mode per run with `--env`; with neither set, nothing is delivered. In Socket Mode, `apps.connections.open` takes an app-level token (`xapp-…`) and answers with a one-time `ws://` URL on the simulator. Boot with the switch and point Bolt's API base at the simulator:

```sh
rystic run slack-twin -d --name slack --port 8080 --scenario design_studio --env SLACK_TWIN_SOCKET_MODE=1
```

```python
from slack_bolt import App
from slack_bolt.adapter.socket_mode import SocketModeHandler
from slack_sdk import WebClient

app = App(client=WebClient(token="xoxb-local", base_url="http://localhost:8080/api/"))

@app.event("app_mention")
def mention(event, say):
    say(f"hi <@{event['user']}>")

SocketModeHandler(app, "xapp-local").start()
```

With `SLACK_CLIENT_ID` and `SLACK_CLIENT_SECRET` in its environment, Bolt expects an OAuth install and drops every event; unset them for a run against the simulator.

Each event arrives as an `events_api` envelope. An envelope that is not acked within 3 seconds is redelivered with `retry_attempt` and `retry_reason: "timeout"` after 0 seconds, 1 minute and 5 minutes, as on Slack. `SLACK_TWIN_SOCKET_MODE=1` turns request-URL delivery off, as enabling Socket Mode does on Slack.

### Apps and app ids

Each app gets its own copy of every event. A token shaped like Slack's, `xapp-1-<app id>-…` with an app id such as `A0123456789`, connects as that app, and its envelopes carry that `api_app_id`; any other token, such as `xapp-local`, connects as the simulator's one default app, `AWDCBOT0001`. An app's connections share its events, one connection per envelope, as on Slack. An app starts receiving events at its first `apps.connections.open` and keeps receiving them until the simulator stops, and a reset keeps it. While none of its connections is open, an event keeps to the redelivery schedule and is dropped when the schedule ends. The verified UI connects as its own app, `AUIRYSTIC01`. To run two bots, or a bot beside the verified UI, give each its own app id, for example `xapp-1-A0000000001-1-x` and `xapp-1-A0000000002-1-x`.

Envelopes name team `TWDC0000001`, app `AWDCBOT0001` (the default app's) and bot user `UWDCBOT0001`, the `design_studio` bot. Set `SLACK_TWIN_TEAM_ID`, `SLACK_TWIN_APP_ID`, `SLACK_TWIN_BOT_USER_ID` and `SLACK_TWIN_VERIFICATION_TOKEN` to match another scenario.

### Request URL

The simulator POSTs each `event_callback` to your URL, signed with your signing secret, so Bolt's request verification passes. A request URL needs `SLACK_TWIN_SIGNING_SECRET`; without it the simulator refuses to start:

```sh
rystic run slack-twin -d --name slack --port 8080 --scenario design_studio \
  --env SLACK_TWIN_EVENTS_URL=http://localhost:3000/slack/events \
  --env SLACK_TWIN_SIGNING_SECRET=my-signing-secret   # your app's signing secret
```

Before its first event the simulator sends your URL Slack's signed [`url_verification`](https://docs.slack.dev/reference/events/url_verification) request, as Slack does when you save a request URL in your app's settings: `{"token":…,"challenge":…,"type":"url_verification"}`. Answer HTTP 200 with the `challenge`, as text, as JSON `{"challenge":…}` or form-encoded; Bolt does this for you. Until an answer passes, events wait, and `GET /_rystic/egress` says why under `delivery.handshake` (`status` and `reason`). The simulator sends it once at startup and, while it has not passed, again before the next event; a reset or a scenario load keeps a passed one. Slack sends none when `apps.manifest.update` sets the URL, and `SLACK_TWIN_URL_VERIFICATION=off` stands for that app. The interactivity URL is never verified, as on Slack.

Your app acks an event with a 2xx within 3 seconds. A delivery that is not acked is retried after 0 seconds, 1 minute and 5 minutes with the same body and a fresh signature made when it is sent. Each retry carries the headers `x-slack-retry-num` and `x-slack-retry-reason`. A non-2xx answer carrying `x-slack-no-retry: 1` ends that event's retries, as on Slack.

A simulator running in a container reaches your machine at `host.docker.internal`, not `localhost`. In either mode, a URL on a Slack host inside an event is on the origin of your latest Web API call, as the Web API answers it, unless `RYSTIC_PRESERVE_SERVICE_URLS=1` keeps the service hosts.

### Which calls send which events

| Call | Events |
|---|---|
| `chat.postMessage` | `message` (`thread_broadcast` for a broadcast reply), then `app_mention` when the text mentions a bot user |
| a `chat.scheduleMessage` message falling due | the same events as `chat.postMessage`, when the simulator clock reaches its `post_at` |
| `chat.update` | `message` / `message_changed` |
| `chat.delete` | `message` / `message_deleted` |
| `conversations.create` | `channel_created` (public channels only), `member_joined_channel`, `message` / `channel_join` |
| `conversations.invite` | `member_joined_channel` and `message` / `channel_join` per user added |
| `conversations.leave` | `member_left_channel` |
| `conversations.rename` | `channel_rename` (public channels only) |
| `conversations.archive` | `channel_archive` (public channels only) |
| `files.completeUploadExternal` with `channel_id` | `file_shared`, then `message` / `file_share` |
| `pins.add` | `pin_added` |
| `reactions.add`, `reactions.remove` | `reaction_added`, `reaction_removed` |

A post made [as a person](https://www.rystic.ai/docs/simulators/slack/features/identities.md) with `X-Rystic-Surface: client` arrives as `message` and `app_mention` events with a `client_msg_id` and no `bot_id`, `app_id` or `bot_profile`, as a message typed in the Slack client does, so an app that ignores bot messages starts a turn on it. So does a message the person scheduled with the header, when it falls due. A post or schedule without the header keeps its app attribution, as on Slack.

### A member leaving

The simulator serves no `admin.users.*`, so a test deactivates a member with a seed, `{"state_user": {"<id>": {"deleted": true}}}` posted to `/_rystic/seed` or as a scenario timeline `seed` event. Every app then receives one [`user_change`](https://docs.slack.dev/reference/events/user_change) with the whole user and `deleted: true`, and so it does for a reactivation or for a seed that changes another field of the user object, such as `profile` or `real_name`; a change to `presence` alone sends none.

As on Slack, a deactivated member leaves every public and private channel but keeps its DMs and stays in `users.list` with `deleted: true`. Both `conversations.members` and `num_members` exclude that member, and a reactivation adds it back to no channel. A boot seed or a scenario load sends nothing.

### Where delivery differs from Slack

- Events from every conversation are delivered, not only those the bot is in, and `app_mention` fires for a mention of any bot user.
- Request-URL delivery follows no redirect, so a 3xx answer is a failed attempt, where Slack follows two. Slack refuses to save a URL that fails `url_verification`, so it sends no event there at all; the simulator holds the events and sends them once the URL passes.
- A request-URL retry runs on the wall clock, and moving the simulator's clock past its due time with `/_rystic/clock` sends it at once. Socket Mode redeliveries run on the wall clock alone.
- Not delivered: `message` / `channel_name` and `message` / `channel_archive`, the hidden `message_changed` Slack sends after a broadcast reply, `user_change` for `users.profile.set` and `users.setPhoto`, and events for private-channel renames and archives.
- A reaction event names the emoji as your call sent it; Slack reports some aliases by their canonical name (`thumbsup` arrives as `+1`).
- A seed that changes only a profile's custom `fields` sends `user_change`, where Slack sends none. A deactivated member leaves its channels without a `member_left_channel` event.

### Waiting for your bot's turn

After your driver acts, your bot's turn is several events, acks and Web API calls, and only the simulator sees when it ends. `GET /_rystic/settle` answers `"settled":true` once no event or interaction awaits your bot's ack (in request URL mode, its 2xx) and no Web API call has landed for `quiet_ms` of wall time (default 500). At `timeout_ms` (default 30000; both at most 60000) it answers `"settled":false` with `pending`, what is still open.

Calls made with `X-Rystic-Surface: client`, from the state UI or by a scheduled event never count, so your driver's own post does not. `principal=UWDCBOT0001` watches only calls made as that user. A redelivery due later never holds it and is listed in `scheduled`.

`last_call_seq` is the last `seq` in `/_rystic/calls`. Settle before you act and again after, and the calls between the two seqs are the bot's turn. Quiet is wall time, not the simulator's clock, because your bot runs in real time: set `quiet_ms` above the longest pause inside one turn, such as a model call. `rystic settle <instance>` and the MCP server's `settle` tool ask the same route.

---

Source: https://www.rystic.ai/docs/simulators/slack/features/events-api · Markdown: https://www.rystic.ai/docs/simulators/slack/features/events-api.md
