Operating Chat Channels
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page was written on 2026-10-01 against FastAPI 0.142.2, langchain 1.4.3 and langgraph 1.2.12 on Python 3.12,
Spring AI 2.0.1 on Spring Boot 4.1.1 with Java 21, Slack Bolt for Python 1.30.0, python-telegram-bot 22.8, the Teams SDK
2.1.x and the Microsoft 365 Agents SDK 1.x (see the version
baseline). The snippets are illustrative: they are written from the official documentation of each platform and are not
compiled or run by this repository. The numbers in the cost section are invented placeholders for a worked example, not
prices; every platform limit is stated as "documented as" with the page it comes from, because limits and policies change
often and should be re-read before you rely on them.
A channel adapter that works in a demo is a webhook endpoint on the public internet that triggers paid model calls on behalf of anonymous senders. Operating it means answering seven questions for every channel: who is allowed to call the endpoint, what happens when the platform delivers the same event twice, what you can see when it fails, how a single user or attacker is kept from draining the budget, which personal data you keep and for how long, what the assistant is allowed to say, and what each answer costs. This page gives a checklist per question and ties them to the channel-agnostic core described in Channel-Agnostic Architecture. The channel specifics are on the pages for Microsoft Teams, WhatsApp Business, Apple Messages for Business, Slack, Telegram & Google Chat and RCS Business Messaging; the general service concerns (authentication, rate limiting, versioning) of your own API are in Serving RAG as an API.
The order matters: verification comes before everything that costs money or touches state, deduplication comes before the
slow work, and the acknowledgement is sent before the model is called, because most platforms retry when they do not get a
fast 200.
Secrets and signature verification
Each platform authenticates its callbacks differently. The adapter must verify the authenticity of every inbound request on the raw request body (re-serialising parsed JSON changes the bytes and breaks the HMAC), compare in constant time, and reject the request before any parsing side effect. Secrets come from the environment or a secret manager, never from source code or the image; on Azure see Key Vault & Secrets.
| Channel | What proves the call is genuine (as documented) | What to configure and rotate |
|---|---|---|
Slack |
|
|
WhatsApp Business (Cloud API) |
|
|
Telegram |
When you call |
|
Microsoft Teams (Teams SDK, Microsoft 365 Agents SDK) |
An |
The bot’s Entra app registration (client ID plus a certificate or a secret kept in Key Vault); rotate before expiry. |
Google Chat |
A bearer ID token on each HTTP request whose audience is your app URL or Cloud project number, per the Chat docs; verify with Google’s auth library. |
The configured audience and the service account used for outbound calls. |
Apple Messages for Business |
Inbound messages arrive through your Messaging Service Provider (MSP) or Apple’s Business Chat API with the authentication scheme documented in Apple’s programme documentation, which is available to approved business accounts. This page documents the principle only. |
MSP credentials in a secret manager; the details are on Apple Messages for Business. |
RCS Business Messaging |
Depends on the agent provider; Google’s RBM webhooks document a signature header computed over the payload with a client secret. Check the provider’s page before relying on it. |
Provider API key or service account, webhook secret; see RCS Business Messaging. |
Your own SSE or WebSocket API |
Your OAuth 2.0 / JWT bearer scheme; browsers cannot set headers on a native |
Signing keys and audiences; see OAuth 2.0 Flows Overview. |
The two body-signature schemes above are small enough to show. The Python version is a FastAPI dependency that reads the
raw body once and rejects bad requests with 401:
import hashlib
import hmac
import os
import time
from fastapi import FastAPI, HTTPException, Request
app = FastAPI()
SLACK_SIGNING_SECRET = os.environ["SLACK_SIGNING_SECRET"] # placeholder: set in the environment
WHATSAPP_APP_SECRET = os.environ["WHATSAPP_APP_SECRET"] # placeholder: set in the environment
def verify_slack(timestamp: str, body: bytes, signature: str) -> bool:
if abs(time.time() - int(timestamp)) > 60 * 5: # replay window
return False
base = b"v0:" + timestamp.encode() + b":" + body
expected = "v0=" + hmac.new(
SLACK_SIGNING_SECRET.encode(), base, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, signature)
def verify_meta(body: bytes, header: str) -> bool:
expected = "sha256=" + hmac.new(
WHATSAPP_APP_SECRET.encode(), body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(expected, header)
@app.post("/channels/whatsapp")
async def whatsapp(request: Request):
body = await request.body() # raw bytes, before any JSON parsing
if not verify_meta(body, request.headers.get("X-Hub-Signature-256", "")):
raise HTTPException(status_code=401)
...
The Java equivalent uses javax.crypto.Mac and MessageDigest.isEqual, which is constant-time:
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.util.HexFormat;
final class MetaSignatureVerifier {
private final byte[] secret = System.getenv("WHATSAPP_APP_SECRET") // placeholder
.getBytes(StandardCharsets.UTF_8);
boolean isValid(byte[] rawBody, String headerValue) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret, "HmacSHA256"));
String expected = "sha256=" + HexFormat.of().formatHex(mac.doFinal(rawBody));
return MessageDigest.isEqual(
expected.getBytes(StandardCharsets.UTF_8),
headerValue.getBytes(StandardCharsets.UTF_8));
}
}
In Spring MVC, read the body as byte[] in the controller (or wrap the request in a caching filter that runs before
JSON conversion) so the verified bytes are exactly the signed bytes. The Telegram check is a single comparison of the
X-Telegram-Bot-Api-Secret-Token header against TELEGRAM_WEBHOOK_SECRET with the same constant-time function.
Operational rules that apply to all channels:
-
Keep one secret per environment and per channel; a leaked staging token must not be valid in production.
-
Rotate on a schedule and on any suspicion; support two valid secrets during rotation (try the new one, then the old).
-
Never log request headers or bodies verbatim: they contain the signature, bearer tokens and user text. Log IDs and sizes.
-
Serve webhooks over HTTPS only and restrict the route to the platform’s calls where the platform publishes source ranges; do not treat IP allow-lists as a replacement for signatures.
-
Run a secret scanner in CI and on the repository history; the platform tokens above all have recognisable prefixes.
Retries and deduplication
Webhook delivery is at-least-once. Platforms retry when the response is slow or not successful, so the same event can
reach you twice, in parallel, or out of order. Slack documents a three-second window to acknowledge an event and retries
with X-Slack-Retry-Num and X-Slack-Retry-Reason headers; Meta documents retrying failed webhook deliveries with
decreasing frequency for a long period; Telegram retries updates it could not deliver and numbers them with an
increasing update_id. The consequence is the same everywhere: acknowledge fast, do the slow work after the response,
and make the work idempotent on the platform’s event ID.
| Channel | Deduplication key |
|---|---|
Slack |
|
the message ID ( |
|
Telegram |
|
Teams / Microsoft 365 Agents |
the activity |
Google Chat |
the event’s message |
XMPP |
the stanza |
A reliable pattern is an atomic "set if absent" with a time-to-live in a shared store such as Redis. The key is
channel:eventId; the first caller wins, later callers return 200 without doing work:
import os
import redis.asyncio as redis
from fastapi import BackgroundTasks
r = redis.from_url(os.environ["REDIS_URL"]) # placeholder: redis://redis:6379/0
async def first_delivery(channel: str, event_id: str, ttl_s: int = 24 * 3600) -> bool:
# SET key value NX EX ttl -> True only for the first caller
return bool(await r.set(f"dedupe:{channel}:{event_id}", "1", nx=True, ex=ttl_s))
async def handle_slack_event(envelope: dict, background: BackgroundTasks):
if not await first_delivery("slack", envelope["event_id"]):
return {"ok": True} # duplicate: acknowledge, do nothing
background.add_task(answer_and_reply, envelope) # slow work after the 200
return {"ok": True}
Details that decide whether this works under load:
-
The window must outlive the platform’s retry horizon. A 24-hour key is a reasonable default; for platforms that retry for days, size it to the documented horizon or accept rare duplicates and make the reply idempotent instead.
-
Process once, reply once. Store the reply’s platform message ID next to the event key so a crash between "model answered" and "reply sent" can be retried without double-posting.
-
Background tasks die with the process. FastAPI
BackgroundTasksand Spring@Asyncare fine for short work; for anything longer than a request, put the event on a queue (a broker, or a Postgres table polled withSELECT … FOR UPDATE SKIP LOCKED) so a restart does not lose it. Messaging background is in Messaging Protocols. -
Ordering is not guaranteed. Serialise work per
conversationId(a per-conversation lock or a partitioned queue) so two quick messages from one user do not interleave in the checkpointer; see Conversational RAG & Memory. -
Outbound retries need backoff. Honour
Retry-Afterand429responses when sending, with exponential backoff and jitter, and a cap after which the message goes to a dead-letter store for inspection.
Observability per channel
The model call is only one span of a chat turn. A useful trace starts at the webhook and carries a channel-neutral vocabulary so that one dashboard serves every adapter. The tracing mechanics for the RAG core (spans for retrieval and generation, trace IDs in responses, sampling) are covered in RAG Observability & Tracing; framework hooks are in LangChain: Observability with LangSmith and Spring AI: Observability. The LLMOps & Evaluation sub-section goes further on OpenTelemetry observability, evaluation pipelines and prompt management.
Attach the same attributes to every turn, and add the channel-specific ones from the table:
| Scope | Attributes and metrics worth recording |
|---|---|
All channels |
|
Slack |
Retry count, 3-second ack misses, Web API |
Whether the reply fell inside the customer-service window or needed a template, template category used, delivery status callbacks ( |
|
Telegram |
Draft or edit calls per answer, |
Teams |
Token acquisition failures, activity send failures, streaming-update cadence. |
Google Chat / RCS / Apple MSP |
Provider delivery receipts and error codes, reply latency against the provider’s documented response windows. |
Own SSE / WebSocket |
Open connections, time to first byte, client aborts, proxy buffering symptoms (long gaps then bursts). |
Two practical points. First, use one OpenTelemetry trace per turn with the platform event ID as an attribute and the trace ID echoed in error messages shown to operators, never leaked to users. Second, alert on symptoms of the channel contract, not only on exceptions: a rising ack-miss rate on Slack, a falling delivered-to-sent ratio on WhatsApp or a growing dedupe-hit rate each point at a different fault (slow handler, template or policy problem, platform retry storm). A minimal Python instrumentation is a span around the core call:
from opentelemetry import trace
tracer = trace.get_tracer("docsassistant.channels")
async def traced_turn(channel: str, event_id: str, conversation_id: str, user_hash: str, text: str):
with tracer.start_as_current_span("chat.turn") as span:
span.set_attribute("chat.channel", channel)
span.set_attribute("chat.event_id", event_id)
span.set_attribute("chat.conversation_id", conversation_id)
span.set_attribute("chat.user_hash", user_hash) # pseudonymous, not the raw handle
answer = await ask(conversation_id, user_hash, text) # the channel-agnostic core
span.set_attribute("chat.outcome", "answered")
return answer
Abuse and rate limiting
Chat channels make the sender anonymous and the cost asymmetric: a one-line message can trigger retrieval, several model calls and a long answer. Defences are layered, from cheap to expensive:
-
Authenticate first. Signature verification (above) removes forged traffic. Then identify the sender in the channel’s own terms: Slack user and workspace, WhatsApp phone number, Telegram user ID, Teams AAD object ID.
-
Allow-list or gate where the use case permits. Internal Slack and Teams bots can restrict to workspace or tenant members; public WhatsApp and Telegram bots cannot, so they need the limits below.
-
Rate limit per identity and per tenant. A token bucket of capacity \(b\) refilled at \(r\) tokens per second admits a burst of \(b\) messages and a sustained rate of \(r\); a user who sends \(n\) messages in a window of \(T\) seconds is admitted only while
Keep the buckets in a shared store (a Redis Lua script or a library on top of it) so all replicas agree, and answer over-limit users with one short message rather than silence, then drop further messages cheaply for a cool-down.
-
Cap work per turn. Limit input length, number of tool calls, retrieved chunks and output tokens, so one message has a bounded cost regardless of what it asks.
-
Cap spend per identity per day, and a global circuit breaker that switches the assistant to a fixed "temporarily unavailable" reply when the daily budget is exhausted.
-
Detect abuse patterns: repeated identical prompts, extremely long inputs, many new identities from one network or number range, attempts to extract the system prompt. Block at the edge (WAF) where possible and log the identity hash for review.
Platform-side limits also constrain you and are documented separately for each platform; treat these as ceilings to
design under, not guarantees, and re-read them before launch. Slack rate-limits Web API methods by tier and documents
roughly one message per second per channel for posting. Telegram documents about one message per second to a single chat
and about thirty messages per second overall for bulk sends, with 429 responses carrying retry_after. WhatsApp
applies throughput limits per phone number and business-initiated messaging limits that grow with quality rating. A
streamed answer implemented as many edits can exhaust these quickly, so throttle edits (for example one update per
second or per few hundred characters) rather than updating on every token.
A short example of a Redis-backed fixed-window limiter is enough to start; replace it with a token bucket when bursts matter:
async def allow(channel: str, user_hash: str, limit: int = 20, window_s: int = 60) -> bool:
key = f"rl:{channel}:{user_hash}:{int(time.time() // window_s)}"
count = await r.incr(key)
if count == 1:
await r.expire(key, window_s)
return count <= limit
For the HTTP surface of your own API the same ideas, plus 429 and Retry-After, are in
Serving RAG as an API.
PII and data retention
A chat transcript is personal data the moment it is tied to an identity, and channel identifiers (a phone number, a Slack user ID) are personal data on their own. Decide the policy once in the core and let adapters enforce it:
-
Minimise. Map the channel identity to an internal pseudonymous
userIdat the adapter boundary and store only that in the checkpointer, logs and traces. Keep the mapping table separate and access-controlled. -
Redact before storing or sending to the model where you can. Mask card numbers, national IDs and similar patterns in the input; the access-control and privacy design of the retrieval layer is in RAG Access Control & Privacy.
-
Set retention per data class: raw transcripts, derived memory, traces and analytics each get their own period, and a deletion path that reaches the checkpointer tables, vector store entries created from chats, logs and backups.
-
Honour deletion and export requests, keyed on the pseudonymous ID.
-
Tell users in the first message of a channel that an AI answers, which data is stored and how to delete it. Some platforms require disclosure of automation; check the policy page of each.
Each platform adds its own rules, which are the part most often missed. The points below summarise what the official documents say as read on 2026-10-01; confirm against the live pages and your contract.
| Channel | Data-handling points to check |
|---|---|
Slack |
Messages live in the customer’s workspace and are subject to the workspace’s retention settings; an app sees only what its scopes allow. Directory-listed apps are subject to Slack’s API terms, which restrict storing and using data. Request the narrowest scopes. |
Meta processes message content as a service provider for the business under its business terms; the number is personal data. The business is responsible for consent (opt-in) and for any AI-related provisions of the WhatsApp Business Solution Terms, which this repository could not re-read programmatically on 2026-10-01 (see WhatsApp Business). Do not retain more than needed beyond the customer-service window. |
|
Telegram |
Bot chats are not end-to-end encrypted and Telegram stores them on its servers; bots can read messages in groups depending on privacy mode. Telegram’s developer terms apply to stored user data. |
Microsoft Teams |
Content is governed by the tenant’s Microsoft Purview retention and compliance policies, which may retain or expose bot conversations. Resource-specific consent and Graph permissions determine what the app may read. |
Google Chat |
Governed by the Workspace administrator’s retention and Vault settings. |
Apple Messages for Business and RCS |
Apple’s programme requires a registered business and an MSP; message handling and retention terms are contractual with Apple and the MSP. RCS content passes through carriers and the agent provider, so treat it as less private than an end-to-end encrypted channel. |
XMPP |
Depends entirely on your server: message archive (XEP-0313) retention, whether OMEMO is in use (the bot would need the keys, which makes it an end of the encrypted conversation), and S2S federation, which sends stanzas to other operators. |
Do not forward chat text to a model provider, an evaluator or an analytics tool that your data-processing agreements and the platform terms do not cover. The same applies to prompts exported to tracing back-ends: scrub or hash identifiers before export.
Content moderation
Moderation has two directions and a different owner in each.
Inbound. Users will paste secrets, personal data and prompt-injection attempts, and on public channels abuse and sexual or violent content. Before the core runs, apply cheap deterministic checks (length, character classes, blocked patterns), then a classifier or the provider’s moderation endpoint where one is available, and treat the message as untrusted data in the prompt rather than instructions. The injection-specific controls (delimiting, tool permission scoping, indirect injection through retrieved documents) are in RAG Guardrails & Prompt Injection; Spring AI users also have Spring AI: Security & Guardrails and LangChain users LangChain: Guardrails & Security. A broader treatment is in the AI Security & Responsible AI sub-section, starting with Prompt Injection.
Outbound. The assistant’s reply is published under your brand into someone else’s product. Check it for unsupported claims (grounding and citations are in Citations & Grounding), leaked internal URLs or personal data, and policy violations. Some channels add their own content rules that apply to the bot as a publisher: WhatsApp and RCS regulate what business messages may contain and which categories need pre-approved templates, Slack’s directory and Teams' store review apps, and Apple vets business accounts. A refusal path is part of the design: when moderation blocks a turn, reply with a short neutral message and a hand-off (a human, a help link) rather than a silent drop.
Human hand-off deserves the same care as the model path: define when the bot stops (low retrieval confidence, repeated refusals, an explicit request), who receives the conversation, and how the channel is told the bot is no longer answering. The approval patterns for agents are in Human-in-the-Loop.
Testing with sandboxes and emulators
Test in three layers, from fastest to most realistic.
-
Unit-test the adapter without the network. Feed recorded payloads (with secrets replaced by test values) to the signature check, the dedupe function and the payload-to-
ask()mapping, and assert on the outgoing platform calls with a fake client. The channel-agnostic core is tested separately with a scripted model, as in LangChain: Testing and Spring AI: Testing. -
Replay and fault-inject. Post each fixture twice and in parallel to prove idempotency, post one with an old timestamp or a wrong signature to prove rejection, make the fake platform return
429and5xxto prove backoff, and make the model call slow to prove that the acknowledgement does not wait for it. -
Use the platform’s test facilities against a tunnel. Expose the local server with a tunnelling tool and register that URL as the webhook.
| Channel | Test facility (as documented) |
|---|---|
Slack |
A free developer sandbox workspace, plus Socket Mode so a local process can receive events without a public URL. |
The Cloud API test phone number and test recipients provided in the Meta app dashboard, and the dashboard’s webhook test button; production needs a verified business, so production-only behaviour (template approval, messaging tiers) is documented, not testable in the sandbox. |
|
Telegram |
Create a separate bot with BotFather for each environment; the Bot API also has a test environment with its own accounts. Use long polling locally and webhooks in deployed environments. |
Teams / Microsoft 365 Agents |
The Microsoft 365 Agents Playground and the Bot Framework Emulator for local runs without Azure registration, then a dev tenant for the real client. |
Google Chat |
A development Cloud project with the app visible only to your account or domain. |
Apple Messages for Business |
Apple provides a sandbox-style setup for approved business accounts and MSPs; it cannot be validated without such an account, so rely on the MSP’s test tools and Apple’s documentation. |
RCS |
The provider’s test devices and tester allow-listing before launch. |
XMPP |
A local ejabberd or Openfire container, or a disposable Prosody instance, with test accounts. |
Keep a small end-to-end suite that runs against the real sandbox on a schedule (a canary): send a message, assert on the reply within a time limit, and alert on failure. It catches the failures unit tests cannot, such as an expired token, a revoked webhook subscription or a changed platform policy. Never point test traffic at production recipients, and never reuse production secrets in tests.
Cost monitoring
Costs have two parts: the model and retrieval work your core performs, and the per-message fees some channels charge. For a period containing \(N\) turns, with average \(\bar{t}_{in}\) input tokens and \(\bar{t}_{out}\) output tokens per turn, model prices \(p_{in}\) and \(p_{out}\) per million tokens, a retrieval cost \(c_{r}\) per turn, and a per-message channel fee \(f\) applied to the fraction \(\phi\) of turns that are billable on the channel:
A worked example with invented numbers: \(N = 100\,000\) turns a month, \(\bar{t}_{in} = 3\,000\) (history plus retrieved chunks), \(\bar{t}_{out} = 400\), \(p_{in} = 1\) and \(p_{out} = 4\) currency units per million tokens, \(c_{r} = 0.0002\), \(\phi = 0.1\) and \(f = 0.03\). The model term is \((3000 \cdot 1 + 400 \cdot 4)/10^{6} = 0.0046\) per turn, so
Even with these made-up figures the lesson is stable: input tokens dominate (history and retrieved context, not the answer), and a channel fee on a small share of turns can rival the model cost. Both are levers. Shorten history with summarisation, retrieve fewer and smaller chunks, and cache repeated answers (see Caching, Latency & Cost); on WhatsApp, keep replies inside the customer-service window so they stay free of template fees, noting that the platform has charged per message rather than per conversation since 2025-07-01 and that rates differ by category and country.
To monitor it, emit the inputs of the formula as metrics rather than computing money in the application, because prices change:
-
tokens in and out per turn, labelled by
channel,tenantand model, from the model client’s usage metadata; -
retrieval calls and reranker calls per turn;
-
billable channel messages by category, from the platform’s delivery callbacks or billing reports;
-
cost per resolved conversation and per active user, computed in the dashboard from a price table you version;
-
budget burn against the daily and monthly caps from the abuse section, with an alert at a fraction of the cap.
Review the per-channel cost per resolved conversation monthly: a channel with a low cost per message and a high retry or re-ask rate can still be the most expensive one.
Operations checklist
-
Every webhook verifies a signature, token or JWT on the raw request before any work, in constant time.
-
Secrets are environment or vault values, per environment and channel, rotatable, and never logged.
-
Events are deduplicated on the platform’s ID; the acknowledgement precedes the model call; replies are idempotent.
-
Work is serialised per conversation and survives a restart (queue, not only in-process tasks).
-
One trace per turn with channel, pseudonymous user, event ID and token usage; channel-specific delivery metrics.
-
Per-identity and per-tenant rate limits, per-turn caps, daily spend caps and a global circuit breaker.
-
Identities are pseudonymised; retention and deletion are defined per data class; platform data rules are reviewed.
-
Input and output moderation, a refusal message and a human hand-off are defined.
-
Unit, replay and fault-injection tests plus a scheduled sandbox canary exist for each channel.
-
Cost inputs are metered per channel, and budgets alert before they are exhausted.