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:
- The session is looked up by
stateand deleted immediately — it is single-use. - The redirect target comes only from the stored session, never from query input.
- Expired session → 302 back with
?status=error&code=session_expired. - Connect failure → 302 back with
?status=error&code=<AxisError code>&message=<first 200 chars>. - Success → 302 back with
?status=connected&connectionId=…&network=….
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:
- Claim the account — native adapters only.
NativeAccountClaimis unique on(network, externalAccountId), so one Instagram account can be connected by exactly one tenant platform-wide. A losing claim throwsaccount_claimed_elsewhere(409). - 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. - Encrypt the credential blob once via
KeyVaultService. - Upsert bindings — a single
*row for awildcardadapter, one row per owned capability otherwise. When the adapter returned alinkState, itsstep/reasonland on the binding'sstatus/statusReason, which is what lets a connections list show per-account progress without an upstream call per row. - Refresh the webhook subscription (Instagram and Facebook).
- Schedule the inbox backfill, when the connection is live and the adapter serves
messagesorcomments.
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:
connectedmust be true. Importing withconnected: false(the "credentials expired, preserve the record" case) schedules nothing — correctly, since a backfill would just fail.- The adapter must
supports('messages')orsupports('comments')on that network. - The backfill service must be wired into the connections service at all.
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.