Skip to main content
Omazy Engineering

RFC 2210 · Channels

One wrapper, every channel.

How we collapse WhatsApp, Telegram, Instagram, Email and a dozen more into one canonical pipeline — so the operator console and the customer app speak the same language, and adding a channel is one file.

// driver engine · CIR · webhook rotation // status: shipping (Epic A + B live)

Section 01 · the problem

A dozen channels, or one model with a dozen edges?

A naive omnichannel build writes WhatsApp code, then Telegram code, then Instagram code — each with its own contact lookup, its own conversation logic, its own reply path. By channel five the codebase is a museum of near-duplicates.

We studied how mature omnichannel inboxes are built and found the real lever isn't the integrations. It's that every channel collapses into one canonical model behind exactly two seams: an inbound normalizer and an outbound sender. Everything else — contacts, conversations, messages, attachments — is shared.

Our middleware already had the canonical storage. What it lacked was the wrapper. RFC 2210 adds it: a stateless driver engine, a canonical message representation, per-config webhook rotation, and a business-owner Channel Manager — so a new channel is one driver file plus one factory case, and nothing else changes.

Section 02 · architecture

The wrapper sits between the vendors and the canonical store.

Drivers are stateless. Per-config credentials are passed on every call, so one driver instance serves every workspace that uses that channel — and the whole engine is unit-testable without a database.

flowchart TB
  subgraph V["Vendors"]
    WA["WhatsApp Cloud"]
    TG["Telegram"]
    IG["Instagram"]
    EM["Email / SMS / ..."]
  end

  subgraph ENGINE["Common Channel Wrapper  (internal/channels/driver)"]
    FAC["Factory  New(kind, provider)"]
    DRV["Channel driver
Verify · Parse · Send"] CAP["Capability descriptor"] FAC --> DRV DRV --- CAP end CIR["Message Wrapper (CIR)
InboundEvent / OutboundEnvelope"] subgraph CANON["Canonical store (already existed)"] ID["user_identities
(contact_inbox)"] SES["chat_sessions
(conversation)"] MSG["messages + channel_envelopes"] end subgraph CLIENTS["Surfaces"] WS["Workspace
operator console"] OU["ouchat
customer app"] end V -->|webhook| FAC DRV --> CIR --> CANON CANON -->|realtime| WS CANON -->|realtime| OU WS -->|reply| DRV --> V
Fig 1 — Vendors → driver engine → CIR → canonical store → both surfaces. The reply path runs the same drivers in reverse.

Common Channel Wrapper

A narrow Channel interface — Inbound().Verify/Parse, Outbound().Send, Capabilities() — resolved from a name-keyed factory. The only place that branches on vendor. Mirrors our driver rule for email, SMS, payments, and AI providers.

Message Wrapper (CIR)

A Common International Representation: InboundEvent and OutboundEnvelope reuse the existing content-type structs, so the in-flight shape and the database agree. WhatsApp and the native widget persist identically.

Section 03 · the canonical model

We already had the hard part.

The canonical entities mapped almost 1:1 onto tables we'd already shipped. The wrapper slots underneath features most inboxes lack — takeoff codes, channel health, fallback chains.

Canonical conceptOmazy (already existed)
Inbox + Channel configapp_channel_configs (+ capabilities JSONB)
ContactInbox (source_id)user_identities (app, channel, channel_user_id)
Conversationchat_sessions (origin + dispatch channel)
Message (+ source_id)messages (ULID, 22 content types) + channel_envelopes
Attachmentembedded in messages.content + userfiles
Outbound senderdriver Outbound().Send
Inbound normalizerdriver Inbound().Parse + persist sink

Section 04 · inbound

From a signed webhook to a conversation.

One ingress — /api/v1/webhook/:configId — for every channel. The config id is an immutable UUID, so renaming a workspace never breaks a vendor's pasted URL. Verification is per-config; persistence reuses already-audited services.

sequenceDiagram
  participant Vendor
  participant WH as Unified Webhook
/api/v1/webhook/:id participant DR as Channel Driver participant SK as Persist Sink participant DB as Canonical Store Vendor->>WH: POST signed payload WH->>DR: Verify(signature, creds) alt signature invalid WH-->>Vendor: 200 (drop + log) else valid WH->>DR: Parse(raw) → InboundEvent[] loop each event WH->>SK: HandleEvents SK->>DB: dedup SET NX (channel, provider_msg_id) SK->>DB: ResolveIdentity → user + identity SK->>DB: published agent → ResolveSession SK->>DB: PersistMessage (+ envelope, realtime) end WH-->>Vendor: 200 end
Fig 2 — The inbound pipeline. The driver only verifies + normalizes; the sink composes identity, session, and message creation. The vendor always gets a fast 200.

Idempotency is a Redis SET NX on (channel, provider_msg_id) — webhooks are at-least-once. Signature verification is the real gate: Meta HMAC-SHA256 over the body with the app secret, Telegram's secret-token header, LINE's base64 HMAC. A failed signature is dropped and logged, never surfaced.

Section 05 · outbound

A template-method with guards, then the driver.

Every outgoing message runs the same guards before a driver ever sees it — is it outgoing, not private, not an echo of an inbound message? Then the capability descriptor decides: free-form reply, or template-only.

flowchart LR
  A["Agent / AI persists
outgoing message"] --> D["Dispatcher"] D --> G{"Guards"} G -->|private / echo| X["skip"] G -->|window open| S["driver.Send(text/media)"] G -->|window closed| T["require approved template"] S --> W["write provider id
+ status"] T --> W S -->|error| F["status=failed
+ health record"]
Fig 3 — The outbound guards. The messaging window (24h WhatsApp, 48h TikTok, none for Telegram/web) decides whether a free-form reply is allowed or an approved template is required.

Section 06 · capabilities

One descriptor gates the backend and both UIs.

The capability struct is the single source of truth. It gates the outbound path (window → template, attachment limits) and drives what the operator console and ouchat render — the same composer affordances, the same "window expired" banner. That's what "same behaviour everywhere" means in practice.

ChannelAttachTemplateInteractiveReceiptsWindowInitiate
WhatsAppyesyesyesread24htemplate
Facebook / Instagramyesyesyesdelivered24h–7dyes
Telegramyes—yes—noneno
LINElimited—yes—noneno
SMS / EmailyesSMS only——noneyes
TikTokimageyesyesread48hyes
Web widget / ouchatyesn/ayesreadnoneyes

Section 07 · security

The URL is immutable. The secret rotates.

The inbound URL is keyed on the config UUID, so it never changes — a vendor console never breaks. The signing secret rotates on demand.

On rotation the current secret moves to a previous slot and a fresh one is generated. Verification accepts either for a 48-hour grace window, so an in-flight webhook is never dropped mid-rotation. Credentials are sealed with the shared AES-GCM envelope (AAD-bound to app_channel_configs · credentials) and never returned over the API — reads expose only the verify token and the signing secret's last four characters.

Section 08 · the surface

The business owner's journey, end to end.

The Channel Manager lives in the workspace (the business's own admin). It reuses the same channel-config service the platform admin uses — they differ only by scope, never by code path.

flowchart LR
  subgraph ENABLE["Enablement"]
    C1["Pick channel
from catalog"] --> C2["Authenticate
OAuth / key / token"] end subgraph CONNECT["Connect"] C2 --> C3["Webhook URL
+ verify token"] --> C4["Rotate secret
(48h grace)"] end subgraph OPERATE["Operate"] C4 --> C5["Assign AI agent"] --> C6["See conversations
AI vs human"] --> C7["Respond"] end subgraph HANDOFF["Handoff"] C7 --> C8["Human takeover"] --> C9["Switch channel
(takeoff)"] end
Fig 4 — Enablement → connect → operate → handoff. The connect/webhook half is the new work; human takeover and channel switching reuse the existing inbox + takeoff machinery.

Section 09 · implementation surface

Where it lives.

Everything below is on main. Adding a channel is a new driver file + a catalog entry; callers, the message model, and the clients don't change.

Middleware (Go)

  • internal/channels/driver/ — wrapper engine: interface, factory, whatsapp_cloud, telegram
  • internal/channelconfig/ — Channel Manager: catalog + capability matrix, secret rotation, CRUD
  • internal/channels/unified_webhook.go — ingress: verify → parse → sink
  • internal/channels/inbound_persist.go — contact → session → message
  • db/migrations/000045 — channel_type += line, tiktok

Workspace (Next.js)

  • app/(ws)/brand/channels/_components/channel-manager.tsx — connect wizard, webhook card, credentials form
  • lib/api/channels.ts — typed client (catalog, CRUD, credentials, rotate)

API surface

  • GET …/channels/catalog
  • POST/GET/PATCH/DELETE …/channels[/:id]
  • PUT …/channels/:id/credentials
  • POST …/channels/:id/webhook/rotate

Sources & further reading

This RFC consolidates two living design docs in the repo:

  • middleware/docs/common-channel-wrapper.md — the engine + canonical model, with file:line evidence
  • middleware/docs/channel-manager-and-lifecycle.md — the product surface, 7-epic work plan, and live implementation notes

No public help-centre article exists for channel setup yet — when it ships it will live under the knowledge base, and this section will link to it. Related engineering reading: the Agentic Harness and Background Tasks.

// RFC 2210 · source in middleware/docs/

// edit → open a PR → ships on merge to main