Skip to main content

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:

Channel types and modes​

The channel schema (lib/converger/channels/channel.ex) accepts these values:

FieldValuesDefault
typeecho, 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, mekikwebhook
modeinbound, outbound, duplexduplex
statusactive 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_signaturetrue / falsetrue
configadapter-specific map, encrypted at rest{}
secretgenerated (32 random bytes, unpadded base64) when not given; encrypted at restgenerated
retry_policyper-channel overrides, see ADR-0019{}
transformationsmiddleware chain run before delivery[]

The mode decides the direction of traffic:

  • inbound: the channel accepts POST /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​

CallbackRequiredReturnsPurpose
type/0yes (defined by use)String.t()The channel type string.
deliver_activity/2yes:ok, {:ok, map}, {:pending, map} or {:error, term}Delivers one activity to the provider. Called by the pipeline only.
parse_inbound/2yes{:ok, [message]}, {:ok, message} or {:error, term}Turns an inbound webhook body into zero or more messages.
supported_modes/0yes (derived by use)[String.t()]Modes the type accepts, checked by the channel changeset. use derives them from :inbound and :outbound.
config_schema/0no[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/1no: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/2no{:ok, [update]}, :ignore or {:error, term}Extracts delivery and read receipts. Missing callback means :ignore.
verify_inbound_signature/3no:ok, :legacy, :missing or {:error, reason}Provider-native signature check. Missing callback means the generic x-converger-signature scheme.
verify_subscription/2no{:ok, body} or :errorAnswers the provider's GET webhook handshake (Meta's hub.challenge). Missing callback means 200 ok.
retry_policy/0nomapAdapter defaults merged over the global retry policy and under the channel's retry_policy.
normalize_error/1noDeliveryError or a map with reason, retryable?, retry_after_msClassifies a delivery failure for the retry policy and the circuit breaker. Missing callback means DeliveryError.normalize/1.
rate_limit/0noString.t() or nilThe provider's default outbound rate, see rate limiting.
health_probe/1no:ok or {:error, term}Checks the provider account for channels without recent deliveries, see channel health.
register_webhook/2no: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/0nolist 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/2no: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/2no: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/1no{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/2no{content_type, body} or :defaultLike 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​

CapabilityEffectBuilt-in adapters with it
:inboundAccepts inbound messages; with :outbound, allows duplexevery built-in except echo and http_pull
:outboundThe pipeline delivers to its channelsall twenty-two
:external_deliveryIts channels get health checks and dashboard healthwebhook, whatsapp_meta, whatsapp_infobip, slack, telegram, email, sms_twilio, sms_vonage, messenger, instagram, viber, discord, teams, kafka, rabbitmq, nats, redis_streams, mqtt, mekik
:receiptsReports delivery or read receiptswebhook, whatsapp_meta, whatsapp_infobip, email, sms_twilio, sms_vonage, messenger, instagram, viber
:typingForwards typing indicatorswhatsapp_meta, telegram, teams
:lifecycle_eventsReceives conversation close and reopen eventswebhook, websocket, http_pull, kafka, rabbitmq, nats, redis_streams, mqtt
:consumerInbound 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: truekafka, rabbitmq, nats, redis_streams, mqtt, discord (Gateway mode)
:provider_ackHandled inbound requests always get 200whatsapp_meta, whatsapp_infobip, slack, telegram, email, sms_twilio, sms_vonage, messenger, instagram, viber, discord, teams
:mediaNative outbound fileswhatsapp_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)
:templatesSends provider message templates (WhatsApp HSM) (WhatsApp)whatsapp_meta, whatsapp_infobip
:editsEdits and deletes of posted messagesslack
:reactionsNative outbound reactions (descriptive)none yet
:bot_relayThe 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 marked sent.
  • {:ok, map}: marked sent; the map is merged into the delivery's metadata. If it contains provider_message_id (or the older whatsapp_message_id / infobip_message_id keys), that value is stored as the delivery's provider_message_id, which is how later receipts find the delivery (see Converger.Deliveries.mark_sent/2).
  • {:pending, map}: handed off, but receipt is not confirmed yet. The delivery stays pending (attempts incremented, map merged into metadata) and is not retried; Converger.Deliveries.acknowledge/3 marks it sent later. The websocket adapter 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 waits ms instead of the policy backoff.
  • any other {:error, term}: classified by the adapter's normalize_error/1 (default: retryable); the next attempt follows the channel's retry policy until max_attempts is 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:

HelperResult
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:

KeyRequiredMeaning
senderyesSender identifier stored on the activity (activity.sender).
text, type, attachments, metadata, reply_to_idnoActivity client fields. type defaults to message; attachments are validated (attachment schema), so put provider fields in an attachment's channelData.
reply_to_provider_idnoThe 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_keynoA stable provider message id (a WhatsApp wamid, an Infobip messageId). A re-delivered webhook carrying the same id never creates a second activity.
participantno%{"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_refsnoProvider 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_idsnoIds 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:

KeyRequiredMeaning
provider_message_idyes (or delivery_id)The id the provider returned when the message was sent.
delivery_idalternativeA Converger delivery id (used by the generic webhook).
statusyessent, delivered, read or failed.
timestampnoISO 8601 or Unix seconds from the provider.
recipient_idnoProvider recipient identifier.
errornoError 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​

TypeModesOutbound deliveryNative activity typesInbound webhookStatus receiptsInbound signatureBatchesTyping / read receipts out
webhookinbound, outbound, duplexCanonical activity JSON to the configured URL, signed with x-converger-signatureallOne message per requestYes: delivery_id or provider_message_id plus statusGeneric x-converger-signatureNo (one message per request)No
whatsapp_metainbound, outbound, duplexText, media, template, interactive, location and contacts messages through the Graph APImessageYes (Cloud API webhook)Yes (statuses)Meta X-Hub-Signature-256 keyed with app_secretYes: every entry / changes / messages / statuses itemYes: typing indicator and mark as read
whatsapp_infobipinbound, outbound, duplexText, media, template, interactive, location and contacts messages through the Infobip APImessageYes (results)Yes (delivery reports in results)Generic x-converger-signature (no Infobip-native check)Yes: every item of resultsNo
slackinbound, outbound, duplexchat.postMessage (cards as Block Kit, files through files.uploadV2), chat.update, chat.deletemessage, messageUpdate, messageDeleteYes (Events API and card button interactions)NoSlack X-Slack-Signature keyed with signing_secret, 5 minute windowOne event per requestNo
telegraminbound, outbound, duplexText, photos and documents through the Bot API; card buttons as inline keyboardsmessageYes (one Update per request: messages, edits, button presses, reactions, media)No (the Bot API has none)X-Telegram-Bot-Api-Secret-Token against secret_tokenNot applicable (one update per request)Typing only (sendChatAction)
emailinbound, outbound, duplexEmail through Swoosh: SMTP, Amazon SES, SendGrid, Mailgun or Postmark (Email)messageYes: SES (SNS), SendGrid Inbound Parse, Mailgun routes, Postmark inbound, raw message/rfc822Yes: delivered, opened, bounced and complained events; bounce mailsSES: SNS signature and topic; Mailgun: payload signature; others: basic auth or x-converger-signatureOne email per request (SendGrid events: an array)No
sms_twilioinbound, outbound, duplexSMS / MMS through Twilio Programmable Messaging (SMS)messageYes (form-encoded messaging webhook, MMS media as attachments)Yes: status callbacks (sent, delivered, undelivered, failed, read)Twilio X-Twilio-Signature keyed with auth_tokenOne message per requestNo
sms_vonageinbound, outbound, duplexSMS through the Vonage Messages API (SMS)messageYes (Messages API inbound webhook)Yes: status webhook (submitted, delivered, rejected, undeliverable, read)Vonage signed-webhook JWT keyed with signature_secret, with body hashOne message per requestNo
messengerinbound, outbound, duplexText, files (URLs and uploads) and quick replies through the Send API (Messenger)messageYes (Messenger Platform webhook: messages, postbacks, reactions, edits, unsends)Yes: delivery eventsMeta X-Hub-Signature-256 keyed with app_secretYes: every entry / messaging itemNo
instagraminbound, outbound, duplexText, files (URLs) and quick replies through the Send API (Instagram)messageYes (as Messenger, plus story replies and mentions)Yes: read eventsMeta X-Hub-Signature-256 keyed with app_secretYes: every entry / messaging itemNo
viberinbound, outbound, duplexText with keyboards, pictures, videos, files, locations and contacts through send_message (Viber)messageYes (messages, conversation started, subscribed, unsubscribed)Yes: delivered, seen, failedX-Viber-Content-Signature keyed with auth_tokenOne callback per requestNo
discordinbound, outbound, duplexChannel messages with buttons, files and threads through the bot API (Discord)messageYes (Interactions Endpoint: slash commands, components, modals); plain messages over the Gateway with gateway: trueNoEd25519 X-Signature-Ed25519 with the application's public_keyOne interaction per requestNo
teamsinbound, outbound, duplexBot Framework messages with hero and Adaptive Cards to the conversation's serviceUrl (Microsoft Teams)messageYes (Bot Framework Activities: messages, card actions, reactions, edits, deletes)NoBot Framework JWT (OpenID keys, aud = app_id, serviceurl claim)One Activity per requestTyping only
http_pulloutboundCanonical activity JSON kept in the channel's outbox until the integrator acknowledges it (HTTP pull)allNoWatermark acknowledgement marks deliveries sentNot applicable (tenant API key)Not applicableNo
kafkainbound, outbound, duplexThe envelope produced to topic_out, keyed by conversation_id (Kafka)allNo: consumes topic_inNoNot applicable (broker credentials, SASL / TLS)Not applicable (one message per record)No
rabbitmqinbound, outbound, duplexThe envelope published to exchange with routing_key, with publisher confirms (RabbitMQ)allNo: consumes queueNoNot applicable (broker credentials, TLS)Not applicable (one message per AMQP message)No
natsinbound, outbound, duplexThe envelope published to subject_out with a JetStream publish acknowledged by the stream (NATS JetStream)allNo: pulls from a durable consumer on streamNoNot applicable (user/password, token, NKey or creds; TLS)Not applicable (one message per NATS message)No
redis_streamsinbound, outbound, duplexThe envelope in the payload field of an entry appended to stream_out (XADD) (Redis Streams)allNo: reads stream_in with a consumer groupNoNot applicable (password or ACL user, TLS)Not applicable (one message per stream entry)No
mqttinbound, outbound, duplexThe envelope published with QoS 1 to the conversation's topic_out (MQTT)allNo: subscribes to topic_in (persistent session)NoNot applicable (broker credentials, TLS)Not applicable (one message per PUBLISH)No
mekikduplexThe end user's turns as mekik/1 text (or resume for an interrupt answer) on a per-conversation upstream WebSocket to the bot (mekik)allNo: the bot's frames arrive on the upstream WebSocket and are stored as the channel'sNoNot applicable (token sent to the bot)Not applicableBot typing from run frames
echooutboundCreates a reply activity from bot in the same conversationmessageNoNoNot applicableNot applicableNo
websocketinbound, outbound, duplexBroadcast to the channel's connected sockets; pending until a client is connected, replays or acks (WebSocket)allNo (clients send over the socket, through the same inbound path)Client ack over the socketNot applicable (socket token)Not applicableNot applicable (clients get typing and deliveryStatus frames)

Notes:

  • The pipeline calls the adapter of every channel whose adapter has the :outbound capability (Adapter.capability?/2, used by resolve_delivery_channels/1 in lib/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_streams and mqtt).
  • Activity types outside an adapter's native types (the activity_types: entry of capabilities/0) are downgraded to a text message (for example messageReaction becomes "user reacted with 👍") or skipped, as set by the channel config key unsupported_activities (downgrade, the default, or skip). typing and the internal deliveryReceipt never reach messaging adapters. See activities.
  • Typed messages (message_type, #68) reach adapters natively when they declare the type in a message_types: capability entry (or have neither message_types nor activity_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 with metadata.action (messages.md sections 5 and 6, ADR-0064). Set show_tool_traces: true in a channel's config to deliver tool_call traces there as name: status lines (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 websocket channel (its other sockets need it).
  • A delivery to a participant who opted out (an SMS STOP, see SMS) fails immediately with recipient <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/0 drives pipeline delivery (:outbound), health checks (:external_delivery), lifecycle events, the inbound 200 policy (:provider_ack) and, with its activity_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 pathActionPurpose
GET /api/v1/channels/:channel_id/inboundverifyWebhook 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/inboundcreateMessages 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/statusstatusReceipts 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}.

note

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.

StatusWhen
201Generic channel (webhook), at least one activity created.
200Duplicates 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.
400Channel 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.
401Signature missing (on a require_signature channel), legacy, invalid, malformed or outside the time window.
404Unknown channel id, or an unknown conversation_id for the channel's tenant.
409The explicit conversation_id refers to a closed conversation.
422Generic channel and every message was invalid (changeset errors), or the message has neither text nor attachments.
429Rate limit exceeded, with a Retry-After header. Default: 500 requests per second per channel (inbound bucket), overridable per tenant.
503unavailable: 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:

  1. an explicit conversation_id in the request body, scoped to the channel's tenant;
  2. the participant's active conversation on the channel (or a new one for the participant), when the parsed message carries participant.external_id;
  3. 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 secret string as-is (it is not base64-decoded).
  • v1 is lowercase hex.
  • t must be within the tolerance window of the server clock: 300 seconds by default, set with config :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 with require_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 resultrequire_signature: true (default)require_signature: false
Valid current signatureacceptedaccepted
Valid legacy sha256= signature401accepted, deprecation warning
No signature401accepted, deprecation warning
Signature present but invalid, malformed or stale401401

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.

StatusFailure rate in the window
healthybelow 10%
degraded10% or more, below 50%
unhealthy50% or more
unknownno 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:

  1. broadcasts health_changed on the PubSub topic channel_health (used by the admin dashboard);
  2. emits the channel.health_changed platform event to the tenant's subscriptions (signed, retried, logged). The tenant's legacy alert_webhook_url is 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.

  • Outbound webhooks: the webhook adapter'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.