Multi-tenant, high-performance real-time messaging backbone.
Converger is a scalable messaging infrastructure built with Elixir and Phoenix Channels. It connects a tenant's channels (webhooks, WhatsApp, WebSocket clients) through conversations: every activity is persisted first, streamed to connected clients in real time, and delivered to the conversation's channel and any routed channels with retries.
Full documentation lives at converger.aimtune.dev: getting started, concepts, architecture, channel adapters, REST and WebSocket APIs, security and operations.
- Getting started
- Architecture decision records (ADR index) (source)
- Contributing: every PR updates the relevant
docs/pages and adds an ADR for architectural decisions.
The site is built with Docusaurus from website/ and uses docs/ as its content.
- Multi-tenant by Design: Full data isolation and tenant-scoped authentication via API Keys.
- Real-time Engine: Powered by Phoenix Channels for instant, bidirectional messaging.
- Channels and routing: Pluggable channel adapters with
inbound,outboundorduplexmodes, and routing rules that fan activities out to other channels. - Measured Performance: a k6 load harness drives the release over REST and WebSocket on 1 and 3 nodes every night, publishes p95 latency, connections and memory, and fails on any lost acknowledged message.
- Observability Stack: Built-in support for OpenTelemetry, Prometheus, Grafana, Jaeger, and Loki.
- Reliability & Safety:
- Idempotency:
x-idempotency-keyheader (REST) andclientIdon the WebSocketpostActivitypush, so clients can retry after a lost reply without duplicates. - Crash-tested: a chaos test kills the node under REST + WebSocket load and checks that no acknowledged message is lost.
- Transaction Safety: Atomic persistence before real-time broadcast.
- Rate Limiting: Tenant and IP-level throttling.
- Idempotency:
- Admin Panel: IP-restricted LiveView interface for managing tenants, channels, and conversations.
- Language/Framework: Elixir / Phoenix Framework
- Database: PostgreSQL
- Background Jobs: Oban
- Monitoring: OpenTelemetry, Prometheus, Grafana
- Traces: Jaeger
- Logs: Loki
Numbers come from the k6 load harness against the production release, and every run records the hardware it ran on. Latest published run: 2026-10-12, 1 and 3 nodes, before #35. This was a developer machine with 12 vCPUs shared by the nodes, Postgres and the load generator.
| Scenario | 1 node | 3 nodes |
|---|---|---|
| REST β WebSocket, 1 000 sockets, ~94 msgs/s: p50 / p95 | 80 / 433 ms | 502 / 1 149 ms |
| Inbound webhook burst (500/s offered): accepted | 96/s | 75/s |
| Idle WebSocket connections held / memory each | 10 000 / ~149 KiB | 10 000 / ~128 KiB |
| Reconnect storm (all 1 000 sockets at once): reconnect p95 | 1.8 s | 6.9 s |
| Node ready again after SIGKILL | 20 s | 29 s |
| Lost acknowledged messages (every scenario, including restarts and kills) | 0 | 0 |
The 250 ms p95 target is not met yet. In that run the database pool saturated under bursts and requests failed with 500.
Since #35 they get 503 with Retry-After; the after-#35 numbers are not published yet. A nightly CI job runs the same scenarios and
fails on any lost message or a p95 regression of more than 15 %. For the write path alone, run
mix converger.loadtest.
- Elixir 1.19 / OTP 28 (see
.tool-versions; theDockerfileuses the same versions) - PostgreSQL 14+
- Docker (optional, for observability stack)
- Clone the repository:
git clone https://github.com/AimTune/converger.git cd converger - Install dependencies:
mix deps.get
- Setup the database:
mix ecto.setup
- Start the server:
mix phx.server
The API will be available at http://localhost:4000.
POST /api/v1/channels/:id/inbound and POST /api/v1/channels/:id/status verify a signature over the raw request body.
- Generic (webhook, infobip, ...): send
x-converger-signature: t=<unix seconds>,v1=<hex HMAC-SHA256("<t>.<raw body>", channel secret)>. The timestamp must be within 300 seconds of the server clock (config :converger, :inbound_signature_tolerance_seconds). - WhatsApp Meta: Meta's
X-Hub-Signature-256is verified with theapp_secretchannel config key. - Each channel has a
require_signatureflag. It defaults totruefor new channels: unsigned requests and the legacysha256=<hex HMAC(raw body)>format get401. Channels created before this flag existed were migrated withrequire_signature: false. They still accept unsigned or legacy-signed requests and log a deprecation warning. An invalid signature always gets401.
Every list endpoint and admin table is bounded. Page sizes are set in config :converger, :pagination (config/config.exs) and can be overridden at runtime with PAGINATION_DEFAULT_LIMIT, PAGINATION_MAX_LIMIT, PAGINATION_ACTIVITY_DEFAULT_LIMIT, PAGINATION_ACTIVITY_MAX_LIMIT, PAGINATION_WS_REPLAY_LIMIT and PAGINATION_LOOKUP_LIMIT. A limit above the max is capped. A missing or invalid limit uses the default.
| Endpoint | Params | Pagination fields in the response |
|---|---|---|
GET /api/v1/converger/conversations/:id/activities |
watermark, limit (default 100, max 1000) |
watermark, has_more (new) |
GET /api/v1/conversations/:id/activities (tenant API key) |
watermark, limit (default 100, max 1000) |
meta: {watermark, has_more, limit} (new) |
GET /api/v1/conversations (tenant API key, new) |
cursor, limit (default 50, max 500), status, channel_id |
meta: {next_cursor, has_more, limit} |
- Activities are paged by their per-conversation
seq. Thewatermarkis opaque. To read the whole conversation, pass back thewatermarkyou received untilhas_moreisfalse. - Conversations are paged newest first with keyset pagination on
(inserted_at, id). To get the next page, pass backnext_cursor. A malformedcursororwatermarkon the tenant API returns400. On the Converger API, an invalid watermark starts from the beginning, as it did before. - Behaviour change: before this change, both activity endpoints returned the whole history when no watermark was given. They now return one page. Clients that need everything must follow
has_more. - WebSocket replay on join is capped at
ws_replay_limit(100) activities. The Converger channel's replayedactivitySetframe now carrieshas_more. When it istrue, fetch the rest over REST from that frame'swatermarkand de-duplicate by activity id against live frames. On the legacyconversation:*channel, areplay_truncatedevent ({has_more, last_activity_id}) follows a truncated replay. Rejoin with thatlast_activity_idto continue.
Clients connect to /socket/converger with a Converger token, join converger:conversation:<id>, receive
activitySet frames and send with postActivity (see the WebSocket guide).
- Deprecated: the legacy socket
/socket(conversation:<id>,new_activity),POST /api/v1/tokensand channel tokens inx-channel-token. They keep working until they are removed (no earlier than two minor releases and 6 months later), and every use logs aDeprecated ... usedwarning. See migrating from the legacy API. - Security fix:
x-channel-tokennow accepts only channel tokens. A conversation token or Converger token sent there used to act as the whole tenant and is now refused with401; usex-api-keyserver side.
Converger comes pre-configured with a full observability stack. To launch it:
cp .env.example .env # then fill in every secret; compose refuses to start without them
docker compose up -dmix ecto.setup (and bin/converger eval "Converger.Release.seed_admin()" in a
release) creates the first super admin. Set ADMIN_EMAIL / ADMIN_PASSWORD, or
a random password is printed once and must be changed at first login.
Production setup, migrations, backups and upgrades are covered in
docs/deployment.md.
- Grafana:
http://localhost:3000(Dashboards enabled) - Jaeger:
http://localhost:16686(Distributed Tracing) - Prometheus:
http://localhost:9090
flowchart LR
subgraph In["Inbound (channel mode inbound or duplex)"]
Client["Client app / SDK<br/>REST or WebSocket"]
Provider["Provider webhook<br/>WhatsApp, generic webhook"]
end
subgraph Converger
API["API and sockets<br/>auth, rate limits, signatures"]
Conv["Conversation<br/>activity stored with seq"]
Pipe["Delivery pipeline<br/>Oban, Broadway or Inline"]
Rules["Routing rules"]
end
PG[("PostgreSQL<br/>activities, outbox, deliveries")]
PubSub["Phoenix PubSub"]
subgraph Out["Outbound (channel mode outbound or duplex)"]
Own["Conversation's channel<br/>adapter"]
Routed["Routed target channels<br/>adapters"]
WS["WebSocket clients"]
end
Client --> API
Provider --> API
API --> Conv
Conv -->|"same transaction"| PG
Conv --> PubSub --> WS
Conv --> Pipe
Pipe --> Rules
Pipe -->|"retries, dead letters"| Own
Rules -->|"fan-out"| Routed
Every activity is committed together with its outbox job before anything is broadcast or delivered, so a crash cannot lose an acknowledged message (ADR-0001). The full picture is in the architecture overview. Telemetry goes to OpenTelemetry, Prometheus, Jaeger and Loki (Observability).
The WebSocket wire protocol (Converger Protocol v1, a superset of mekik/1) is specified in
docs/protocol/v1.md, with the rich message vocabulary in
docs/protocol/messages.md and JSON Schemas in priv/protocol/v1/.
- Contributing guide and code of conduct
- Security policy: report vulnerabilities privately, never in a public issue
- Changelog and roadmap
Converger is released under the MIT License.