API Docs

Channels overview

AdminUpdated Sep 19, 2026

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

web

widget

Preact widget. Default channel for any new workspace. No customer creds. Served through the /v1/widget/* surface rather than an inbound adapter.

email

adapter

BYOK Postmark / SendGrid / Mailgun / SES for outbound; the same provider's inbound parse webhook for inbound, with RFC 5322 threading. See Email.

whatsapp

adapter

Meta Cloud API direct. 24-hour window enforced server-side. Details.

messenger

adapter

Facebook Page Access Token.

instagram

adapter

Instagram Business or Creator account, via Meta Graph API.

sms

adapter

BYOK Twilio / Telnyx / SignalWire.

voice

adapter

One type, not two. Self-hosted LiveKit for WebRTC; PSTN rides the same channel via the livekit-sip bridge with BYOK Twilio.

telegram

adapter

Bot token from BotFather.

telegram_user

adapter

MTProto user account rather than a bot. BYOK per-workspace api_id / api_hash — there is no platform Telegram app.

discord

adapter

Bot in your server.

slack

adapter

Slack app in your workspace.

msteams

adapter

Microsoft Teams bot.

tiktok

adapter

TikTok business messaging, with its own message-window rules.

apple

adapter

Apple Business Chat. Requires Apple Business Register approval.

line

adapter

LINE Official Account.

viber

adapter

Viber Service.

api

adapter

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 — verifyWebhookSignature is not optional

A channel's publicId is 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 + widget

Channel-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_at

There 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-level

Not MFA_ENCRYPTION_KEY, and there is no per-workspace salt — one application key seals every workspace's channel credentials, the same key integration_installs.credentials_encrypted uses. 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_dump attached 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 allowedOrigins accepts 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 no deleted_at column to soft-delete into. To take a channel out of service, PATCH it with enabled: 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.

Was this page helpful?