Hubzoid
Chat surfaces

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, 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.

  1. Verify request
  2. Drop duplicates
  3. Check contact list
  4. Load history
  5. Ask the agent
  6. Send reply
  7. 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 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

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 for putting a hub behind a public address.

For a separate process or a systemd unit:

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, 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.

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
ColumnRule
phoneRequired. 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.
emailRequired 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.
groupsOptional. Separate several groups with ;. Groups unlock restricted tools on hubs whose access still comes from chat app groups.
Anything elseAllowed, 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.

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.

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.

Prop

Type

WhatsApp

WhatsApp uses the Meta WhatsApp Cloud API.

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:

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.

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.

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

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:

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.

SettingDefaultEffect
DATABASE_URLSQLite at <hub>/.hubzoid/hub.dbWhere history is stored. A postgresql+psycopg:// URL moves it to PostgreSQL.
INBOUND_HISTORY_MAX40Messages kept and sent per chat (about 20 turns). Older ones are deleted as new ones arrive.
INBOUND_HISTORY_TTL_DAYSoffDeletes 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 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:

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, 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.

Reference

VariableDefaultPurpose
WHATSAPP_VERIFY_TOKEN, WHATSAPP_APP_SECRET, WHATSAPP_TOKEN, WHATSAPP_PHONE_NUMBER_IDnoneWhatsApp credentials. All four turn the surface on.
TELEGRAM_BOT_TOKEN, TELEGRAM_WEBHOOK_SECRETnoneTelegram credentials. Both turn the surface on.
TELEGRAM_STREAMtrueEdit-in-place streaming on Telegram.
INBOUND_STREAM_INTERVAL1.0Seconds between Telegram edits, minimum 0.8.
INBOUND_HISTORY_MAX40Messages kept per chat.
INBOUND_HISTORY_TTL_DAYSoffAge limit for history.
HUBZOID_INBOUND_PORT8100Loopback port of the inbound server.
HUBZOID_HUB_SLUGfolder namePins the /webhooks/<hub> segment.
HUBZOID_MAX_UPLOAD_BYTES25 MiBPer-attachment limit, shared with Slack and the API.
INBOUND_MSG_*built-in EnglishFixed replies, listed above.

Next steps

Read this page as Markdown

Choose which cookies Hubzoid can use. You can change this at any time from Cookie settings in the footer. Read the Cookie Policy for details.