---
title: WhatsApp and Telegram
description: Answer teammates on WhatsApp and Telegram through verified webhooks, with a contact list that decides who gets in, bounded history and attachments.
canonical_url: https://hubzoid.com/docs/chat/whatsapp-and-telegram
last_updated: 2026-09-27
---

# WhatsApp and Telegram

Answer teammates on WhatsApp and Telegram through verified webhooks, with a contact list that decides who gets in, bounded history and attachments.

WhatsApp and Telegram reach the Hub through webhooks: the provider posts each message to your hub, Hubzoid checks who sent it, and the agent answers in the same chat. Unlike Slack, the webhook address is public, so a contact list in the hub decides who may talk to the agent at all.

## When to use this

Use these surfaces for people who work from a phone: store staff, field coordinators, a manager on the move. Each person gets the same Hub as the [web chat](https://hubzoid.com/docs/chat/web-chat), limited to the numbers you list.

## How a message is handled

Every inbound route lives under `/webhooks/<hub>/<surface>`, for example `https://hub.example.com/webhooks/stock-desk/whatsapp`. The `<hub>` part is the hub's folder name in lowercase, with anything other than letters and digits turned into hyphens.

Verify request → Drop duplicates → Check contact list → Load history → Ask the agent → Send reply → Save the turn

Unknown senders stop at the contact list. They never reach the model, tools or data.

The provider gets its `200 ok` straight away and the work runs in the background, because Meta and Telegram redeliver when an answer is slow. The duplicate check absorbs those redeliveries.

After the checks, the message goes to the bridge's [OpenAI-compatible API](https://hubzoid.com/docs/chat/openai-compatible-api) with the sender's email and groups from the contact list, the surface name (`whatsapp` or `telegram`) and the recent history of that chat.

## Run the inbound server

```bash
hubzoid run my-hub --whatsapp --telegram
```

Combine the flags freely with `--slack` and `--webhook`. Each surface starts only when its variables are set. A missing one is named in a warning and that surface is skipped, while the rest of the hub keeps running.

All inbound surfaces share one server on a loopback port, `127.0.0.1:8100` by default (`HUBZOID_INBOUND_PORT`). The hub's public port forwards `/webhooks/<hub>` to it, so the providers need only reach your public HTTPS address. See [Single server](https://hubzoid.com/docs/deploy/single-server) for putting a hub behind a public address.

For a separate process or a systemd unit:

```bash
hubzoid inbound run my-hub
hubzoid inbound systemd my-hub > /etc/systemd/system/hubzoid-inbound@my-hub.service
```

The inbound server needs the hub's bridge running. `hubzoid inbound systemd` accepts `--user` and `--python` like the Slack unit, and the unit requires `hubzoid@my-hub.service`. The `/webhooks/<hub>` route on the public port is added only when `hubzoid run` starts the inbound surfaces itself. When the inbound server runs on its own, route `/webhooks/<hub>` to its port in your reverse proxy.

Under a [gateway](https://hubzoid.com/docs/deploy/gateway), every hub with an inbound surface gets its own `/webhooks/<hub>` route on the shared front door. Give each of those hubs a different `HUBZOID_INBOUND_PORT`. The gateway refuses to start two inbound hubs on the same port. If two hubs share a folder name and the gateway had to rename one, pin the path segment with `HUBZOID_HUB_SLUG`.

## The contact list

The contact list lives in the hub's `identity/` folder. It is the allowlist: a number that is not on it gets nothing.

```text title="my-hub/identity/access.csv"
phone,email,groups,store
+44 7700 900101,asha@example.com,stock-lead,harbor
447700900102,ben@example.com,,market-street
447700900103,carmen@example.com,stock-lead;finance,head-office
```

| Column | Rule |
| --- | --- |
| `phone` | Required. Only the digits are compared, so `+44 7700 900101` and `447700900101` match. Store numbers in full international form with the country code and no leading national zero. |
| `email` | Required for access. It is the person's identity everywhere in Hubzoid and should match the address they use in the chat app or the Console. A row without an email is treated as not registered. |
| `groups` | Optional. Separate several groups with `;`. Groups unlock [restricted tools](https://hubzoid.com/docs/guides/restrict-tools) on hubs whose access still comes from chat app groups. |
| Anything else | Allowed, for example a store or a name. |

Header names ignore case and surrounding spaces. Blank rows are skipped. Rows that share an email combine their groups, so one person can have several numbers. The file is reread when it changes, so an edit applies to the next message with no restart. Save it in one step (write a new file, then rename) so a half-written file is never read. If the file cannot be read, every lookup denies.

The same list also adds groups on other surfaces: a person who signs in to the web chat or connects over MCP with the same email gets the list's groups on top of their own. It never opens the MCP front door.

### A contact list backed by your own system

To look people up in a CRM or directory instead, put a function in `identity/access.py`. It wins over the CSV when both exist.

```python title="my-hub/identity/access.py"
def resolve(surface, handle):
    """handle is the sender's phone number. Return a record or None."""
    row = staff_directory.find_by_phone(handle)
    if row is None:
        return None
    return {"email": row.email, "groups": row.roles}


def groups_for_email(email):
    """Optional. Lets the web chat and MCP use the same groups."""
    row = staff_directory.find_by_email(email)
    return row.roles if row else []
```

Returning `None`, or raising an error, denies the sender. Without `groups_for_email`, the function is only ever asked about phone numbers.

> **Warning: No identity folder means no access**
>
> A hub with WhatsApp or Telegram turned on but no `identity/access.csv` or `identity/access.py` rejects every sender. The inbound server logs a warning at start when this happens.

## Unknown senders and fixed replies

The handshake and refusal replies are fixed text sent without the model. Override any of them per hub in `.env`.

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `INBOUND_MSG_NOT_REGISTERED` | `string` | `"This number is not registered for access."` | Sent to a WhatsApp number that is not on the list, and to a Telegram contact whose shared number is not on it. |
| `INBOUND_MSG_VERIFY_PROMPT` | `string` | `"Please tap the button below to verify your number."` | Telegram reply to /start, with a Share my number button. |
| `INBOUND_MSG_PLEASE_VERIFY` | `string` | `"Please verify first. Tap the button below to share your number."` | Telegram reply to a message from someone who has not verified yet. |
| `INBOUND_MSG_VERIFIED` | `string` | `"You are verified. How can I help?"` | Telegram confirmation after a successful verification. |
| `INBOUND_MSG_NOT_OWN_CONTACT` | `string` | `"Please share your own number to verify."` | Telegram reply when someone shares a contact that is not their own. |
| `INBOUND_MSG_NO_RESPONSE` | `string` | `"Sorry, I do not have a response for that. Please try again."` | Sent when the agent produced no visible text, so the person always gets an answer. |

## WhatsApp

WhatsApp uses the Meta WhatsApp Cloud API.

```bash title="my-hub/.env"
WHATSAPP_VERIFY_TOKEN=any-string-you-choose
WHATSAPP_APP_SECRET=<Meta app secret, from App settings, Basic>
WHATSAPP_TOKEN=<Graph API access token>
WHATSAPP_PHONE_NUMBER_ID=<from WhatsApp API setup>
```

All four are required. In Meta's configuration:

1. **Subscribe the app's webhook**

   Set the callback URL to `https://<host>/webhooks/<hub>/whatsapp`, the verify token to your `WHATSAPP_VERIFY_TOKEN`, and subscribe to the `messages` field. Meta then calls the URL once with a `GET` handshake. Hubzoid echoes Meta's challenge only when the verify token matches.

2. **Subscribe the WhatsApp Business Account to the app**

   Call `POST /{waba-id}/subscribed_apps` on the Graph API. Without this step, messages do not reach the webhook.

What Hubzoid does with each delivery:

- Every `POST` must carry a valid `X-Hub-Signature-256`, an HMAC-SHA256 of the exact request body under your app secret. Anything else gets `403`.
- Messages are deduplicated by message id.
- Text, quick-reply buttons, interactive button and list replies, images, documents, audio, voice notes, video and stickers are accepted. A caption travels as the message text.
- The incoming message is marked as read with a typing indicator while the agent works. WhatsApp cannot edit a sent message, so the answer arrives in one piece when it is complete.
- Replies are converted to WhatsApp formatting: bold, strikethrough and monospace. Headings become bold lines, links become `label: url`, and tables are wrapped in a monospace block. A reply is capped at 4,096 characters with a note to ask for the rest.

> **Note: Meta's 24-hour window**
>
> Meta allows free-text replies only within 24 hours of the person's last message. A message your hub starts on its own, such as a scheduled morning summary, must use an approved template. `hubzoid.whatsapp.send` provides `send_text` and `send_template` for code you write, for example in a scheduled task.

## Telegram

```bash title="my-hub/.env"
TELEGRAM_BOT_TOKEN=<token from @BotFather>
TELEGRAM_WEBHOOK_SECRET=any-string-you-choose
```

Register the webhook once, with the secret Telegram will echo back on every delivery:

```bash
curl "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/setWebhook?url=https://hub.example.com/webhooks/stock-desk/telegram&secret_token=<TELEGRAM_WEBHOOK_SECRET>"
```

Every `POST` must carry the secret in `X-Telegram-Bot-Api-Secret-Token` or it gets `403`. Updates are deduplicated by update id.

### Verification by shared number

Telegram does not reveal a phone number on ordinary messages, so each person verifies once:

1. They send `/start`. The bot answers with a one-tap **Share my number** button.
2. They tap it. Hubzoid accepts only the sender's own contact, looks the number up in the contact list and stores the link between their Telegram id and that number under `<hub>/.inbound/telegram-bindings/`.
3. From then on their messages resolve through the stored number. Removing their row from the contact list removes their access.

A Telegram bot cannot message someone first, so share the bot's link with your team another way, for example by email.

What Hubzoid does with each message:

- A typing indicator stays on while the agent works.
- The answer streams in by editing one message, at most once per `INBOUND_STREAM_INTERVAL` seconds (1.0 by default, never below 0.8 because Telegram limits edits). Set `TELEGRAM_STREAM=false` to send the answer once instead.
- Photos (the largest size), documents, voice notes, audio, video and video notes are accepted, with any caption as the text.
- Replies use Telegram's HTML formatting with real links, capped at 4,096 characters.

## Attachments

Media on either surface follows the same path as the web chat and Slack. Hubzoid downloads the file from the provider and stores it in the chat's uploads folder, `<hub>/.hubzoid/chats/<chat_id>/uploads/`.

- Images are shown to the model directly, and the reference stays in history so follow-up questions can use them.
- Other files are read with `read_upload` when the agent needs them.
- Voice notes and audio are stored and referenced. The agent understands them only if the hub provides a transcription tool.
- Files larger than `HUBZOID_MAX_UPLOAD_BYTES` (25 MiB by default), or files that fail to download, are skipped without failing the message.

Attachments flow inbound only. The agent answers with text.

## History and limits

WhatsApp and Telegram do not resend the conversation, so the inbound server keeps it. Each chat's recent turns are stored in the `hz_inbound_history` table of the hub database and sent with every new message.

| Setting | Default | Effect |
| --- | --- | --- |
| `DATABASE_URL` | SQLite at `<hub>/.hubzoid/hub.db` | Where history is stored. A `postgresql+psycopg://` URL moves it to PostgreSQL. |
| `INBOUND_HISTORY_MAX` | `40` | Messages kept and sent per chat (about 20 turns). Older ones are deleted as new ones arrive. |
| `INBOUND_HISTORY_TTL_DAYS` | off | Deletes turns older than this many days. |

- History is isolated per chat (`whatsapp-<number>`, `telegram-<id>`). Two senders never see each other's conversation.
- Only the visible answer is saved, without reasoning or tool output.
- Messages from one chat are handled one at a time, in order, so a quick second message sees the first answer. Different chats run in parallel.
- History rows are keyed by chat, not by hub. Give each inbound hub its own hub database rather than pointing several at one `DATABASE_URL`.

## Restricted tools on these surfaces

[Restricted tools](https://hubzoid.com/docs/guides/restrict-tools) are off on `whatsapp` and `telegram` by default. To let verified people use them, add the surfaces to the complete list and grant the permission:

```bash title="my-hub/.env"
HUBZOID_RESTRICTED_SURFACES=owui,web,api,mcp,workflow,whatsapp,telegram
```

The setting replaces the default list, so keep the entries you still want. The access check runs in the bridge, so restart the hub after changing it. When the hub's access is managed in [the Console](https://hubzoid.com/docs/console/agents-and-access), the contact list's email must also hold **Use this agent** for the person to chat at all.

## Generic webhooks

The same inbound server can also receive machine events, such as an alert, a CI result or a form submission, at `/webhooks/<hub>/<name>`. That surface verifies a shared secret or an HMAC signature, stores each event as a file for a scheduled task to act on. It never talks to the model directly and never replies. Setup and the `on_webhook:` trigger are covered in [Webhooks](https://hubzoid.com/docs/guides/webhooks).

## Reference

| Variable | Default | Purpose |
| --- | --- | --- |
| `WHATSAPP_VERIFY_TOKEN`, `WHATSAPP_APP_SECRET`, `WHATSAPP_TOKEN`, `WHATSAPP_PHONE_NUMBER_ID` | none | WhatsApp credentials. All four turn the surface on. |
| `TELEGRAM_BOT_TOKEN`, `TELEGRAM_WEBHOOK_SECRET` | none | Telegram credentials. Both turn the surface on. |
| `TELEGRAM_STREAM` | `true` | Edit-in-place streaming on Telegram. |
| `INBOUND_STREAM_INTERVAL` | `1.0` | Seconds between Telegram edits, minimum 0.8. |
| `INBOUND_HISTORY_MAX` | `40` | Messages kept per chat. |
| `INBOUND_HISTORY_TTL_DAYS` | off | Age limit for history. |
| `HUBZOID_INBOUND_PORT` | `8100` | Loopback port of the inbound server. |
| `HUBZOID_HUB_SLUG` | folder name | Pins the `/webhooks/<hub>` segment. |
| `HUBZOID_MAX_UPLOAD_BYTES` | 25 MiB | Per-attachment limit, shared with Slack and the API. |
| `INBOUND_MSG_*` | built-in English | Fixed replies, listed above. |

## Next steps

- [Identity and access](https://hubzoid.com/docs/concepts/identity-and-access): How the contact list, chat accounts and Console grants combine.
- [Webhooks](https://hubzoid.com/docs/guides/webhooks): Receive machine events and act on them with a scheduled task.
- [Single server](https://hubzoid.com/docs/deploy/single-server): Put a hub behind a public HTTPS address the providers can reach.
