Channels overview
- Written for
- + Written for
- Deprecated
- + Deprecated
- Applies to
- + Applies to
Channels overview
How Chatly unifies every messaging surface behind one inbox, with one adapter pattern, one credential model, and one outbound flow.
A channel is a configured connection between Chatly and one of your messaging surfaces — web chat, email, WhatsApp, SMS, voice, social DMs. Every channel has the same internal shape: an inbound parser, an outbound sender, a webhook-signature verifier, and a JSON config whose credential-valued keys are encrypted at rest.
This page is about the shared model — the per-channel docs (linked below) cover provider-specific setup.
Info — The dashboard inbox is channel-agnostic
Once a message lands in Chatly, it doesn't matter which channel it came in on — same routing, same AI copilot, same reports, same agents. The channel adapter is invisible above its layer.
Supported channels
The channel_type enum has exactly seventeen values. GET /v1/channels/adapters returns the ones a live adapter is registered for.
Channel inventory (the channel_type values)
Name | Type | Description |
|---|---|---|
|
| Preact widget. Default channel for any new workspace. No customer creds. Served through the |
|
| BYOK Postmark / SendGrid / Mailgun / SES for outbound; the same provider's inbound parse webhook for inbound, with RFC 5322 threading. See Email. |
|
| Meta Cloud API direct. 24-hour window enforced server-side. Details. |
|
| Facebook Page Access Token. |
|
| Instagram Business or Creator account, via Meta Graph API. |
|
| BYOK Twilio / Telnyx / SignalWire. |
|
| One type, not two. Self-hosted LiveKit for WebRTC; PSTN rides the same channel via the livekit-sip bridge with BYOK Twilio. |
|
| Bot token from BotFather. |
|
| MTProto user account rather than a bot. BYOK per-workspace |
|
| Bot in your server. |
|
| Slack app in your workspace. |
|
| Microsoft Teams bot. |
|
| TikTok business messaging, with its own message-window rules. |
|
| Apple Business Chat. Requires Apple Business Register approval. |
|
| LINE Official Account. |
|
| Viber Service. |
|
| Custom integrations — your own endpoint is the transport. |
Why BYOK on every channel
Warning — No platform credentials. Period.
Chatly doesn't own phone numbers, doesn't run SMTP servers, doesn't hold Meta apps, doesn't pay Twilio. Each customer brings the credentials for the providers they already use. The benefit: deliverability, cost, and ownership all stay yours.
This means:
Your Twilio bill is yours. You pick countries. You port numbers out. We never touch your account.
Your WhatsApp messaging tier is between you and Meta. Chatly never intermediates the API quota.
Inbound email comes via your existing transactional provider; we do not operate SMTP and never will.
Apple Business Chat goes through your existing MSP if you have one; otherwise you become your own MSP.
LLM keys for AI are per-workspace BYOK too — see AI cost metering.
The complete credential ownership matrix lives at docs/credential-ownership.md in the repo.
The adapter pattern
Every channel adapter implements three methods:
interface ChannelAdapter {
readonly type: string;
readonly capabilities?: MessageCapabilities;
// Provider webhook → normalized messages.
// An ARRAY, because providers batch: Meta posts entry[], LINE posts
// events[]. Returning a bare message stays legal for the providers
// that genuinely deliver one event per request.
parseInbound(
payload: unknown,
headers: Record<string, string | string[] | undefined>,
): Promise<InboundMessage | InboundMessage[] | null>;
// Normalized message → provider API call
sendOutbound(
message: OutboundMessage,
config: Record<string, unknown>,
): Promise<OutboundResult>;
// Authenticate an inbound webhook. REQUIRED, deliberately — an
// optional version meant the ingest controller silently skipped
// verification for any adapter that had not implemented it.
verifyWebhookSignature(
rawBody: string,
headers: Record<string, string | string[] | undefined>,
config: Record<string, unknown>,
requestUrl?: string,
): boolean;
}Warning —
verifyWebhookSignatureis not optionalA channel's
publicIdis operator-chosen (acme-sms), not a secret. Making verification a required method forces every adapter to state its answer, so "this provider's scheme is not implemented yet" has to be written down as a denial rather than happening silently through an absent method.
Adapters live in apps/api/src/modules/channels/adapters/{provider}.adapter.ts and are registered in registry.ts. Note that parseInbound receives the raw payload and headers — not the channel config; the config reaches the adapter on the outbound and verification paths only.
Inbound flow
provider webhook
│
▼
channel adapter ← signature verification + parsing
│
▼
InboundMessage{} ← normalized shape
│
▼
InboundDispatcher ← contact upsert + conversation routing
│
├──► persist (Postgres, RLS-scoped tx)
│
├──► realtime fan-out (dashboard sees the new message live)
│
└──► outbox event → worker
│
├──► trigger evaluator
├──► workflow runner
├──► webhook deliverer
└──► AI bot (if enabled on channel)Outbound flow
Agent / bot composes reply
│
▼
POST /v1/conversations/{id}/messages
│
▼
ConversationsService.sendMessage() ← RLS scope, validation
│
▼
adapter.sendOutbound(msg, channel.config)
│
▼
Provider API call (Twilio, Meta, Postmark, etc.)
│
▼
Persist provider message ID + delivery status
│
▼
Realtime echo to dashboard + widgetChannel-specific constraints (WhatsApp's 24-hour window, SMS character limits, voice ring timeout) are enforced inside the adapter — by the time you call sendMessage, you've either succeeded or you have a typed error explaining why not.
Channel config
Each channel's config JSON differs per provider, but always lives in the same encrypted column:
channels:
id uuid PK (bare uuid, no prefix)
workspace_id uuid NOT NULL (RLS)
brand_id uuid NOT NULL (every workspace has a default brand)
type channel_type NOT NULL
name varchar(120) NOT NULL
public_id varchar(64) NOT NULL, globally unique
enabled boolean NOT NULL DEFAULT true
config jsonb NOT NULL DEFAULT '{}'
created_at, updated_atThere is no config_encrypted bytea column and no deleted_at — the column is plain config jsonb, and encryption happens inside it, per key. Only the credential-valued keys (the ones the channel setup catalog renders as type: 'password') are sealed with AES-256-GCM; the rest of the object stays readable JSON. The cipher is applied at the database driver boundary, so all ~85 places that read channel.config still see a plain object.
Warning — The key is
INTEGRATION_ENCRYPTION_KEY, and it is platform-levelNot
MFA_ENCRYPTION_KEY, and there is no per-workspace salt — one application key seals every workspace's channel credentials, the same keyintegration_installs.credentials_encrypteduses. Lose it and every stored credential becomes undecryptable; changing it needs a re-encrypt migration written against both keys, because nothing rotates today. Back it up.
Info — What this protects against, and what it doesn't
It protects against anyone who can read the rows without the application key — a replica, a
pg_dumpattached to a support ticket, a snapshot restored onto a laptop, a backup bucket. It does not protect against a compromised app process, which reads every credential in plaintext by design, because it has to use them. This is encryption at rest, not a KMS or an HSM.
Origin allow-lists
For the Web channel specifically, a channel's config can carry an allowedOrigins glob list (["https://acme.com", "https://*.acme.com"]). Inbound widget requests with an Origin / Referer outside the list are 403'd before any handler logic runs.
Warning — Origin enforcement is opt-in
A channel with no
allowedOriginsaccepts widget init from anywhere. The empty list is not a deny-all — it means the check is off. Set it explicitly on any production web channel. See channel-web.
Identity Verification per channel
Each channel can have its own IDV configuration (signing secret + rotation grace period). The widget endpoints /v1/widget/* enforce the channel's IDV setting on every request. See Identity Verification.
Multi-brand
A workspace can host multiple brands. channels.brand_id is NOT NULL, so every channel belongs to exactly one brand — and every workspace is created with a default brand so this is invisible until you need it.
A brand row carries a name, a slug, one optional domain, and a branding JSON blob (logo, primary colour, support email, icons). It does not carry routing rules or an email-sending domain of its own; triggers, workflows and AI bot routes are what reference a brand, by carrying their own brand_id.
See Workspaces, brands, and contacts for the tenancy model.
Delivery failures
There is no periodic credential health-check and no health chip — nothing pings a provider on a timer. What you get instead is the record of replies that actually failed to go out:
GET /v1/channels/{id}/delivery-failures — Bearer token
It returns a bounded tail of recent undelivered replies on the channel (capped at 50), which is the question an agent actually asks when a customer says they never got the answer. Requires channels:read.
API
GET /v1/channels — Bearer token
GET /v1/channels/{id} — Bearer token
POST /v1/channels — Bearer token
PATCH /v1/channels/{id} — Bearer token
GET /v1/channels/adapters — Bearer token
GET /v1/channels/{id}/secret — Bearer token
POST /v1/channels/{id}/secret/rotate — Bearer token
Danger — Channels cannot be deleted over the API
There is no
DELETE /v1/channels/{id}, and the table has nodeleted_atcolumn to soft-delete into. To take a channel out of service,PATCHit withenabled: false.
Reads require channels:read; writes require channels:write. None of these routes accept a workspace API key — they all require a signed-in user.