Tenants
A tenant is an isolated customer of a Converger installation. Every channel, conversation, participant, activity, routing rule, attachment and tenant user belongs to exactly one tenant. Every API path derives the tenant from its credentials (API key, token, channel secret) or from the channel an inbound webhook targets, never from a tenant id in the request, and all queries are scoped to it.
Source: lib/converger/tenants/tenant.ex, lib/converger/tenants.ex.
Schema
Table tenants:
| Field | Type | Notes |
|---|---|---|
id | uuid | Primary key. |
name | text | Required. Tenant users type it to log in to the portal. |
api_key_hash | binary | SHA-256 digest of the current API key. Unique, never shown. |
api_key_prefix | text | The first characters of the key, for display only (cvg_live_abcd****). |
previous_api_key_hash | binary | Digest of the key replaced by the last rotation. |
previous_api_key_expires_at | utc_datetime_usec | Until when the previous key is still accepted. |
status | text | Defaults to "active". Only active tenants authenticate. The admin panel toggles between active and inactive. |
alert_webhook_url | string | Optional http/https URL for channel health alerts, kept as a managed platform event webhook subscription. |
limits | map, default {} | Per-tenant rate-limit overrides (see below). |
tier | text, default "default" | Delivery queue tier: high, default or bulk (see tenant tiers). Set by an admin on the Tenants page. |
allowed_upload_types | text array | Optional MIME allowlist for uploads. NULL or empty means the global default (storage). |
retention_days | integer, default 365 | Days activities and deliveries are kept before they are archived to object storage and removed. At least RETENTION_MIN_DAYS (default 30). Set in the admin tenant form. See Data retention. |
inserted_at, updated_at | utc_datetime_usec |
api_key also exists as a virtual, redacted field. It holds the plaintext key only on the struct returned right after creation or rotation, so it can be shown once. It is never persisted.
API keys
Format and storage
Tenant.generate_api_key/0 produces cvg_live_ followed by 32 random bytes in unpadded URL-safe Base64. On insert, the changeset generates a key, stores Converger.Secrets.hash/1 (SHA-256) as api_key_hash, and keeps the first 13 characters (the cvg_live_ prefix plus 4 random characters) as api_key_prefix.
The migration 20261008100001_hash_tenant_api_keys replaced the plaintext tenants.api_key column with these hashes (issue #12). Existing keys kept working: the digest of each old value was stored, with left(api_key, 4) as its prefix. The migration is irreversible: plaintext keys cannot be recovered, and a lost key can only be rotated. See ADR-0012.
Authentication
Server-to-server routes under /api/v1 (conversations, activities, routing rules) authenticate with the x-api-key header through ConvergerWeb.Plugs.TenantAuth. The presented key is hashed and looked up among the scoped keys (api_keys.key_hash, active and not expired), then by api_key_hash, or by previous_api_key_hash while previous_api_key_expires_at is in the future. The tenant must have status: "active". Otherwise the response is 401 unauthorized with the message "Invalid or inactive API Key" (errors).
The same plug also accepts a channel token in x-channel-token, and then resolves the tenant from the token's tenant_id claim. Only channel tokens are accepted there: end-user tokens (legacy conversation tokens and Converger API tokens) are refused with 401 Unauthorized: Invalid token. x-channel-token is deprecated; x-api-key is not (see migrating from the legacy surfaces). Listing conversations (GET /api/v1/conversations) requires the API key specifically, because it exposes other end users' conversations. See the tenant API.
Rotation
Converger.Tenants.rotate_api_key/2 (the Rotate button on Admin, Tenants):
- moves the current hash to
previous_api_key_hashand setsprevious_api_key_expires_at = now + grace; - generates a new key and shows it once;
- writes an audit log entry with action
rotate_api_keywhen an actor is given.
| Setting | Default | Description |
|---|---|---|
config :converger, :api_key_rotation_grace_period | 86400 (24 h) | Seconds during which the previous key remains valid. rotate_api_key/2 also accepts grace_period: per call. |
Only one previous key is kept. A second rotation inside the grace period invalidates the first key immediately.
The key above is the tenant's primary key: it has every scope. It can also be rotated with
POST /api/v1/admin/tenants/:id/rotate_api_key.
Scoped keys
A tenant can also have any number of scoped keys in api_keys, with a name, a subset of the scopes read,
write and manage, and an optional expiry. They are issued and revoked with /api/v1/manage/api_keys (by a key
with manage) or /api/v1/admin/tenants/:id/api_keys, and are deleted with the tenant. Revoking or expiring one
does not affect the primary key, and rotating the primary key does not affect them. See the
management API and
ADR-0061.
Status
Tenants are created active. An inactive tenant:
- fails
x-api-keyauthentication on/api/v1; - fails
x-channel-tokenauthentication ("Unauthorized: Tenant is not active"); - cannot obtain conversation tokens from the legacy, deprecated
POST /api/v1/tokens(403).
The client API (/api/v1/converger) and its sockets check the channel's status. Deactivating a channel disconnects its sockets (channels). Deleting a tenant cascades to all its data.
Rate-limit overrides
Rate limits are fixed-window counters (Hammer 7) on the hot paths (ADR-0013, deployment). Four buckets can be overridden per tenant through tenants.limits (migration 20261009130000_add_limits_to_tenants):
| Bucket | Default | Counted per | Applies to |
|---|---|---|---|
activity_create | 100 per 1000 ms | tenant | POST /api/v1/conversations/:id/activities, POST /api/v1/converger/conversations/:id/activities |
upload | 10 per 1000 ms | tenant | POST /api/v1/converger/conversations/:id/upload |
inbound | 500 per 1000 ms | channel | POST /api/v1/channels/:id/inbound, POST /api/v1/channels/:id/status |
token_generate | 10 per 60000 ms | channel | POST /api/v1/converger/tokens/generate, /tokens/refresh |
The value maps a bucket name to exactly two positive integers:
{
"activity_create": { "limit": 200, "scale_ms": 1000 },
"inbound": { "limit": 2000, "scale_ms": 1000 }
}
An empty map means the installation defaults apply. Set overrides with Converger.Tenants.update_tenant_limits/3, for example from a remote console:
tenant = Converger.Tenants.get_tenant!("3f2a1b0c-...")
Converger.Tenants.update_tenant_limits(tenant, %{"inbound" => %{"limit" => 2000, "scale_ms" => 1000}})
Tenant.limits_changeset/2 rejects unknown buckets, non-positive or non-integer values, and extra keys. Overrides are cached for 30 s per node (override_cache_ttl_ms) and invalidated cluster-wide on update. There is no admin UI or REST endpoint for limits yet. The management API is Planned (#51).
Alert webhook
When alert_webhook_url is set, the channel health worker (Converger.Workers.ChannelHealthWorker, every 5 minutes) POSTs a JSON alert whenever the health status of one of the tenant's channels changes (channels):
{
"event": "channel_health_changed",
"channel_id": "8d1e...",
"channel_name": "support-webhook",
"tenant_id": "3f2a...",
"previous_status": "healthy",
"new_status": "degraded",
"failure_rate": 0.25,
"total_deliveries": 40,
"failed_deliveries": 10,
"checked_at": "2026-10-09T10:20:00.000000Z"
}
The same URL also receives channel.circuit_opened and channel.circuit_closed events when a channel's delivery circuit breaker changes state (see Delivery: circuit breaker).
The URL is kept as a platform event webhook subscription (managed_by: "alert_webhook_url") on channel.health_changed, channel.circuit_opened and channel.circuit_closed, created, updated and removed with the tenant field (#49). It keeps the JSON above, but requests are now signed (x-converger-signature), retried under the shared retry policy and visible in the event log; see the legacy alert_webhook_url. New integrations should create their own subscriptions instead.
Data retention
Each tenant's activities and deliveries are kept for retention_days. Once the whole month they belong to is older
than that, the monthly retention job archives them to object storage (archive/<tenant_id>/<YYYY-MM>/, gzip JSON
Lines, verified by checksum) and removes them from the database. Archived months can be re-imported with
mix converger.archive.import --tenant <id> --month YYYY-MM. Details:
Data retention, partitions and archive.
Deleting a tenant
Converger.Tenants.delete_tenant/2 (the Delete button on Admin, Tenants) deletes the tenant row; its
channels, conversations, participants, routing rules, attachments rows and tenant users go with it
(ON DELETE CASCADE), and audit log entries keep a NULL tenant. Activities and deliveries live in partitioned
tables without foreign keys (ADR-0034): in the same
transaction a Converger.Workers.PurgeWorker job is enqueued that deletes them in batches of 5,000 rows, so a
large tenant no longer means one statement holding locks for hours. Until the job has finished the rows still exist
but are unreachable (no tenant, no API key). Deleting a channel or a conversation works the same way. Deleting
rows with raw SQL bypasses the purge; retention archives such orphans eventually.
Admin users and tenant users
Converger has two kinds of human accounts, both with bcrypt-hashed passwords (password policy: at least 12 characters by default), optional TOTP two-factor authentication, server-side sessions, password reset by email, and status active/inactive:
Admin users (admin_users) | Tenant users (tenant_users) | |
|---|---|---|
| Scope | The whole installation. | One tenant (tenant_id). Email is unique per tenant. |
| UI | Admin panel, /admin | Tenant portal, /portal |
| Network | IP allowlist ADMIN_IP_WHITELIST (default 127.0.0.1,::1), proxy-aware through TRUSTED_PROXIES | No IP allowlist |
| Login | /admin/login with email and password | /portal/login with tenant name, email and password |
| Security settings | /admin/security: 2FA, sessions, "Log out everywhere" (2FA required for the roles in ADMIN_REQUIRE_2FA_ROLES, default super_admin) | /portal/security: 2FA, sessions, own password, "Log out everywhere" |
| Forgotten password | /admin/password/reset (email) | /portal/password/reset (tenant name and email) |
| Roles | super_admin, admin, viewer | owner, admin, member, viewer |
| Created by | Converger.Release.seed_admin/0 or priv/repo/seeds.exs (first super_admin), then Admin, Users | Admin, Tenant Users, or portal users with role owner/admin |
Role effects in the current UI:
- Admin: only a
super_adminmanages admin users. Avieweris read-only everywhere: it browses tenants, channels, routing rules, conversations and users but cannot change them, has read-only access to the Oban dashboard (/admin/oban), and can browse and export the Deliveries page but not replay dead letters.super_adminandadminhave full access. See Admin panel and tenant portal. - Portal:
ownerandadmincan pause/resume channel deliveries, send test messages and rotate channel secrets.owner,adminandmembercan toggle channel status, edit routing rules, close and reopen conversations, and replay the tenant's dead letters on the Deliveries page (/portal/deliveries).ownerandadminmanage the tenant's users.vieweris read-only. Portal users cannot create channels or rotate API keys.
The first admin created without ADMIN_PASSWORD gets a generated password and must_change_password: true. It is redirected to /admin/password until the password is changed. Login attempts are throttled per IP and per account (5 failures per minute each). A super_admin can reset another admin's 2FA and sign them out on Admin, Users; portal owner/admin users can reset a team member's 2FA on Users. See Security model. See Getting started and security.
Create, update, delete, status-toggle and key-rotation operations made through the admin panel (and routing rule changes made through the tenant API), and every dead-letter replay (admin panel, portal or tenant API), write an audit log entry. Sensitive values are redacted (ADR-0012).