Connections and bindings
A connection is one connected account on one network — a Facebook page, a WhatsApp sender, an
SMS short code, a LiveChat widget. It is owned by exactly one tenant and is unique on
(tenantId, network, externalAccountId).
The connection row itself carries no secrets. Credentials live one level down, on
ConnectionBinding: one row per (connectionId, capability), holding an encrypted blob in
secretEnc. Two things fall out of that split. A connection can hold credentials from different
integrations at once (native Meta DMs alongside aggregator comments on the same page), and reading
the connections list never decrypts anything — the vault is touched only when a capability call
actually needs to talk to a provider.
SocialConnection ConnectionBinding
id connectionId ─┐
tenantId capability │ @@unique([connectionId, capability])
network integrationKey ─┘
externalAccountId externalRef
ownerUserId? secretEnc ← AES-256-GCM
accountName/Handle/avatar status
connected statusReason?
Capabilities
There are five, defined in packages/types/src/capabilities.ts as IntegrationCapability:
| Capability | What it means |
|---|---|
publish |
Post to the network's feed |
messages |
Direct messages — list, fetch, send, mark read |
comments |
Comments on posts — list, reply, hide, like, delete, private-reply |
reviews |
Public reviews — list and reply (Google Business today) |
connection |
Connect, disconnect, verify webhooks. Served by the adapter itself |
Encryption
KeyVaultService (apps/service/src/crypto/key-vault.service.ts) encrypts with AES-256-GCM
under a key-encryption key from INBOX_KEK, which must be 32 bytes as 64 hex characters — the
service refuses to boot otherwise. The packed form stored in secretEnc is
base64(iv[12] | tag[16] | ciphertext). It is reversible by design: adapter calls need the
plaintext credential back to sign a provider request.
connectBindingScope
Every adapter declares how a fresh connect occupies capability slots. This is a per-adapter
constant, not a branch on the adapter's kind, so a new integration has to state its strategy
rather than inherit one silently.
| Scope | Behaviour | Who uses it |
|---|---|---|
wildcard |
One * binding serves every capability routed to this integration |
aggregator |
owned-capabilities |
One binding per capability the integration owns on that network | every native adapter |
The aggregator holds a single tenant-level profile credential, so splitting it across four rows would store the same secret four times. A native adapter's credential is genuinely per-capability scoped, so it gets a row each.
Resolution
IntegrationRegistry.resolveForConnection(tenantId, connectionId, capability) is the one path from
"I want to send on this connection" to "here is the adapter and its decrypted credential". In order:
- Load the connection by id. If it is missing or belongs to another tenant, throw
not_found. Notforbidden— a cross-tenant probe must not learn that the id exists. - If
connection.connectedis false, throwcapability_unavailable. A paused account serves nothing, even if a binding is still markedavailable. - List the connection's bindings and keep only
status === 'available'. - Pick the binding whose
capabilitymatches exactly; fall back to the*binding. - If neither exists, throw
capability_unavailable— with a hint (see below). - Check
adapter.supports(capability, connection.network). Then check the provider object itself exists (publishing/dm/comments/reviews).supports()is a claim; the second check is defence in depth against an adapter that claims a capability it has no code for. - Decrypt only that binding's
secretEncand return{ adapter, ctx, integrationKey, network }.
Note step 7's ctx.network comes from the connection, not from the credential — the connection is
the canonical answer to "what network is this".
The capability_unavailable hint
When no binding serves the capability, the error's details carry connectIntegrationKey: the
integration you would need to connect to gain that capability, computed by
effectiveIntegrationKey(network, capability). A native-DM-only Instagram account asked to
publish gets back {"connectIntegrationKey": "aggregator"}, which is actionable in a way a bare
error code is not.
The hint is suppressed when the candidate integration is already bound on this connection —
telling you to connect something you have already connected is noise, and usually means the real
problem is a binding stuck in a non-available status.
{
"error": {
"code": "capability_unavailable",
"message": "...",
"requestId": "...",
"details": { "connectIntegrationKey": "aggregator" }
}
}
Ownership and visibility
SocialConnection.ownerUserId is an Accounts user id — inbox stores no users of its own, so
every *UserId column in the schema is a foreign identity. It is set to whoever completed the
connect.
Who sees a connection in GET /v1/connections depends on how you authenticated:
| Caller | Sees |
|---|---|
Machine key (sk_) |
Every connection in the tenant. No user context to filter by |
| Accounts user session | Their own connections, plus those owned by co-members of their Accounts groups, plus every connection with a null owner |
A null ownerUserId falls back to tenant-wide visibility on purpose: connections that predate
ownership tracking, or were imported by a machine, must not become invisible to everyone.
Group expansion works by forwarding the caller's own session bearer to Accounts and reading back
their groups and co-members (UserGroupsService.visibleOwnerIds). Accounts exposes no per-user
group lookup, so "who are my co-members" is both the available question and the safe one — a caller
can only ever enumerate their own groups. If Accounts is unreachable the lookup returns an empty
set, which fails closed: you see only your own connections, never more.
Set INBOX_SHARE_CONNECTIONS_WITH_GROUPS=false to disable group expansion entirely, so a session
sees only connections it owns. Any other value (including unset) leaves it on — the config reads
env.INBOX_SHARE_CONNECTIONS_WITH_GROUPS !== 'false'.
See also Adapter catalogue and Connecting an account.