# Give each person who texts you a Slack channel

> Build a small Slack app that opens a private channel for each person who texts your number, posts their texts there, and sends whatever your team writes in the channel back to them as a text from your number.

Maya manages the office at Coastline Property Management. Tenants text the office number about leaks, lockouts and late rent, and three people answer them: Maya, the leasing agent and the maintenance lead. Until now that meant passing one phone around. With this guide, each tenant who texts gets a private Slack channel of their own, such as `#text-15555550123`. Their texts appear there as they come in. Whatever the team writes in the channel goes back to the tenant as a text from the office number, and a thread under any message is a place to talk it over without the tenant seeing a word.

## What you’ll have

- A private Slack channel for each person who texts you. It’s made the first time they text, and your team is in it.
- Their texts posted in their channel, and the texts you send them from anywhere else (the Mac, your iPhone, BlueBot’s inbox) posted there too, so the channel holds the whole conversation.
- Every top-level message in the channel sent as a text from your number. A tick on the message once it’s sent, and a reply in its thread when it’s waiting or wasn’t sent.
- Threads that stay in Slack, so the team can talk about a text before answering it.

If you only want to hear about new texts in Slack and answer them elsewhere, you need no code: add a Slack incoming webhook under **Settings** in the BlueBot dashboard and turn on alerts for new texts. [Slack recipes](https://usebluebot.com/docs/recipes/slack)

## What you need

- A BlueBot account with your Mac connected. A number used only for the business is best, because of the next point.
- **Which chats to copy** set to **Everything** under **Settings → Sending rules**, so people who text you first get a channel. With the default, BlueBot copies only chats with people you’ve texted through BlueBot. [Receiving texts](https://usebluebot.com/docs/sending/receiving)
- A Slack workspace where you can install an app.
- Somewhere to run a small server with an `https` address: Node.js 18 or later, or Python 3.10 or later. While you build, a tunnel to your laptop is enough. [Use a tunnel](https://usebluebot.com/docs/webhooks/testing#use-a-tunnel)

## How it fits together

The server talks to both sides. BlueBot posts a webhook to it for each text, and it posts the text to the person’s channel through the Slack API. Slack posts an event to it for each message in those channels, and it sends the message through the BlueBot API.

Each channel’s topic holds the conversation it belongs to, such as `bluebot:+15555550123`. That’s the only record the server keeps: when it starts, it reads the topics of the channels it’s in and knows every conversation again. A group chat gets a channel too, keyed by its `chat_id`, and messages in it go to the whole group.

Each text sent from Slack carries the Slack channel, the message and who wrote it in its [metadata](https://usebluebot.com/docs/concepts/metadata). BlueBot echoes metadata on every webhook about the text, which is how the server finds the Slack message to tick or to answer in a thread.

### Make the Slack app

1. Open [api.slack.com/apps](https://api.slack.com/apps), press **Create New App**, choose **From scratch**, name it “Tenant texts” and pick your workspace.
2. Open **OAuth & Permissions**. Under **Bot Token Scopes**, add `groups:write` (make private channels, set their topic and invite people), `groups:read` (list them when the server starts), `groups:history` (read messages in them), `chat:write` (post) and `reactions:write` (tick a message once it’s sent).
3. Press **Install to Workspace** and allow it. Copy the **Bot User OAuth Token**, which starts `xoxb-`.
4. Open **Basic Information** and copy the **Signing Secret** under **App Credentials**.
5. Find the member ID of each person who answers texts: in Slack, open their profile, press the **⋮** button and choose **Copy member ID**. It starts with `U`.

Leave **Event Subscriptions** for later. Slack checks the address when you save it, so the server has to be running first.

### Make a BlueBot key

In the BlueBot dashboard, open **Developers → API keys** and make a key named “Slack” with the scope `messages:send`. Put everything in the server’s environment:

```bash
export BLUEBOT_API_KEY="bb_live_…"
export SLACK_BOT_TOKEN="xoxb-…"
export SLACK_SIGNING_SECRET="…"
export SLACK_TEAM_USER_IDS="U0123ABCD,U0456EFGH,U0789IJKL"   # who joins each new channel
```

### Write the server

One small server takes both Slack’s events at `/slack/events` and BlueBot’s webhooks at `/bluebot/webhooks`. It uses Bolt, Slack’s own framework, which checks each Slack request’s signature for you. The BlueBot route checks BlueBot’s signature the same way.

```ts title="server.ts"
// npm install @slack/bolt @usebluebot/sdk express
import express from 'express';
import { App, ExpressReceiver, type GenericMessageEvent } from '@slack/bolt';
import BlueBot, { APIError, WebhookVerificationError } from '@usebluebot/sdk';

const bluebot = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY });
const receiver = new ExpressReceiver({ signingSecret: process.env.SLACK_SIGNING_SECRET!, endpoints: '/slack/events' });
const slack = new App({ token: process.env.SLACK_BOT_TOKEN, receiver });
const TEAM = (process.env.SLACK_TEAM_USER_IDS ?? '').split(',').map((id) => id.trim()).filter(Boolean);

// A conversation's key is the person's number or address, or chat:<id> for a group chat.
const channelByKey = new Map<string, string>();
const keyByChannel = new Map<string, string>();
const handled = new Set<string>(); // BlueBot event ids; use a table in production
const queues = new Map<string, Promise<unknown>>();

function inOrder<T>(key: string, run: () => Promise<T>): Promise<T> {
  const next = (queues.get(key) ?? Promise.resolve()).then(run, run); // two first texts at once make one channel
  queues.set(key, next.catch(() => undefined));
  return next;
}

function remember(key: string, channel: string) {
  channelByKey.set(key, channel);
  keyByChannel.set(channel, key);
}

// Slack sends &, < and > escaped, and links and email addresses wrapped in < >.
function plain(text: string) {
  return text
    .replace(/<((?:https?|mailto):[^|>]+)\|([^>]*)>/g, (_, target: string, label: string) => (target.startsWith('mailto:') ? label : target))
    .replace(/<((?:https?|mailto):[^>]+)>/g, '$1')
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/&amp;/g, '&');
}

const slackSafe = (text: string) => text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');

function channelName(key: string) {
  const id = key.startsWith('chat:') ? `group-${key.slice(-8)}` : key.replace(/^\+/, '');
  return `text-${id.toLowerCase().replace(/[^a-z0-9_-]+/g, '-')}`.slice(0, 72);
}

async function loadChannels() {
  let cursor: string | undefined;
  do {
    const page = await slack.client.conversations.list({ types: 'private_channel', exclude_archived: true, limit: 200, cursor });
    for (const channel of page.channels ?? []) {
      const topic = plain(channel.topic?.value ?? '');
      if (channel.id && topic.startsWith('bluebot:')) remember(topic.slice('bluebot:'.length), channel.id);
    }
    cursor = page.response_metadata?.next_cursor || undefined;
  } while (cursor);
}

async function channelFor(key: string) {
  const known = channelByKey.get(key);
  if (known) return known;
  let created;
  try {
    created = await slack.client.conversations.create({ name: channelName(key), is_private: true });
  } catch (error: any) {
    if (error?.data?.error !== 'name_taken') throw error; // an archived channel keeps its name
    created = await slack.client.conversations.create({ name: `${channelName(key)}-${Date.now().toString(36).slice(-6)}`, is_private: true });
  }
  const channel = created.channel!.id!;
  await slack.client.conversations.setTopic({ channel, topic: `bluebot:${key}` });
  if (TEAM.length) await slack.client.conversations.invite({ channel, users: TEAM.join(',') });
  remember(key, channel);
  return channel;
}

const SENT_FROM: Record<string, string> = { mac: 'the Mac or an iPhone', inbox: 'BlueBot’s inbox' };

async function handle(event: ReturnType<typeof bluebot.webhooks.verify>) {
  if (event.type === 'message.received') {
    const message = event.data;
    const key = message.chatId ? `chat:${message.chatId}` : message.from;
    if (!key) return;
    await inOrder(key, async () => {
      const channel = await channelFor(key);
      const lines = [];
      if (message.text) lines.push(message.chatId ? `*${slackSafe(message.from ?? 'Someone')}:* ${slackSafe(message.text)}` : slackSafe(message.text));
      if (message.files.length) lines.push(`_Sent ${message.files.length === 1 ? 'a picture or file' : `${message.files.length} pictures or files`}. Open the conversation in BlueBot to see them._`);
      await slack.client.chat.postMessage({ channel, text: lines.join('\n'), unfurl_links: false, unfurl_media: false });
    });
  }

  if (event.type === 'message.sent') {
    const message = event.data;
    const { slack_channel: channel, slack_ts: ts } = message.metadata;
    if (channel && ts) {
      // Sent from Slack: tick the Slack message.
      await slack.client.reactions.add({ channel, timestamp: ts, name: 'white_check_mark' }).catch((error: any) => {
        if (error?.data?.error !== 'already_reacted') throw error;
      });
      return;
    }
    // Sent some other way: post it, but only into a conversation that already has a channel.
    const key = message.chatId ? `chat:${message.chatId}` : message.to;
    const known = key && channelByKey.get(key);
    if (!known || !message.text) return;
    const from = SENT_FROM[message.source] ?? `BlueBot (${message.source})`;
    await slack.client.chat.postMessage({ channel: known, text: `_Sent from ${from}:_ ${slackSafe(message.text)}`, unfurl_links: false });
  }

  if (event.type === 'message.failed' || event.type === 'message.canceled') {
    const message = event.data;
    const { slack_channel: channel, slack_ts: ts } = message.metadata;
    if (!channel || !ts) return;
    const why =
      event.type === 'message.failed'
        ? `Not sent: ${message.failure?.detail ?? message.failure?.reason ?? 'the Mac couldn’t send it'}.`
        : message.cancelReason === 'opted_out'
          ? 'Not sent: they asked not to get texts from you (they texted a stop word).'
          : 'Not sent: it was canceled.';
    await slack.client.chat.postMessage({ channel, thread_ts: ts, text: why });
  }
}

// BlueBot's webhooks
receiver.router.post('/bluebot/webhooks', express.raw({ type: 'application/json' }), async (req, res) => {
  let event;
  try {
    event = bluebot.webhooks.verify(req.body, req.headers, process.env.BLUEBOT_WEBHOOK_SECRET!);
  } catch (error) {
    return res.status(error instanceof WebhookVerificationError ? 400 : 500).end();
  }
  if (handled.has(event.id)) return res.status(204).end();
  try {
    await handle(event);
  } catch (error: any) {
    console.error('Slack refused', event.id, error?.data?.error ?? 'unknown'); // Slack's error code, never the text's words
    return res.status(500).end(); // BlueBot sends it again
  }
  handled.add(event.id);
  res.status(204).end();
});

// Messages in the texting channels
slack.message(async ({ message, client }) => {
  const posted = message as GenericMessageEvent;
  if (posted.subtype || posted.bot_id || !posted.text) return; // edits, joins, topics and bots
  if (posted.thread_ts && posted.thread_ts !== posted.ts) return; // threads stay in Slack
  const key = keyByChannel.get(posted.channel);
  if (!key) return; // not a texting channel

  const reply = (text: string) => client.chat.postMessage({ channel: posted.channel, thread_ts: posted.ts, text });
  if (/<[@#!]/.test(posted.text)) {
    await reply('Not sent. A message that mentions someone stays in Slack. Mention people in a thread instead.');
    return;
  }
  const text = plain(posted.text).trim();
  if (text.length > 2000) {
    await reply('Not sent. A text is at most 2,000 characters.');
    return;
  }

  try {
    const sent = await bluebot.messages.send(
      {
        ...(key.startsWith('chat:') ? { chatId: key.slice('chat:'.length) } : { to: key }),
        text,
        metadata: { slack_channel: posted.channel, slack_ts: posted.ts, slack_user: posted.user },
      },
      { idempotencyKey: `slack-${posted.channel}-${posted.ts}` }, // Slack's retries never send twice
    );
    if (sent.hold) await reply(`Waiting: ${sent.hold.description}`);
  } catch (error) {
    if (!(error instanceof APIError)) throw error;
    await reply(`Not sent. ${error.message}`);
  }
});

async function start() {
  await loadChannels();
  await slack.start(Number(process.env.PORT ?? 3000));
  console.log(`Listening on ${process.env.PORT ?? 3000}, with ${channelByKey.size} texting channels`);
}

start();
```

```python title="server.py"
# pip install slack-bolt bluebot flask
import os
import re
import threading
import time

from flask import Flask, request
from slack_bolt import App
from slack_bolt.adapter.flask import SlackRequestHandler
from slack_sdk.errors import SlackApiError
from bluebot import APIError, BlueBot, WebhookVerificationError

bluebot = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"])
slack = App(token=os.environ["SLACK_BOT_TOKEN"], signing_secret=os.environ["SLACK_SIGNING_SECRET"])
app = Flask(__name__)
slack_handler = SlackRequestHandler(slack)
TEAM = [user.strip() for user in os.environ.get("SLACK_TEAM_USER_IDS", "").split(",") if user.strip()]

# A conversation's key is the person's number or address, or chat:<id> for a group chat.
channel_by_key: dict[str, str] = {}
key_by_channel: dict[str, str] = {}
handled: set[str] = set()  # BlueBot event ids; use a table in production
locks: dict[str, threading.Lock] = {}
locks_guard = threading.Lock()
SENT_FROM = {"mac": "the Mac or an iPhone", "inbox": "BlueBot's inbox"}

def lock_for(key: str) -> threading.Lock:
    with locks_guard:  # two first texts at once make one channel
        return locks.setdefault(key, threading.Lock())

def remember(key: str, channel: str) -> None:
    channel_by_key[key] = channel
    key_by_channel[channel] = key

def plain(text: str) -> str:
    """Slack sends &, < and > escaped, and links and email addresses wrapped in < >."""
    text = re.sub(r"<mailto:[^|>]+\|([^>]*)>", r"\1", text)
    text = re.sub(r"<((?:https?|mailto):[^|>]+)(?:\|[^>]*)?>", r"\1", text)
    return text.replace("&lt;", "<").replace("&gt;", ">").replace("&amp;", "&")

def slack_safe(text: str) -> str:
    return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")

def channel_name(key: str) -> str:
    ident = f"group-{key[-8:]}" if key.startswith("chat:") else key.lstrip("+")
    return ("text-" + re.sub(r"[^a-z0-9_-]+", "-", ident.lower()))[:72]

def load_channels() -> None:
    cursor = None
    while True:
        page = slack.client.conversations_list(types="private_channel", exclude_archived=True, limit=200, cursor=cursor)
        for channel in page["channels"]:
            topic = plain(channel.get("topic", {}).get("value", ""))
            if topic.startswith("bluebot:"):
                remember(topic[len("bluebot:"):], channel["id"])
        cursor = page.get("response_metadata", {}).get("next_cursor")
        if not cursor:
            break

def channel_for(key: str) -> str:
    if key in channel_by_key:
        return channel_by_key[key]
    try:
        created = slack.client.conversations_create(name=channel_name(key), is_private=True)
    except SlackApiError as error:
        if error.response["error"] != "name_taken":  # an archived channel keeps its name
            raise
        suffix = format(int(time.time()), "x")[-6:]
        created = slack.client.conversations_create(name=f"{channel_name(key)}-{suffix}", is_private=True)
    channel = created["channel"]["id"]
    slack.client.conversations_setTopic(channel=channel, topic=f"bluebot:{key}")
    if TEAM:
        slack.client.conversations_invite(channel=channel, users=",".join(TEAM))
    remember(key, channel)
    return channel

def handle(event) -> None:
    if event.type == "message.received":
        message = event.data
        key = f"chat:{message.chat_id}" if message.chat_id else message.from_
        if not key:
            return
        with lock_for(key):
            channel = channel_for(key)
            lines = []
            if message.text:
                words = slack_safe(message.text)
                lines.append(f"*{slack_safe(message.from_ or 'Someone')}:* {words}" if message.chat_id else words)
            if message.files:
                count = len(message.files)
                what = "a picture or file" if count == 1 else f"{count} pictures or files"
                lines.append(f"_Sent {what}. Open the conversation in BlueBot to see them._")
            slack.client.chat_postMessage(channel=channel, text="\n".join(lines), unfurl_links=False, unfurl_media=False)

    elif event.type == "message.sent":
        message = event.data
        channel, ts = message.metadata.get("slack_channel"), message.metadata.get("slack_ts")
        if channel and ts:  # sent from Slack: tick the Slack message
            try:
                slack.client.reactions_add(channel=channel, timestamp=ts, name="white_check_mark")
            except SlackApiError as error:
                if error.response["error"] != "already_reacted":
                    raise
            return
        # Sent some other way: post it, but only into a conversation that already has a channel.
        key = f"chat:{message.chat_id}" if message.chat_id else message.to
        known = channel_by_key.get(key) if key else None
        if known and message.text:
            source = SENT_FROM.get(message.source, f"BlueBot ({message.source})")
            slack.client.chat_postMessage(channel=known, text=f"_Sent from {source}:_ {slack_safe(message.text)}", unfurl_links=False)

    elif event.type in ("message.failed", "message.canceled"):
        message = event.data
        channel, ts = message.metadata.get("slack_channel"), message.metadata.get("slack_ts")
        if not channel or not ts:
            return
        if event.type == "message.failed":
            detail = (message.failure.detail or message.failure.reason) if message.failure else "the Mac couldn't send it"
            why = f"Not sent: {detail}."
        elif message.cancel_reason == "opted_out":
            why = "Not sent: they asked not to get texts from you (they texted a stop word)."
        else:
            why = "Not sent: it was canceled."
        slack.client.chat_postMessage(channel=channel, thread_ts=ts, text=why)

@app.post("/bluebot/webhooks")
def bluebot_webhook():
    try:
        event = bluebot.webhooks.verify(request.get_data(), request.headers, secret=os.environ["BLUEBOT_WEBHOOK_SECRET"])
    except WebhookVerificationError:
        return "Bad signature", 400
    if event.id in handled:
        return "", 204
    try:
        handle(event)
    except SlackApiError as error:
        print("Slack refused", event.id, error.response["error"])  # Slack's error code, never the text's words
        return "", 500  # BlueBot sends it again
    handled.add(event.id)
    return "", 204

@app.post("/slack/events")
def slack_events():
    return slack_handler.handle(request)

@slack.event("message")
def on_message(event, client):
    if event.get("subtype") or event.get("bot_id") or not event.get("text"):
        return  # edits, joins, topics and bots
    if event.get("thread_ts") and event["thread_ts"] != event["ts"]:
        return  # threads stay in Slack
    key = key_by_channel.get(event["channel"])
    if not key:
        return  # not a texting channel

    def reply(text: str) -> None:
        client.chat_postMessage(channel=event["channel"], thread_ts=event["ts"], text=text)

    if re.search(r"<[@#!]", event["text"]):
        reply("Not sent. A message that mentions someone stays in Slack. Mention people in a thread instead.")
        return
    text = plain(event["text"]).strip()
    if len(text) > 2000:
        reply("Not sent. A text is at most 2,000 characters.")
        return

    target = {"chat_id": key[len("chat:"):]} if key.startswith("chat:") else {"to": key}
    try:
        sent = bluebot.messages.send(
            **target,
            text=text,
            metadata={"slack_channel": event["channel"], "slack_ts": event["ts"], "slack_user": event.get("user", "")},
            idempotency_key=f"slack-{event['channel']}-{event['ts']}",  # Slack's retries never send twice
        )
    except APIError as error:
        reply(f"Not sent. {error.message}")
        return
    if sent.hold:
        reply(f"Waiting: {sent.hold.description}")

load_channels()
# Run with: flask --app server run --port 3000
```

A few choices in that code are worth knowing:

- **A message that mentions someone is never sent.** `@Dana can you take this?` is meant for the team, so it stays in Slack with a reply saying so. The habit to teach the team is simple: write to the tenant in the channel, talk about it in a thread.
- **The idempotency key is the Slack message.** Slack sends an event again when it doesn’t hear back fast enough, and the key `slack-<channel>-<ts>` makes BlueBot answer a second send with the first text rather than a second one. [Idempotency](https://usebluebot.com/docs/concepts/idempotency)
- **Pictures stay in BlueBot.** A file’s address from BlueBot works for an hour, so the channel says a picture came rather than posting a link that stops working. To copy them into Slack, add the `files:write` scope and upload each file with `files.uploadV2` within the hour.
- **Nothing is logged but ids and error codes.** Texts from tenants are private; keep them out of logs.

### Run it and connect both sides

Start the server where it has an `https` address, or on your laptop with a tunnel to port 3000.

```bash
npx tsx server.ts                          # or: flask --app server run --port 3000
```

**In Slack:** open your app’s **Event Subscriptions**, turn on **Enable Events** and set the **Request URL** to your address with `/slack/events` at the end. Slack checks it at once and shows **Verified**. Under **Subscribe to bot events**, add `message.groups` and press **Save Changes**. If Slack asks you to reinstall the app, do.

**In BlueBot:** make a webhook to your address with `/bluebot/webhooks` at the end, in **Developers → Webhooks** or with the API:

```bash
curl -X POST "https://usebluebot.com/api/v1/webhooks" \
  -H "Authorization: Bearer $BLUEBOT_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://texts.coastlinepm.example/bluebot/webhooks",
    "events": [
      "message.received",
      "message.sent",
      "message.failed",
      "message.canceled"
    ],
    "description": "Slack channels"
  }'
```

```ts
import BlueBot from '@usebluebot/sdk';

const client = new BlueBot({ apiKey: process.env.BLUEBOT_API_KEY });

const webhook = await client.webhooks.create({
  url: 'https://texts.coastlinepm.example/bluebot/webhooks',
  events: [
    'message.received',
    'message.sent',
    'message.failed',
    'message.canceled',
  ],
  description: 'Slack channels',
});
console.log(webhook);
```

```python
import os
from bluebot import BlueBot

client = BlueBot(api_key=os.environ["BLUEBOT_API_KEY"])

webhook = client.webhooks.create(
    url="https://texts.coastlinepm.example/bluebot/webhooks",
    events=[
        "message.received",
        "message.sent",
        "message.failed",
        "message.canceled",
    ],
    description="Slack channels",
)
print(webhook)
```

Copy the `secret` from the answer into `BLUEBOT_WEBHOOK_SECRET` and restart the server. The secret is shown once. [Webhooks](https://usebluebot.com/docs/webhooks)

### Test it with your own number

From your own phone, text your business number: “Testing the Slack channels.”

| You got                                                                                     | It means                                                               | Do next                                                                                                                                                                       |
| ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| A new private channel, `#text-` and your number, with your text in it and your team invited | BlueBot to Slack works.                                                | Write “Got it” in the channel, not in a thread.                                                                                                                               |
| “Got it” on your phone, and a tick on the Slack message                                     | Slack to BlueBot works.                                                | Reply from your phone and watch it appear in the channel.                                                                                                                     |
| No channel                                                                                  | BlueBot didn’t copy your text, or the webhook didn’t reach the server. | Check **Which chats to copy** is **Everything**, then open the webhook in **Developers → Webhooks** and read its deliveries. [Retries and deliveries](https://usebluebot.com/docs/webhooks/retries) |
| A channel, but your message in it never went                                                | Slack isn’t sending the app events.                                    | Check **Event Subscriptions** shows **Verified** and lists `message.groups`, and that the app is in the channel (it made it, so it should be).                                |
| A thread reply: “Waiting: …”                                                                | The text is accepted and waiting, often for quiet hours.               | Nothing; it goes when the hold ends. [Holds](https://usebluebot.com/docs/concepts/holds)                                                                                                            |
| A thread reply: “Not sent. …”                                                               | BlueBot refused the text, and the reply says why.                      | Fix what it names. A refusal needs a new message, since its key is spent. [Errors](https://usebluebot.com/docs/errors)                                                                              |

## When it goes wrong

- **Somebody texts STOP.** BlueBot puts them on your stop list and cancels anything sent to them after that, so a message in their channel gets the thread reply “Not sent: they asked not to get texts from you.” Leave the channel be, or archive it. [Stop words](https://usebluebot.com/docs/concepts/stop-words)
- **The channel list gets long.** Archive a channel when the conversation is over. If that person texts again, the server makes a fresh channel with a short suffix on its name, since Slack keeps the archived channel’s name.
- **Your Mac is asleep.** Texts from Slack get the reply “Waiting: …” and go when the Mac wakes. Nothing comes in from tenants while it’s asleep either. [The Mac app](https://usebluebot.com/docs/concepts/mac-app)
- **The server was down for a while.** BlueBot keeps trying each webhook for about three days, so the channels catch up when it’s back. Messages written in Slack while it was down weren’t sent; Slack gives up on events after a few tries.
- **Two people answer the same tenant at once.** Both texts go. A thread under the tenant’s message is a good place to say “I’ve got this one.”

## Next

- [Group chats](https://usebluebot.com/docs/sending/group-chats): How texts to a group work, and what to expect from one.
- [Slack recipes](https://usebluebot.com/docs/recipes/slack): Alerts in Slack with no code, and texting the on-call person from a channel.
- [Webhooks](https://usebluebot.com/docs/webhooks): Signatures, retries, replays and rotating secrets.
