Skip to main content

Participants

A participant is an external party on a channel, such as a WhatsApp phone number, a chat id or an email address. It is identified by external_id, which is unique per channel. Participants solve two problems. Providers never send a Converger conversation id, so inbound messages need another way to find their conversation. And outbound adapters need to know whom to send a reply to.

Participant-based resolution was added for issue #16. Before that, every inbound message without a conversation_id started a new conversation. See ADR-0016.

Source: lib/converger/participants/participant.ex, lib/converger/participants.ex, migration 20261009150100_create_participants.

Schema​

Table participants:

FieldTypeNotes
iduuidPrimary key.
tenant_iduuidSet from the channel. Never cast.
channel_iduuidThe channel the party is reached on. Never cast.
external_idtextRequired, at most 255 characters. The provider's identifier, for example 905551112233. Unique together with channel_id.
display_nametext, nullableAt most 255 characters, for example the WhatsApp profile name.
metadatamapDefault {}.
opted_out_atutc_datetime_usec, nullableSet while the participant has opted out (an SMS STOP), cleared when they opt back in. Never cast. See Opt-out.
inserted_at, updated_atutc_datetime_usec

conversations.participant_id references a participant (ON DELETE SET NULL), indexed together with status. A participant can have many conversations over time, but at most one is reused at a time: the most recent active one.

The same phone number on two channels is two participants. Resolution is always per channel.

Resolution of inbound messages​

An adapter's parse_inbound/2 returns one map per message. When it can identify the sender, it includes a participant:

{
"type": "message",
"text": "Hi, is my order shipped?",
"sender": "905551112233",
"idempotency_key": "wamid.HBgMOTA1NTUxMTEyMjMzFQIAEhgg...",
"participant": { "external_id": "905551112233", "display_name": "Ayse" }
}

If the request carries no conversation_id, the inbound controller calls Participants.resolve_conversation(channel, participant). In one transaction, it:

  1. Upserts the participant with INSERT ... ON CONFLICT (channel_id, external_id) DO UPDATE. A non-null display_name overwrites the stored one, and a null one keeps it (COALESCE). The conflict update locks the participant row until commit.
  2. Finds the participant's most recent conversation on this channel with status = 'active' (ordered by inserted_at descending).
  3. Checks the idle timeout. If the channel has an idle timeout and the conversation's last activity (or, without activities, its creation time) is older than that, the conversation is not reused.
  4. Reuses that conversation, or creates a new one with participant_id set and metadata: {"source": "inbound_webhook"}.

Because of the row lock in step 1, concurrent messages from the same participant are resolved one at a time and share a single conversation instead of racing to create two.

Closed conversations, whether closed manually or by expiry, are never reused. The next message from that participant starts a new conversation.

Idle timeout​

SettingDefaultDescription
channel config["conversation_idle_timeout_seconds"]unsetPositive integer, per channel.
config :converger, :inbound_conversation_idle_timeout_secondsunsetInstallation-wide fallback.

With neither set, the active conversation is reused until it is closed or expires (by default after 24 hours without activity, see conversations).

Duplicates and invalid participants​

Before resolution, the controller looks up the message's idempotency_key across all conversations of the channel (Activities.get_activity_by_channel_idempotency_key/2, served by a partial index on activities.idempotency_key). A redelivered provider message is reported as a duplicate and creates nothing. A participant that fails validation, for example an external_id longer than 255 characters, rejects only that message: it is logged and counted in rejected, and the rest of the batch is processed (ADR-0015).

Outbound addressing​

Outbound adapters find the recipient with Participants.recipient_for(activity, channel_id). It returns the external_id of the conversation's participant when that participant belongs to the target channel (for example, the WhatsApp number to reply to), and nil otherwise. The WhatsApp adapters first look at the activity's metadata["recipient_phone"] or metadata["to"], then fall back to the participant. With no recipient at all, the delivery fails permanently: it is dead-lettered without retries.

The pipeline never delivers an inbound message back to its author. When the activity's sender equals the conversation participant's external_id, the participant's own channel is removed from the delivery targets. So is any target channel on which the sender is a participant (an email reply resolved into another channel's conversation records its sender as a participant of the email channel, see Email). Other routing rule targets still receive it. See routing rules.

Opt-out​

A participant can opt out of a channel. The SMS adapters set opted_out_at when an inbound message is an opt-out keyword (STOP, UNSUBSCRIBE...) and clear it on an opt-in keyword (START...), see SMS. The parsed message carries "opt_out": "stop" or "start", and Converger.Inbound calls Participants.set_opted_out/3 before it creates the activity, so a re-delivered webhook never loses the opt-out. The keyword itself is stored as an event activity.

While opted_out_at is set, Pipeline.deliver/2 fails every delivery to that participant on that channel: it checks the conversation participant on the target channel and any explicit metadata.to / metadata.recipient_phone (Participants.opted_out_recipient/2). The delivery is marked failed with recipient <external_id> opted out, without calling the adapter, counting against the circuit breaker or retrying. The flag is per channel: the same number on another channel is another participant.

The flag is returned with the conversation (participant.opted_out_at). Migration 20261012100000_add_opted_out_at_to_participants added the column.

Querying by participant​

GET /api/v1/conversations?external_id=905551112233 (tenant API key) returns conversations whose participant on the conversation's channel has this external id. Conversation responses include the preloaded participant as {"id", "external_id", "display_name", "opted_out_at"}.