Channels and adapters
A channel is a tenant-owned connection to one external system: a WhatsApp number, an HTTP endpoint, a set of WebSocket clients. Every channel has a type, and every type is implemented by an adapter: a module that implements the Converger.Channels.Adapter behaviour. The adapter knows how to validate the channel config, how to deliver an activity to the provider, how to turn a provider webhook into activities and how to read delivery receipts.
This page covers the behaviour, the built-in channel types and what each one supports, the shared inbound endpoints and their signature scheme, and channel health checks. Each adapter has its own page:
- WhatsApp (Meta Cloud API and Infobip)
- Slack
- Telegram
- Discord
- Microsoft Teams
- Facebook Messenger and Instagram Direct
- Viber
- SMS (Twilio, Vonage)
- Broker channels: Kafka, RabbitMQ, NATS JetStream, Redis Streams and MQTT
- Echo
- WebSocket
- mekik: Converger dials a mekik/1 bot server per conversation (Mode A of #64)
- Generic HTTP webhook: see Outbound webhooks
- HTTP pull (outbox)
- Writing an adapter
Channel types and modes
The channel schema (lib/converger/channels/channel.ex) accepts these values:
| Field | Values | Default |
|---|---|---|
type | echo, webhook, websocket, whatsapp_meta, whatsapp_infobip, slack, telegram, email, sms_twilio, sms_vonage, messenger, instagram, viber, discord, teams, http_pull, kafka, rabbitmq, nats, redis_streams, mqtt, mekik | webhook |
mode | inbound, outbound, duplex | duplex |
status | active or another value such as inactive. Only active channels accept inbound requests and socket joins, are routing-rule targets and get health checks. | active |
require_signature | true / false | true |
config | adapter-specific map, encrypted at rest | {} |
secret | generated (32 random bytes, unpadded base64) when not given; encrypted at rest | generated |
retry_policy | per-channel overrides, see ADR-0019 | {} |
transformations | middleware chain run before delivery | [] |
The mode decides the direction of traffic:
inbound: the channel acceptsPOST /api/v1/channels/:id/inbound. Activities in its conversations are not delivered back through the adapter.outbound: the pipeline delivers activities to the channel. Inbound messages are refused (400 Channel does not accept inbound messages); delivery receipts are still accepted.duplex: both.
The changeset rejects a mode the adapter does not support (<type> channels only support modes: ...). The modes follow from the adapter's capabilities: echo and http_pull are outbound only, mekik is duplex only; the others support all three modes.
Channels are created and edited in the admin panel (/admin/channels) and the tenant portal (/portal/channels). The admin config form is generated from the adapter's config_schema/0; the fields per type are listed on each adapter page.
The adapter behaviour
The behaviour lives in lib/converger/channels/adapter.ex. The module is also the registry and the dispatcher: Adapter.adapter_for/1 maps a type string to its module, and the Adapter.* functions with the same names call the adapter (and handle missing optional callbacks). Adapters start with use Converger.Channels.Adapter, type: "<type>", which defines type/0 and defaults for the optional callbacks.
Registry
The channel types are the registered adapters: the built-ins (echo, webhook, websocket, whatsapp_meta, whatsapp_infobip, slack, telegram, email, sms_twilio, sms_vonage, messenger, instagram, viber, discord, teams, http_pull, kafka, rabbitmq, nats, redis_streams, mqtt, mekik) followed by the modules in config :converger, :adapters. A configured adapter with a built-in type replaces it. Adapter.types/0 lists the types (the channel changeset and the admin type dropdown use it), and Adapter.types_with/1 the types with a capability. The registry is checked at boot (Adapter.validate_registry!/0). Nothing else in the code base lists channel types: the pipeline, health checks, the admin forms and the inbound controller ask the adapter. See Writing an adapter and ADR-0038.
Callbacks
| Callback | Required | Returns | Purpose |
|---|---|---|---|
type/0 | yes (defined by use) | String.t() | The channel type string. |
deliver_activity/2 | yes | :ok, {:ok, map}, {:pending, map} or {:error, term} | Delivers one activity to the provider. Called by the pipeline only. |
parse_inbound/2 | yes | {:ok, [message]}, {:ok, message} or {:error, term} | Turns an inbound webhook body into zero or more messages. |
supported_modes/0 | yes (derived by use) | [String.t()] | Modes the type accepts, checked by the channel changeset. use derives them from :inbound and :outbound. |
config_schema/0 | no | [field] | Config fields (name, type, required, secret, label, placeholder, help, summary, form). Validates channel.config and renders the admin form. Missing callback means []. See config validation. |
validate_config/1 | no | :ok or {:error, message} | Adapter rules beyond the schema, run after it on create and update. The message becomes a config error on the changeset. |
parse_status_update/2 | no | {:ok, [update]}, :ignore or {:error, term} | Extracts delivery and read receipts. Missing callback means :ignore. |
verify_inbound_signature/3 | no | :ok, :legacy, :missing or {:error, reason} | Provider-native signature check. Missing callback means the generic x-converger-signature scheme. |
verify_subscription/2 | no | {:ok, body} or :error | Answers the provider's GET webhook handshake (Meta's hub.challenge). Missing callback means 200 ok. |
retry_policy/0 | no | map | Adapter defaults merged over the global retry policy and under the channel's retry_policy. |
normalize_error/1 | no | DeliveryError or a map with reason, retryable?, retry_after_ms | Classifies a delivery failure for the retry policy and the circuit breaker. Missing callback means DeliveryError.normalize/1. |
rate_limit/0 | no | String.t() or nil | The provider's default outbound rate, see rate limiting. |
health_probe/1 | no | :ok or {:error, term} | Checks the provider account for channels without recent deliveries, see channel health. |
register_webhook/2 | no | :ok or {:error, term} | Points the provider's webhook at the channel's inbound URL (Telegram setWebhook). The admin channel list shows a Register webhook button for adapters that implement it; a successful call is audited as register_webhook. |
capabilities/0 | no | list of atoms and activity_types: [String.t()] | What the adapter can do (below). An activity_types: [...] entry names the activity types it delivers natively; other types are downgraded to text or skipped per channel (capabilities and downgrade). Missing callback means [:inbound, :outbound]; a missing activity_types entry means every client type. |
send_typing/2 | no | :ok or {:error, term} | Shows a WebSocket participant's typing indicator to the channel's participant. Missing callback means the channel gets no typing. |
send_read_receipt/2 | no | :ok or {:error, term} | Marks the channel participant's messages as read when a WebSocket participant reads them. Missing callback means no read receipts are sent. |
inbound_response/1 | no | {content_type, body} | The body the provider expects in a 200 answer to a handled inbound request (Twilio: an empty TwiML <Response>). Missing callback means the JSON summary. |
inbound_response/2 | no | {content_type, body} or :default | Like inbound_response/1, with the request params, for answers that depend on the request (a Discord interaction response, a Teams invoke response). Takes precedence over inbound_response/1; :default means the JSON summary. |
Capabilities
| Capability | Effect | Built-in adapters with it |
|---|---|---|
:inbound | Accepts inbound messages; with :outbound, allows duplex | every built-in except echo and http_pull |
:outbound | The pipeline delivers to its channels | all twenty-two |
:external_delivery | Its channels get health checks and dashboard health | webhook, whatsapp_meta, whatsapp_infobip, slack, telegram, email, sms_twilio, sms_vonage, messenger, instagram, viber, discord, teams, kafka, rabbitmq, nats, redis_streams, mqtt, mekik |
:receipts | Reports delivery or read receipts | webhook, whatsapp_meta, whatsapp_infobip, email, sms_twilio, sms_vonage, messenger, instagram, viber |
:typing | Forwards typing indicators | whatsapp_meta, telegram, teams |
:lifecycle_events | Receives conversation close and reopen events | webhook, websocket, http_pull, kafka, rabbitmq, nats, redis_streams, mqtt |
:consumer | Inbound messages are pulled by a consumer Converger runs per channel, once in the cluster (broker channels): a broker subscription, or a provider connection such as the Discord Gateway. Broker channels have no inbound webhook; Discord keeps its Interactions Endpoint and runs a consumer only with gateway: true | kafka, rabbitmq, nats, redis_streams, mqtt, discord (Gateway mode) |
:provider_ack | Handled inbound requests always get 200 | whatsapp_meta, whatsapp_infobip, slack, telegram, email, sms_twilio, sms_vonage, messenger, instagram, viber, discord, teams |
:media | Native outbound files | whatsapp_meta, whatsapp_infobip, slack, telegram (photos and documents), email (stored uploads are attached), sms_twilio (MMS from absolute URLs), messenger (URLs and stored uploads), discord (stored uploads), instagram, viber and teams (absolute URLs) |
:templates | Sends provider message templates (WhatsApp HSM) (WhatsApp) | whatsapp_meta, whatsapp_infobip |
:edits | Edits and deletes of posted messages | slack |
:reactions | Native outbound reactions (descriptive) | none yet |
:bot_relay | The channel relays to a mekik/1 bot itself: a conversation routed to it treats it as its bot channel, so the end users' bot-only frames (resume, genui_event, ...) and per-turn meta reach it (protocol section 9) | mekik |
deliver_activity/2
:ok: the delivery is markedsent.{:ok, map}: markedsent; the map is merged into the delivery'smetadata. If it containsprovider_message_id(or the olderwhatsapp_message_id/infobip_message_idkeys), that value is stored as the delivery'sprovider_message_id, which is how later receipts find the delivery (seeConverger.Deliveries.mark_sent/2).{:pending, map}: handed off, but receipt is not confirmed yet. The delivery stayspending(attemptsincremented, map merged intometadata) and is not retried;Converger.Deliveries.acknowledge/3marks itsentlater. Thewebsocketadapter returns it when no client is connected or the channel requires acks.{:error, %Converger.Channels.DeliveryError{retryable?: false}}: the delivery is dead-lettered after this attempt (status: "failed"), no retry.{:error, %DeliveryError{retry_after_ms: ms}}: retried, and the next attempt waitsmsinstead of the policy backoff.- any other
{:error, term}: classified by the adapter'snormalize_error/1(default: retryable); the next attempt follows the channel's retry policy untilmax_attemptsis reached.
DeliveryError (lib/converger/channels/delivery_error.ex) has the fields reason, status, retry_after_ms and retryable? (default true). Its helpers classify failures the same way for every adapter:
| Helper | Result |
|---|---|
DeliveryError.from_http(status, headers, body, label) | Retryable for 408, 425, 429 and any 5xx; permanent for every other status. retry_after_ms is parsed from a Retry-After header (delta-seconds or IMF-fixdate HTTP date). |
DeliveryError.from_transport(reason, label) | Always retryable (timeout, connection refused, DNS failure). |
DeliveryError.permanent(reason) | retryable?: false. Use it for misconfiguration and errors no retry can fix. |
DeliveryError.message(error) | The string written to the delivery's last_error. |
The design is recorded in ADR-0019.
parse_inbound/2
Providers batch several messages into one webhook call, so the callback returns a list (a single map is accepted and wrapped). The list may be empty, for example for a payload that only carries receipts. Each message is a map with string keys:
| Key | Required | Meaning |
|---|---|---|
sender | yes | Sender identifier stored on the activity (activity.sender). |
text, type, attachments, metadata, reply_to_id | no | Activity client fields. type defaults to message; attachments are validated (attachment schema), so put provider fields in an attachment's channelData. |
reply_to_provider_id | no | The provider id of the message this one replies or reacts to (a WhatsApp context.id or reaction.message_id). The controller resolves it to reply_to_id; an unresolvable messageReaction, messageUpdate or messageDelete is stored as an event. |
idempotency_key | no | A stable provider message id (a WhatsApp wamid, an Infobip messageId). A re-delivered webhook carrying the same id never creates a second activity. |
participant | no | %{"external_id" => ..., "display_name" => ...}. When present (and the request has no conversation_id), the message joins the participant's active conversation on the channel. See ADR-0016. |
thread_refs | no | Provider message ids of the messages this one replies to, most recent first (an email's In-Reply-To and References). Before the participant, the message joins the active conversation of the first one that went through this channel: an inbound message (by idempotency key) or an outbound one (by its delivery's provider message id), whatever channel that conversation started on. |
thread_activity_ids | no | Ids of activities this one replies to (decoded from the Message-ID Converger gives outbound email). Used like thread_refs, for activities with a delivery on this channel. |
Only client fields are taken from the parsed message when the activity is created (Activities.create_client_activity/2); sender and idempotency_key are passed as server-controlled attributes by the controller.
parse_status_update/2
Each update is a map with:
| Key | Required | Meaning |
|---|---|---|
provider_message_id | yes (or delivery_id) | The id the provider returned when the message was sent. |
delivery_id | alternative | A Converger delivery id (used by the generic webhook). |
status | yes | sent, delivered, read or failed. |
timestamp | no | ISO 8601 or Unix seconds from the provider. |
recipient_id | no | Provider recipient identifier. |
error | no | Error text for failed; stored in last_error. |
Status progression is monotonic (pending < sent < delivered < read): a delivered arriving after read is ignored, and failed is applied unless the delivery is already read. Updates for unknown message ids are skipped and not counted in receipts_processed.
Capability matrix
| Type | Modes | Outbound delivery | Native activity types | Inbound webhook | Status receipts | Inbound signature | Batches | Typing / read receipts out |
|---|---|---|---|---|---|---|---|---|
webhook | inbound, outbound, duplex | Canonical activity JSON to the configured URL, signed with x-converger-signature | all | One message per request | Yes: delivery_id or provider_message_id plus status | Generic x-converger-signature | No (one message per request) | No |
whatsapp_meta | inbound, outbound, duplex | Text, media, template, interactive, location and contacts messages through the Graph API | message | Yes (Cloud API webhook) | Yes (statuses) | Meta X-Hub-Signature-256 keyed with app_secret | Yes: every entry / changes / messages / statuses item | Yes: typing indicator and mark as read |
whatsapp_infobip | inbound, outbound, duplex | Text, media, template, interactive, location and contacts messages through the Infobip API | message | Yes (results) | Yes (delivery reports in results) | Generic x-converger-signature (no Infobip-native check) | Yes: every item of results | No |
slack | inbound, outbound, duplex | chat.postMessage (cards as Block Kit, files through files.uploadV2), chat.update, chat.delete | message, messageUpdate, messageDelete | Yes (Events API and card button interactions) | No | Slack X-Slack-Signature keyed with signing_secret, 5 minute window | One event per request | No |
telegram | inbound, outbound, duplex | Text, photos and documents through the Bot API; card buttons as inline keyboards | message | Yes (one Update per request: messages, edits, button presses, reactions, media) | No (the Bot API has none) | X-Telegram-Bot-Api-Secret-Token against secret_token | Not applicable (one update per request) | Typing only (sendChatAction) |
email | inbound, outbound, duplex | Email through Swoosh: SMTP, Amazon SES, SendGrid, Mailgun or Postmark (Email) | message | Yes: SES (SNS), SendGrid Inbound Parse, Mailgun routes, Postmark inbound, raw message/rfc822 | Yes: delivered, opened, bounced and complained events; bounce mails | SES: SNS signature and topic; Mailgun: payload signature; others: basic auth or x-converger-signature | One email per request (SendGrid events: an array) | No |
sms_twilio | inbound, outbound, duplex | SMS / MMS through Twilio Programmable Messaging (SMS) | message | Yes (form-encoded messaging webhook, MMS media as attachments) | Yes: status callbacks (sent, delivered, undelivered, failed, read) | Twilio X-Twilio-Signature keyed with auth_token | One message per request | No |
sms_vonage | inbound, outbound, duplex | SMS through the Vonage Messages API (SMS) | message | Yes (Messages API inbound webhook) | Yes: status webhook (submitted, delivered, rejected, undeliverable, read) | Vonage signed-webhook JWT keyed with signature_secret, with body hash | One message per request | No |
messenger | inbound, outbound, duplex | Text, files (URLs and uploads) and quick replies through the Send API (Messenger) | message | Yes (Messenger Platform webhook: messages, postbacks, reactions, edits, unsends) | Yes: delivery events | Meta X-Hub-Signature-256 keyed with app_secret | Yes: every entry / messaging item | No |
instagram | inbound, outbound, duplex | Text, files (URLs) and quick replies through the Send API (Instagram) | message | Yes (as Messenger, plus story replies and mentions) | Yes: read events | Meta X-Hub-Signature-256 keyed with app_secret | Yes: every entry / messaging item | No |
viber | inbound, outbound, duplex | Text with keyboards, pictures, videos, files, locations and contacts through send_message (Viber) | message | Yes (messages, conversation started, subscribed, unsubscribed) | Yes: delivered, seen, failed | X-Viber-Content-Signature keyed with auth_token | One callback per request | No |
discord | inbound, outbound, duplex | Channel messages with buttons, files and threads through the bot API (Discord) | message | Yes (Interactions Endpoint: slash commands, components, modals); plain messages over the Gateway with gateway: true | No | Ed25519 X-Signature-Ed25519 with the application's public_key | One interaction per request | No |
teams | inbound, outbound, duplex | Bot Framework messages with hero and Adaptive Cards to the conversation's serviceUrl (Microsoft Teams) | message | Yes (Bot Framework Activities: messages, card actions, reactions, edits, deletes) | No | Bot Framework JWT (OpenID keys, aud = app_id, serviceurl claim) | One Activity per request | Typing only |
http_pull | outbound | Canonical activity JSON kept in the channel's outbox until the integrator acknowledges it (HTTP pull) | all | No | Watermark acknowledgement marks deliveries sent | Not applicable (tenant API key) | Not applicable | No |
kafka | inbound, outbound, duplex | The envelope produced to topic_out, keyed by conversation_id (Kafka) | all | No: consumes topic_in | No | Not applicable (broker credentials, SASL / TLS) | Not applicable (one message per record) | No |
rabbitmq | inbound, outbound, duplex | The envelope published to exchange with routing_key, with publisher confirms (RabbitMQ) | all | No: consumes queue | No | Not applicable (broker credentials, TLS) | Not applicable (one message per AMQP message) | No |
nats | inbound, outbound, duplex | The envelope published to subject_out with a JetStream publish acknowledged by the stream (NATS JetStream) | all | No: pulls from a durable consumer on stream | No | Not applicable (user/password, token, NKey or creds; TLS) | Not applicable (one message per NATS message) | No |
redis_streams | inbound, outbound, duplex | The envelope in the payload field of an entry appended to stream_out (XADD) (Redis Streams) | all | No: reads stream_in with a consumer group | No | Not applicable (password or ACL user, TLS) | Not applicable (one message per stream entry) | No |
mqtt | inbound, outbound, duplex | The envelope published with QoS 1 to the conversation's topic_out (MQTT) | all | No: subscribes to topic_in (persistent session) | No | Not applicable (broker credentials, TLS) | Not applicable (one message per PUBLISH) | No |
mekik | duplex | The end user's turns as mekik/1 text (or resume for an interrupt answer) on a per-conversation upstream WebSocket to the bot (mekik) | all | No: the bot's frames arrive on the upstream WebSocket and are stored as the channel's | No | Not applicable (token sent to the bot) | Not applicable | Bot typing from run frames |
echo | outbound | Creates a reply activity from bot in the same conversation | message | No | No | Not applicable | Not applicable | No |
websocket | inbound, outbound, duplex | Broadcast to the channel's connected sockets; pending until a client is connected, replays or acks (WebSocket) | all | No (clients send over the socket, through the same inbound path) | Client ack over the socket | Not applicable (socket token) | Not applicable | Not applicable (clients get typing and deliveryStatus frames) |
Notes:
- The pipeline calls the adapter of every channel whose adapter has the
:outboundcapability (Adapter.capability?/2, used byresolve_delivery_channels/1inlib/converger/pipeline.ex): all twenty-two built-in types today. - Conversation lifecycle events (close, reopen) are delivered only to channels whose adapter has
:lifecycle_events(webhook,websocket,http_pull,kafka,rabbitmq,nats,redis_streamsandmqtt). - Activity types outside an adapter's native types (the
activity_types:entry ofcapabilities/0) are downgraded to a textmessage(for examplemessageReactionbecomes"user reacted with 👍") or skipped, as set by the channel config keyunsupported_activities(downgrade, the default, orskip).typingand the internaldeliveryReceiptnever reach messaging adapters. See activities. - Typed messages (
message_type, #68) reach adapters natively when they declare the type in amessage_types:capability entry (or have neithermessage_typesnoractivity_types, like webhook, WebSocket and broker channels). WhatsApp, Telegram, Slack and email render buttons, cards and media natively; every other end-user channel gets the prompt with its actions as a numbered list, and a user's tap, list choice or numeric reply comes back as one normalised turn withmetadata.action(messages.md sections 5 and 6, ADR-0064). Setshow_tool_traces: truein a channel's config to delivertool_calltraces there asname: statuslines (activities). - An inbound message is never delivered back to its author: not to the conversation participant's own channel, and not to any target channel on which the sender is a participant (an email reply in a web chat conversation), unless it is a
websocketchannel (its other sockets need it). - A delivery to a participant who opted out (an SMS
STOP, see SMS) fails immediately withrecipient <id> opted out, without calling the adapter. - WhatsApp channels send media, templates, interactive messages, locations and contacts, and download inbound media into Converger storage (WhatsApp, ADR-0039).
capabilities/0drives pipeline delivery (:outbound), health checks (:external_delivery), lifecycle events, the inbound200policy (:provider_ack) and, with itsactivity_types:entry, the downgrade of unsupported activity types (#28).
Inbound endpoints
Defined in lib/converger_web/router.ex under the /api/v1 scope (JSON pipeline), handled by ConvergerWeb.InboundController:
| Method and path | Action | Purpose |
|---|---|---|
GET /api/v1/channels/:channel_id/inbound | verify | Webhook verification handshake. Answered by the adapter's verify_subscription/2: for whatsapp_meta it checks hub.verify_token against the channel's verify_token and echoes hub.challenge (403 on mismatch); adapters without the callback answer 200 ok. |
POST /api/v1/channels/:channel_id/inbound | create | Messages and receipts. WhatsApp sends both to the same URL. An adapter with inbound_challenge/2 answers an in-band handshake here once the signature is verified (Slack's url_verification), without creating anything. |
POST /api/v1/channels/:channel_id/status | status | Receipts only (delivery and read receipts). |
There is no bearer token or API key on these routes. A request is authenticated by the channel id in the path plus the signature described below. The id is a UUID; treat the full URL as a credential anyway. Broker channels (kafka, rabbitmq, nats, redis_streams, mqtt) do not use these endpoints: their inbound messages are consumed from the broker (broker channels), and a POST to their inbound URL is rejected.
Processing order for POST .../inbound:
Batch semantics
A batch is not all-or-nothing. Each message is committed in its own transaction, and because each one carries its provider message id as idempotency key, the provider can always re-deliver the whole batch safely:
- created and duplicate messages count as handled;
- a message that is permanently invalid (for example text over the size limit) is logged and skipped;
- a transient failure (for example delivery jobs could not be enqueued) stops processing and returns an error, so the provider re-delivers; messages committed before the failure are recognized as duplicates and the rest are created, in order.
Receipts in the same request are applied best-effort before the messages. See ADR-0015.
The duplicate check looks up the key across all conversations of the channel (Activities.get_activity_by_channel_idempotency_key/2) before a conversation is resolved, because provider message ids are unique per channel. The database also enforces a unique (conversation_id, idempotency_key) index.
Responses
Successful requests return:
{
"status": "accepted",
"activity_id": "0b4c3f8e-6f53-4f4e-9d0c-1f0a3a5e2b71",
"activity_ids": ["0b4c3f8e-6f53-4f4e-9d0c-1f0a3a5e2b71"],
"duplicates": 0,
"rejected": 0,
"receipts_processed": 0
}
A receipts-only request returns {"status": "accepted", "receipts_processed": 2}.
POST .../status expects at least one receipt the adapter recognizes. A body without one (the adapter returns :ignore) is not mapped to a client error by the fallback controller today and ends in a server error, so senders should only post receipts there. POST .../inbound accepts receipt-only payloads and, for WhatsApp, well-formed payloads that carry neither messages nor receipts.
| Status | When |
|---|---|
201 | Generic channel (webhook), at least one activity created. |
200 | Duplicates only, receipts only, or any handled request on a channel whose adapter has :provider_ack (whatsapp_meta, whatsapp_infobip, slack, telegram, email, sms_twilio, sms_vonage, messenger, instagram, viber, discord, teams). These channels always get 200 once the request was handled, even when every message was rejected permanently, because the provider would otherwise retry for days. |
400 | Channel inactive (channel_inactive), channel mode outbound with messages (inbound_not_supported), or the adapter could not parse the body (bad_request, message "unable to parse WhatsApp Meta webhook payload" and similar). Bodies use the error envelope. |
401 | Signature missing (on a require_signature channel), legacy, invalid, malformed or outside the time window. |
404 | Unknown channel id, or an unknown conversation_id for the channel's tenant. |
409 | The explicit conversation_id refers to a closed conversation. |
422 | Generic channel and every message was invalid (changeset errors), or the message has neither text nor attachments. |
429 | Rate limit exceeded, with a Retry-After header. Default: 500 requests per second per channel (inbound bucket), overridable per tenant. |
503 | unavailable: the activity could not be accepted because its delivery jobs could not be enqueued (details.reason is delivery_enqueue_failed). Retry; idempotency keys make that safe. |
Conversation resolution
For each message, the conversation is chosen in this order:
- an explicit
conversation_idin the request body, scoped to the channel's tenant; - the participant's active conversation on the channel (or a new one for the participant), when the parsed message carries
participant.external_id; - otherwise a new conversation with
metadata.source = "inbound_webhook".
Participant resolution is described in ADR-0016. The channel config key conversation_idle_timeout_seconds (or the app config :inbound_conversation_idle_timeout_seconds) starts a new conversation after a period of inactivity; by default there is no idle timeout.
Inbound signatures
Signature checks run over the exact raw bytes of the request body. ConvergerWeb.CacheBodyReader (configured as the endpoint's body_reader) caches the body in conn.assigns.raw_body for every /api/v1/channels/* path, so both /inbound and /status verify what was sent, not a re-encoded JSON.
The generic scheme
Implemented in lib/converger/channels/inbound_signature.ex and used by every adapter that does not implement verify_inbound_signature/3 (today: webhook and whatsapp_infobip). The header is:
x-converger-signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>">
- The HMAC key is the channel
secretstring as-is (it is not base64-decoded). v1is lowercase hex.tmust be within the tolerance window of the server clock: 300 seconds by default, set withconfig :converger, :inbound_signature_tolerance_seconds. This limits replay of captured requests.- Several
v1=values are allowed (t=...,v1=<new>,v1=<old>) so a sender can sign with two secrets during rotation; any match is accepted. - The legacy format
sha256=<hex HMAC-SHA256 of the raw body>has no timestamp and no replay protection. It is accepted only on channels withrequire_signature: false, with a deprecation warning in the log.
Outbound webhooks are signed with the same scheme; the verification snippets and a test vector are in Outbound webhooks.
Per-channel enforcement
| Verification result | require_signature: true (default) | require_signature: false |
|---|---|---|
| Valid current signature | accepted | accepted |
Valid legacy sha256= signature | 401 | accepted, deprecation warning |
| No signature | 401 | accepted, deprecation warning |
| Signature present but invalid, malformed or stale | 401 | 401 |
A signature that is present but wrong is always rejected, even when signatures are optional. require_signature: false exists for channels created before signatures were enforced; unsigned requests will be rejected in a future release. The decision is recorded in ADR-0009.
whatsapp_meta channels use Meta's own X-Hub-Signature-256 instead (see WhatsApp), and slack channels Slack's X-Slack-Signature (see Slack); for them the generic header is not accepted in its place. telegram channels compare X-Telegram-Bot-Api-Secret-Token with their secret_token; once a secret_token is set, a missing or wrong header is rejected even when require_signature is false (see Telegram). sms_twilio channels verify X-Twilio-Signature and sms_vonage channels a signed JWT (see SMS); for them too the generic header is not accepted. messenger and instagram channels check X-Hub-Signature-256 like WhatsApp. viber (X-Viber-Content-Signature), discord (Ed25519) and teams (Bot Framework JWT) channels reject a missing or invalid signature even when require_signature is false, because their providers sign every request (see Viber, Discord, Microsoft Teams).
Signing a request with bash and openssl
CHANNEL_ID='3f6c1b9e-2d4a-4c55-9f0e-7b8a1c2d3e4f'
SECRET='<channel secret>'
BODY='{"sender":"user-42","text":"hello","idempotency_key":"evt-1001","external_id":"user-42","display_name":"Ada"}'
T=$(date +%s)
SIG=$(printf '%s' "$T.$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.*= //')
curl -sS "https://converger.example.com/api/v1/channels/$CHANNEL_ID/inbound" \
-H 'content-type: application/json' \
-H "x-converger-signature: t=$T,v1=$SIG" \
--data-raw "$BODY"
Send exactly the bytes you signed: --data-raw with the same $BODY string. Pretty-printing or re-encoding the JSON after signing breaks the signature.
Signing a request in Elixir
defmodule MyApp.ConvergerInbound do
def post(channel_id, secret, params) do
body = Jason.encode!(params)
t = System.system_time(:second)
v1 =
:crypto.mac(:hmac, :sha256, secret, "#{t}.#{body}")
|> Base.encode16(case: :lower)
Req.post!("https://converger.example.com/api/v1/channels/#{channel_id}/inbound",
body: body,
headers: [
{"content-type", "application/json"},
{"x-converger-signature", "t=#{t},v1=#{v1}"}
]
)
end
end
Inside the Converger code base (tests, tools), Converger.Channels.InboundSignature.sign(secret, raw_body) builds the same header value.
Channel health
Converger computes a health status for every active channel whose adapter has the :external_delivery capability (built in: webhook, whatsapp_meta, whatsapp_infobip, slack, telegram, email, sms_twilio, sms_vonage, messenger, instagram, viber, discord, teams, kafka, rabbitmq, nats and redis_streams) from its delivery failure rate.
Converger.Channels.Healthcounts the channel's deliveries created in the last 60 minutes and the ones with statusfailed.Converger.Channels.HealthCheckis the stored result (tablechannel_health_checks):status,total_deliveries,failed_deliveries,failure_rate(0.0 to 1.0, rounded to 4 decimals) andchecked_at.Converger.Workers.ChannelHealthWorkeris an Oban cron job (*/5 * * * *inconfig/config.exs, queuedefault).
| Status | Failure rate in the window |
|---|---|
healthy | below 10% |
degraded | 10% or more, below 50% |
unhealthy | 50% or more |
unknown | no deliveries in the window, and no health probe (or probes disabled) |
Health probes
A channel without deliveries in the window says nothing about its provider. When its adapter implements health_probe/1, the check calls it: :ok stores healthy, {:error, reason} (or a raise) stores degraded and logs Health probe failed for channel <id>: <reason>. A failed probe never yields unhealthy, so a single failed call cannot open the circuit breaker. The whatsapp_meta adapter probes GET https://graph.facebook.com/<version>/<phone_number_id>?fields=id with the channel's access token (5 second timeout, no retry), which catches an expired or revoked token before the next message fails. The slack adapter calls auth.test with its bot token, and the telegram adapter getMe. The sms_twilio adapter reads its account, and sms_vonage reads the account balance (API key auth) or checks that its application key signs a JWT. messenger and instagram read their Page or account, viber calls get_account_info, discord GET /users/@me, and teams requests a Bot Framework token. The kafka adapter requests the metadata of its topics and rabbitmq opens a connection (broker channels). Disable probes with config :converger, :channel_health, probe_idle_channels: false (they are disabled in the test environment).
Each run inserts one health check per channel. When a channel's status differs from its previous check, the worker:
- broadcasts
health_changedon the PubSub topicchannel_health(used by the admin dashboard); - emits the
channel.health_changedplatform event to the tenant's subscriptions (signed, retried, logged). The tenant's legacyalert_webhook_urlis such a subscription (details) and still receives this JSON:
{
"event": "channel_health_changed",
"channel_id": "3f6c1b9e-2d4a-4c55-9f0e-7b8a1c2d3e4f",
"channel_name": "Support WhatsApp",
"tenant_id": "8a0d2c71-5b8e-4f0c-a1d3-6e9f7b2c4d10",
"previous_status": "healthy",
"new_status": "degraded",
"failure_rate": 0.1875,
"total_deliveries": 32,
"failed_deliveries": 6,
"checked_at": "2026-10-09T12:05:00.123456Z"
}
The first check of a channel never alerts (there is no previous status). Health checks older than 7 days are pruned at the end of every run.
Planned adapters
All adapters tracked in #44 are implemented: Discord (Interactions Endpoint and Gateway mode), Microsoft Teams, Messenger, Instagram, Viber, HTTP pull and MQTT. gRPC streaming is not a channel type but a transport of Protocol v1 for backend clients: Protocol v1 over gRPC. New adapter requests get their own issue.
Related
- Outbound webhooks: the
webhookadapter's config, headers, signature verification and SSRF guard. - ADR-0009: inbound signature scheme and per-channel enforcement.
- ADR-0014: webhook SSRF guard and outbound signing.
- ADR-0015: per-message idempotent inbound batches.
- ADR-0016: participant-based conversation resolution.
- ADR-0019: per-channel retry policy and
DeliveryError.