--- title: "Catalog and connections" description: "Browse the frozen connector catalog, register a broker, and drive a connection through its credential lifecycle." --- The catalog is a frozen index shipped with the image (ADR-0030). These endpoints read it, attach the workspace's own connections to it, and move a connection between `pending`, `active`, and `disabled`. Brokers are vendors, never authority. A federated connection uses your own OAuth client, a broker never mints a receipt or assigns evidence trust, and broker-side retries are disabled on every call (invariants 8 and 9). ## Authentication All routes require `Authorization: Bearer ` ([/api-reference/overview](/api-reference/overview)) and carry `Cache-Control: no-store`. Reads need `workspace.read`; every mutation needs `connection.manage`, which is granted to admin and owner. A non-member gets `404 workspace not found`. Errors raised inside the connection service surface as `{"detail": "..."}` with the status noted per endpoint; a failed gateway call surfaces as `502 {"error": {"code": "upstream_error", ...}}` and an unavailable hot path as `503 {"error": {"code": "not_ready", ...}}`. ## Read the catalog `GET /api/v1/workspaces/{workspace_id}/catalog` Two shapes from one endpoint, discriminated by the presence of a `page` object in the response. With no query parameters you get every catalog row verbatim. Supply any of `q`, `category`, `source`, `limit`, `offset`, or `cursor` and you get one page of gallery cards instead. Search text, at most 64 characters. Longer is refused rather than truncated into a different search. Category token, at most 64 characters. A well-formed token nobody uses is an empty page, not an error. Lane: `native`, `nango`, or `composio`. A closed enum — anything else is `422 unknown catalog source`. The lane also joins the cursor fingerprint, so a cursor issued in one lane cannot be replayed in another. 1–100. 0–100000. Cannot be combined with `cursor`. At most 128 characters, from a previous `page.next_cursor`. ### Unfiltered response Schema version of the frozen index. When the index was frozen. Every catalog row verbatim — `slug`, `display_name`, `description`, `categories`, `default_tier`, and `sources` (each with `provider`, `auth_modes`, `mcp`, `allowed_mcp_origins`, and `upstream_id`) — plus a `connections` array holding this workspace's connections on that slug. ### Browse response The same `schema_version` and `generated_at`, plus: Gallery cards: `slug`, `display_name`, `description`, `categories`, `default_tier`, and `sources` reduced to `{provider, auth_modes}`, plus `connections`. The card projection drops `upstream_id`, `mcp`, and the reviewed `allowed_mcp_origins` allowlist — the allowlist is what a federated completion is checked against, so it is control's input and never a browser's. `{name, count}` facets over the *unfiltered* catalog, so the rail does not reshuffle while an operator types. Sorted by descending count, then name. `{source, count}`, one row per lane in `native`, `nango`, `composio` order. A lane is never omitted, and its count may be `0`. How many rows more than one broker carries. The lanes overlap and are never a partition, so a rail drawn from the counts alone would overstate coverage. `{total, limit, offset, next_cursor}`. `next_cursor` is `null` on the last page. An offset past the end is an empty page with the real `total`, not a `404`. ```bash Request curl "https://control.tryobol.dev/api/v1/workspaces/ws_acme_prod/catalog?q=hubspot&source=nango&limit=2" \ -H "Authorization: Bearer $OBOL_SESSION" ``` ```json Response { "schema_version": 1, "generated_at": "2026-08-29T00:00:00Z", "connectors": [ { "slug": "hubspot", "display_name": "HubSpot", "description": "HubSpot.", "categories": ["crm", "marketing", "popular", "support"], "default_tier": "native", "sources": [ { "provider": "native", "auth_modes": ["api_key"] }, { "provider": "nango", "auth_modes": ["oauth2"] }, { "provider": "composio", "auth_modes": ["api_key", "oauth2"] } ], "connections": [] } ], "categories": [ { "name": "productivity", "count": 199 }, { "name": "dev-tools", "count": 134 } ], "lanes": [ { "source": "native", "count": 103 }, { "source": "nango", "count": 980 }, { "source": "composio", "count": 1380 } ], "lane_overlap": 243, "page": { "total": 2, "limit": 2, "offset": 0, "next_cursor": null } } ``` {/* TODO: facet counts above are read from the catalog index frozen at 2026-08-29; refresh them when the index is regenerated. The `categories` array is truncated here — the live response carries every category token. */} ### Browse errors | Status | Detail | | --- | --- | | `400` | `provide either cursor or offset, not both` | | `400` | `malformed catalog cursor` | | `400` | `catalog cursor does not match this query` | | `422` | `unknown catalog source` | ## Read one catalog entry `GET /api/v1/workspaces/{workspace_id}/catalog/{slug}` The verbatim catalog row for one slug, with no connections attached. `404 unknown catalog slug` when the slug is not in the index. ## List connections `GET /api/v1/workspaces/{workspace_id}/connections` Every non-model connection in the workspace. `operator_connection` projections. `federated` when custody is federated, `trusted_worker` when the target is a worker, otherwise `native`. Derived from the tier: `gateway_observed` for native, `connector_attested` for a trusted worker, `untrusted` for federated. `{catalog_session_id, expires_at}` for a federated handoff this caller may still finish, or `null`. It is returned only to a caller who holds `connection.manage`, only for a session they themselves initiated, only while the session is unconsumed and unexpired, only while the session's provider still matches the connection's broker, and only while a published version covers the workspace's current snapshot version. Anything narrower at completion time would be a handle that completion refuses. Also returned: `schema_version`, `connection_id`, `workspace_id`, `connector_slug`, `connector_display_name`, `source`, `status`, `custody`, `last4`, `expires_at`, `last_refreshed_at`, `refreshable`, `credential_revision`, and `publication`. ## Register a catalog broker `POST /api/v1/workspaces/{workspace_id}/catalog-providers` Stores the workspace's own broker API key so federated connect sessions can be requested. The key is sent to the gateway to be sealed and only the ciphertext is stored. `nango` or `composio`. Your broker API key. At most 8192 bytes, no control characters. Answers `200` with the mutation envelope. When the first snapshot publication has not settled the response comes back with the connection still `pending` and `next.kind` of `none`; retry once publication reports `published`. ## Start a connection `POST /api/v1/workspaces/{workspace_id}/connections` Catalog slug. `404 unknown catalog slug` if the index does not carry it. `native`, `nango`, or `composio`. `400 catalog source is not available` when the slug does not publish that lane. OAuth client id for the native OAuth path. OAuth client secret for the native OAuth path, sealed before storage. The lane and the catalog entry's auth modes decide what comes back in `next`: | Prepared mode | `next.kind` | Meaning | | --- | --- | --- | | `api_key` | `restricted_key` | Post a restricted key to `/restricted-key` | | `oauth2` | `redirect` | Send the browser to `url` — see [/api-reference/oauth](/api-reference/oauth) | | `federated` | `catalog_redirect` | Send the browser to the broker's connect session; keep `catalog_session_id` | A workspace holds at most one live connection per connector. Starting a second one is `409 connector already has a live connection`; an unfinished `pending` shell on the same connector and custody is reused rather than duplicated. ```bash Request curl -X POST https://control.tryobol.dev/api/v1/workspaces/ws_acme_prod/connections \ -H "Authorization: Bearer $OBOL_SESSION" \ -H "Content-Type: application/json" \ -d '{ "slug": "hubspot", "source": "nango" }' ``` ```json Response { "schema_version": 1, "connection": { "schema_version": 1, "connection_id": "conn_4b81ee", "workspace_id": "ws_acme_prod", "connector_slug": "hubspot", "connector_display_name": "HubSpot", "source": "nango", "tier": "federated", "status": "pending", "custody": "federated", "evidence_ceiling": "untrusted", "last4": null, "expires_at": null, "last_refreshed_at": null, "refreshable": false, "credential_revision": 0, "pending_catalog_session": null, "publication": { "state": "published", "requested_version": 9, "published_version": 9 } }, "next": { "kind": "catalog_redirect", "url": "https://api.nango.dev/connect/session/...", "expires_at": "2026-09-04T10:25:00+00:00", "catalog_session_id": "cxs_a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1" } } ``` A catalog connect session lives ten minutes and can be consumed once. ## Reconnect a connection `POST /api/v1/workspaces/{workspace_id}/connections/{connection_id}/reconnect` Resumes an unfinished setup. The connection must be `pending`; anything else is `409 connection is not pending`. Replacement OAuth client secret. Refused with `400 connection does not accept an oauth client secret` when the connection has no OAuth profile. Replacement API key. Refused with `400 connection does not accept an api key` when the connection has no API-key profile. Full live keys are refused — `400 full live keys are refused; use a restricted key` for anything starting with `sk_live_`. Sending both is `400 provide only one credential`. An empty body takes the mode the connection already has: a federated connection gets a fresh `catalog_redirect` — and any superseded unconsumed session is deleted first — and a native OAuth connection gets a fresh `redirect` with its `credential_revision` bumped and its outstanding OAuth state rows cleared. ## Disable a connection `POST /api/v1/workspaces/{workspace_id}/connections/{connection_id}/disable` Keeps the connection and its sealed credential, but stops it serving: a disabled connection is skipped when tools are published, and reconnecting restores it. This is the reversible half of the duplicate-connection fix. Two active connections on one connector publish two tool snapshots with the same name, and the gateway binds a call to whichever sorts first — so the second credential would be silently unreachable. Idempotent: disabling an already-disabled connection answers with the projection rather than a conflict. ## Discard a connection `DELETE /api/v1/workspaces/{workspace_id}/connections/{connection_id}` `204` with no body. The only deletion this plane offers, and only for a connection that never held a credential — `credential_revision` still `0`, no sealed ciphertext, no refresh ciphertext, and no `last4`. Anything that has held one, including a row whose secret was destroyed by `purge-credential`, is `409 connection has held a credential and is kept as a record`. The act stays on the record: `connection.start` was audited when the row was made, and this adds `connection.discard`. ## Purge a credential `POST /api/v1/workspaces/{workspace_id}/connections/{connection_id}/purge-credential` Destroys the sealed vendor credential and keeps the row that held it. Clears the access and refresh envelopes, `kms_key_id`, `kek_provider`, `cred_scheme`, `last4`, and `expires_at`; bumps `credential_revision`; and sets `status` to `disabled`. The cleared `last4` moves into the audit detail so the surviving record can say which credential went. Irreversible — Obol holds one copy of a vaulted secret and the plaintext never left the gateway process. The connection returns to service only by being re-credentialed. Idempotent when there is nothing to destroy and the connection is already disabled. ## Store a restricted key `POST /api/v1/workspaces/{workspace_id}/connections/{connection_id}/restricted-key` The restricted vendor key. Sealed by the gateway; control stores only the envelope and `last4`. The connection's auth profile must be `api_key`, otherwise `400 connection is not an api_key profile`. Full live keys are refused with `400 full live keys are refused; use a restricted key` — no pooled vendor accounts and no full `sk_live_` storage (invariant 8). On success the connection becomes `active` and a `connection.credential.store` audit event is recorded. If the workspace snapshot is not published, the endpoint answers with the projection and stores nothing; retry after publication settles. ## Complete a federated connection `POST /api/v1/workspaces/{workspace_id}/connections/{connection_id}/federated-complete` Consumes one issued `cxs_` handoff and would activate the federated connection. Must match `^cxs_[a-f0-9]{32}$`. The broker's own connection id, 1–256 bytes. Rejected with `400 invalid upstream connection id` when it is padded, over 256 bytes, contains control characters, or contains `:` or `//` — it is vendor-supplied and lands verbatim in an audit detail. The broker's MCP endpoint for this connection. Validated against the reviewed origin allowlist frozen onto the session row at handoff time, then probed. Anything refused is `400 mcp target is not allowed`. Federated completion is gated. `complete_federated_session` refuses every well-formed request with `409 broker_verification_unavailable`: a `HEAD` probe proves a reviewed, reachable origin but not who owns the connected account, and no authenticated broker-account verifier exists yet. An operator-supplied identifier cannot be authority for a credentialed route (ADR-0030, invariants 8 and 9). The handoff and the pending connection are left untouched, so nothing is consumed. ### Refusals One fixed detail per closed outcome, so a caller cannot tell "no such session" from "expired", "consumed", "wrong operator", or "wrong provider": | Status | Detail | | --- | --- | | `400` | `invalid upstream connection id` | | `400` | `mcp target is not allowed` | | `409` | `catalog session is not available` | | `409` | `connection is not pending` | | `409` | `connection is not federated` | | `409` | `connection snapshot is not published` | | `409` | `broker_verification_unavailable` | ```bash Request curl -X POST https://control.tryobol.dev/api/v1/workspaces/ws_acme_prod/connections/conn_4b81ee/federated-complete \ -H "Authorization: Bearer $OBOL_SESSION" \ -H "Content-Type: application/json" \ -d '{ "catalog_session_id": "cxs_a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1", "upstream_connection_id": "nango-conn-1", "mcp_url": "https://api.nango.dev/proxy/mcp" }' ``` ```json Response { "detail": "broker_verification_unavailable" } ``` See [/connectors/federated](/connectors/federated) for what a federated route can and cannot prove, and [/routing/catalog](/routing/catalog) for how a connection reaches a snapshot.