Connecting an account

Connecting is a two-leg flow: you start it, the provider authorises it, and inbox completes it. The routes live on ConnectionsController (apps/service/src/modules/connections/connections.controller.ts) and all require the connections scope unless noted.

The flow

POST /v1/connections/:network/start        connections
  → StartConnectResponse { kind, redirectUrl?, qr?, pairingCode?, payload?, supportedCapabilities }
  → browser goes to the provider

GET  /v1/connections/callback?state=…      PUBLIC — the OAuth return leg
  → completeConnect(), server-side
  → 302 to the redirectUrl you stored at /start

  ── or, if you drive the exchange yourself ──

POST /v1/connections/:network/complete     connections

start accepts { integration?, redirectUrl?, … }. When you supply a redirectUrl and the deployment knows its own public URL, inbox stores a ConnectSession — tenant, network, integration key, your redirect URL, a random state, an expiry — and routes the provider back to <publicUrl>/v1/connections/callback?state=… instead. That is the one-call connect: you never call /complete yourself.

StartConnectResponse.kind is one of:

kind Meaning Also carries
redirect Send the browser to redirectUrl
qr Render qr for the account holder to scan pairingCode when the caller supplied a handset number
embeddedSignup Provider-hosted signup embedded in your page payload

The response is rebuilt field by field rather than spread, so nothing an adapter happens to return leaks into the public shape. The cost of that is real: a new ConnectStart field is invisible to callers until it is added to the service too — pairingCode was, for a while.

The callback leg

GET /v1/connections/callback is @Public(), because the browser arriving from the provider holds no service key. Everything it needs comes from state:

If the session is unknown, already consumed, or the state is missing, there is nowhere to bounce to. Rather than leak a raw JSON API error into the user's browser, the route renders a plain 400 HTML page: "This connection link is invalid or has expired." Every other outcome is a redirect.

For native Meta, the provider returns to the fixed INBOX_META_REDIRECT_URL, not to a URL inbox chose — so the state also travels via the OAuth state param. Point INBOX_META_REDIRECT_URL at <publicUrl>/v1/connections/callback for one-call native connects.

What completeConnect does

Once credentials are in hand, persistConnection runs the same sequence regardless of how they were obtained:

  1. Claim the account — native adapters only. NativeAccountClaim is unique on (network, externalAccountId), so one Instagram account can be connected by exactly one tenant platform-wide. A losing claim throws account_claimed_elsewhere (409).
  2. Find or create the connection, keyed by (tenantId, network, externalAccountId), falling back to a handle lookup. The provider account id is the durable key; a display handle is optional and can change, so keying on it made migration reruns mint a second connection for one sender.
  3. Encrypt the credential blob once via KeyVaultService.
  4. Upsert bindings — a single * row for a wildcard adapter, one row per owned capability otherwise. When the adapter returned a linkState, its step/reason land on the binding's status/statusReason, which is what lets a connections list show per-account progress without an upstream call per row.
  5. Refresh the webhook subscription (Instagram and Facebook).
  6. Schedule the inbox backfill, when the connection is live and the adapter serves messages or comments.

Break-glass on a stuck claim

If a claim is orphaned out-of-band, reconnection fails forever with account_claimed_elsewhere. POST /v1/admin/native-claims/release with { network, externalAccountId } force-releases it. It is cross-tenant by nature, so it needs admin, not connections.

Backfill

InboxBackfillService.schedule() is fire-and-forget: it never blocks, and never fails, the connect that triggered it. It seeds the inbox so the first GET /v1/inbox/threads is not empty while you wait for webhooks.

Bound Value
Conversations 25
Messages per conversation 50
Commented posts 20
Comments per post 100

Comment bounds are tighter because the shape of the work differs: a DM backfill is 1 list call plus 25 message calls, while comments cost one call per post. A busy Facebook page with two years of history would fire ~1,400 Graph calls in a burst at connect time and spend the whole app's rate budget, 429-ing every other tenant's live webhook replies.

Every ingest runs with silent: true. Historical messages produce no realtime events and no outbound webhooks — otherwise connecting an account would fire a thousand notifications for conversations that happened last year. It is idempotent (ingest dedups on externalEntryId), so a re-run, or a race with a live webhook, is harmless.

DMs and comments are resolved independently under Promise.allSettled: a connection may serve one, the other, or both, and neither half may fail the other.

Gotcha: backfill fires only through persistConnection

Both POST /v1/connections/:network/complete and POST /v1/connections/import route through persistConnection, so both schedule a backfill. But it is gated three ways, and each gate is a silent-empty-inbox failure mode:

Any code path that writes a ConnectionBinding without going through persistConnection gets no backfill, and the connection stays empty until the first webhook arrives. Live-channel adapters without provider history — livechat, ai — have nothing to pull in the first place.

Import

POST /v1/connections/import writes credentials you already hold, skipping OAuth. It needs the admin scope, not connections, precisely because it writes a credential without proving possession of the account, and must not be reachable with an ordinary connect-capable key.

{
  "network": "whatsapp",
  "integration": "whatsapp",
  "externalAccountId": "254700000000",
  "credentials": { "provider": "infobip", "...": "..." },
  "capabilities": ["messages"],
  "accountName": "Support",
  "connected": true
}

Three checks run before anything is stored. The integration must be registered; it must serve at least one capability on that network (otherwise the import "succeeds" and writes a binding no adapter can honour, leaving a connection that neither sends nor receives); and the adapter's optional validateCredentials(network, credentials) must accept the blob. That last one exists because the import path used to cast credentials straight to the vault, and the row was born connected: true with binding status available — indistinguishable from a working sender until the first campaign failed on every recipient.

You may narrow capabilities to a subset of what the adapter serves, never widen past it.

Lifecycle

Route Scope Effect
POST /v1/connections/:id/disconnect connections Non-destructive. Pauses the account — no capability serves while paused — but keeps the connection, its credentials and all inbox history
POST /v1/connections/:id/reconnect connections Re-authorises the retained account in place under the same id, reusing its stored record and saved threads. Returns a redirect, like start
DELETE /v1/connections/:id connections Hard teardown. Tells the vendor to delete the session, releases claims, removes the local record. Returns { "success": true }
POST /v1/connections/:id/switch-integration connections Flips a capability's binding to another integration, reusing the existing credential — no re-auth
POST /v1/connections/:id/retry-webhook-subscription connections Re-runs the provider webhook subscription for a connection whose subscribe leg failed

Disconnect and delete are genuinely different operations, and the distinction matters when you build UI: disconnect is the reversible pause every channel offers; delete is what the QR-session "delete channel" action calls, and it is not recoverable.

switch-integration takes { toIntegration, capability? }capability defaults to *. It reuses a credential already on the connection; if none exists for the target, only the aggregator can synthesise one (from the tenant's profile ref). Any other integration must be connected first.

Every mutating route here is a @DataWrite(), so it requires an Idempotency-Key.

See Connections and bindings for what a binding holds, and Connection requests for the channels that cannot be self-served at all.