Connection requests

Some channels cannot be self-served. A WhatsApp Business number is provisioned by the vendor, not by an OAuth redirect inbox owns, and an email sender needs a provider API key nobody would hand to a workspace user. Both adapters throw capability_unavailable from startConnect for exactly that reason.

A connection request is the workflow around that gap: a workspace asks for a channel, an operator does the provider-side work, and the result is an ordinary connection with an ordinary encrypted binding. Two requestable channels today:

export const REQUESTABLE_CHANNELS = ['whatsapp-api', 'email'] as const;

Note whatsapp-api is the request channel type — it is not one of the KNOWN_NETWORKS strings. The connection it produces lands on the whatsapp network.

Status lifecycle

requested ──approve──▶ in_progress ──test (ok)──▶ connected
    │                       │
    │                       ├──test (fail)──▶ failed
    │                       │
    └──────reject───────────┴──▶ rejected

requested | in_progress ──cancel (tenant)──▶ cancelled

connected, rejected and failed are terminal. A tenant can only cancel a request that has not been actioned into a live connection — cancelling anything else returns validation_failed with "A <status> request cannot be cancelled."

whatsapp/connect and email/connect move a request to in_progress and attach a connectionId. They deliberately do not flip the connection to connected — the test step does, and only after an authenticated read against the provider succeeds. For email, test runs the shared domain verification, so a request stays in_progress until DNS actually verifies. A failure at connect time writes status: 'failed' with lastError.

Tenant surface

/v1/connection-requests, scope connections — the same scope as the self-serve connect routes.

Route Body Notes
GET /v1/connection-requests The tenant's own requests
POST /v1/connection-requests { channelType, payload? } channelType must be in REQUESTABLE_CHANNELS; unknown values are validation_failed
POST /v1/connection-requests/:id/cancel Tenant-scoped: a workspace can never cancel another's request

The tenant view is narrowed on purpose — { id, channelType, status, operatorNotes, connectionId, lastError, createdAt, updatedAt }. Operators see the full row, including the tenant id and the request payload; workspaces do not.

requestedByUserId is stamped from the caller's Accounts user id when the request comes from a user session. Every transition emits on the tenant EventBus — connection.request_created, connection.request_approved, connection.request_rejected, connection.request_cancelled, connection.request_failed and connection.tested — so a channels UI can drop a pending card in real time rather than polling.

No provider credentials ever transit the tenant routes. For WhatsApp the operator enters them later in the dashboard; for email the operator supplies the provider key.

Operator surface

/v1/operator/connection-requests is cross-tenant and carries no tenant header — each handler resolves the request to its own tenantId internally, then performs the underlying work against that tenant.

Route Guard Needs
GET /v1/operator/connection-requests @Operator() stats
POST /v1/operator/connection-requests/:id/approve @OperatorWrite() stats + operator:connections:write
POST /v1/operator/connection-requests/:id/reject @OperatorWrite() stats + operator:connections:write
POST /v1/operator/connection-requests/:id/whatsapp/connect @OperatorWrite() stats + operator:connections:write
POST /v1/operator/connection-requests/:id/email/connect @OperatorWrite() stats + operator:connections:write
POST /v1/operator/connection-requests/:id/test @OperatorWrite() stats + operator:connections:write

operator:connections:write is an explicit grant, not implied by admin. On tenant routes admin satisfies every @RequireScopes; on operator routes it satisfies nothing. That is what lets a read-only operator key browse every workspace's pending requests without being able to action one. See Scopes and guards.

Every operator handler calls logOperatorAccess — cross-tenant reads and writes are audited.

Filters on the list route: ?status=, ?channelType=, ?tenantId=.

Actioning a WhatsApp request

POST /v1/operator/connection-requests/{id}/whatsapp/connect

{
  "apiKey": "…",
  "phoneNumberId": "…",
  "baseUrl": "9r3z2r.api.infobip.com",
  "displayPhoneNumber": "254700000000",
  "businessAccountId": "…"
}

apiKey and phoneNumberId are required. baseUrl is normalised — a bare host gets an https:// prefix and a trailing slash is stripped, so the stored credential is a usable URL rather than a scheme-less host the HTTP client would reject. Absent, it defaults to Infobip.

The provisioner creates the connection, registers the inbound webhook through the real Infobip Subscriptions API (idempotent — it tolerates the account-level "already exists"), and returns the new connection id. test then does a credential-and-reachability probe: an authenticated read against the provider, never a message send.

Actioning an email request

POST /v1/operator/connection-requests/{id}/email/connect

{ "apiKey": "…", "baseUrl": "…", "domain": "example.com", "fromName": "Example Support" }

The domain falls back to the request payload's domain, then to the host part of its fromAddress. This runs exactly the same two steps as the self-serve POST /v1/connections/email/provision route, with operator-supplied credentials: complete an email connect, then register the sending domain. The response includes dnsRecords for the operator to pass on.

reject requires a non-empty reason, which is stored in operatorNotes.

Secrets never live on the request

ConnectionRequest has no credential column. It holds channelType, status, requestPayload, operatorUserId, operatorNotes, connectionId, lastError and timestamps — nothing sensitive.

The API key an operator enters goes straight through completeConnect into ConnectionBinding.secretEnc, AES-256-GCM encrypted under INBOX_KEK, exactly like a credential from any other connect path. A request that has been actioned is just a pointer at an ordinary connection, and everything in Connections and bindings applies to it unchanged.

Gotcha: operator writes are not idempotency-protected

The IdempotencyInterceptor keys its records by tenant context. Operator routes carry no tenant header — they are cross-tenant by design — so they fall outside idempotency entirely. Sending whatsapp/connect twice runs the provider-side work twice; a double-click on an operator dashboard button is a real duplicate call, not a replayed cached response.

The provisioner is written to tolerate this where it can (the webhook subscription is idempotent), but do not rely on the platform to deduplicate here the way it does on tenant routes. See Idempotency.