Control never holds a vendor access token in plaintext. It issues the authorize URL, consumes the vendor’s redirect, and asks the gateway to perform the code exchange; the ciphertext comes back sealed and control stores the envelope (ADR-0004, ADR-0023). Background refresh runs in a control worker that calls the same gateway endpoint — there is no HTTP refresh route on this API. Connection lifecycle endpoints live in the catalog router and are documented in full on /api-reference/catalog. This page covers the OAuth path through them and the one endpoint the OAuth router owns.

Authentication

Every /api/v1 route requires a dashboard session token — see /api-reference/overview for how it is verified:
Responses under /api/v1 carry Cache-Control: no-store. A caller that is not a member of the workspace gets 404, never 403 — an “exists but forbidden” answer would be an oracle for workspace ids across tenants. A member whose role does not grant the action gets 403 action not permitted; a frozen workspace answers 409 workspace is frozen on any non-read action. GET /oauth/callback is the exception: it is unauthenticated because the vendor redirects a browser to it. It is bound instead to a single-use, hashed OAuth state row and to the operator who initiated the connection.

Start an OAuth connection

POST /api/v1/workspaces/{workspace_id}/connections Requires the connection.manage action (admin or owner). Creates a pending connection for a native OAuth catalog entry and, once the workspace snapshot is published, returns an authorize URL for the operator’s browser.
string
required
Workspace the connection belongs to.
string
required
Catalog slug, for example github.
string
required
Catalog lane. Use native for the gateway-owned OAuth path. nango and composio take the federated path instead — see /api-reference/catalog.
string
Your own OAuth client id. No pooled Obol OAuth app exists: a connection uses the customer’s own client (invariant 8). Falls back to the deployment’s configured client id; when neither is present the request is refused with 400 oauth client id is required.
string
Your OAuth client secret. Sent to the gateway to be sealed, then stored only as ciphertext on the connection’s auth profile. Never echoed back.

Response

Every connection mutation answers with the same envelope.
integer
Always 1.
object
The operator_connection projection: connection_id, workspace_id, connector_slug, connector_display_name, source, tier, status, custody, evidence_ceiling, last4, expires_at, last_refreshed_at, refreshable, credential_revision, pending_catalog_session, and publication. No broker_ref, target, or auth_profile ever leaves here.
object
What the caller must do next. See The next object.

The next object

string
required
One of none, redirect, catalog_redirect, or restricted_key.
The authorize URL is built from the connection’s stored OAuth profile plus a fresh PKCE challenge: client_id, redirect_uri, scope, state, code_challenge, code_challenge_method=S256, and response_type=code. The state value is returned only inside next.url and is never a separate field in the response body. Its row stores only the SHA-256 hash, and it expires ten minutes after issue.

Complete the vendor redirect

GET /oauth/callback The redirect target the vendor sends the operator’s browser back to. It is mounted at the root of the control app, not under /api/v1, and takes no Authorization header.
string
Authorization code from the vendor. Absent on a denied or failed authorization.
string
required
The opaque state issued in the authorize URL. Single use.
string
Vendor error code. When present, the code is ignored.

Behavior

The state row is consumed first and committed immediately, so a replayed callback finds nothing to consume. The callback then re-derives its authority from the row: the operator who started the connection must still exist, still be a member, and still hold connection.manage, and the connection must still be ready for OAuth. The exchange itself is a gateway call; control receives a sealed envelope and writes it under a fresh workspace lock, records a connection.credential.store audit event, and stages a snapshot publication. Only two outcomes exist:
  • 400 invalid or expired oauth state when state is missing, malformed, unknown, expired, or already consumed. This one message covers all of them deliberately.
  • 302 to the dashboard for everything else.
The redirect target is {OBOL_WEB_URL}/dashboard/connectors with two query parameters:
string
connected when the credential is stored and the workspace snapshot published, pending when it is stored but publication has not settled, and error for a vendor error, a missing or malformed code, a failed exchange, or an authorization that no longer holds.
error is intentionally undifferentiated — the callback is a browser destination, not a diagnostic surface. Use the audit log to see whether a connection.credential.store event was recorded.

Re-authorize or rotate

POST /api/v1/workspaces/{workspace_id}/connections/{connection_id}/reconnect Requires connection.manage, and the connection must be pending. With an empty body on a native OAuth connection, this bumps credential_revision, deletes any outstanding OAuth state rows for the connection, and issues a fresh redirect. With client_secret, it seals a replacement OAuth client secret first, then issues the redirect. Full parameters, including the federated and API-key modes, are on /api-reference/catalog.
Reconnect

Revoke access

There is no OAuth revoke endpoint. Two connection endpoints cover the cases:
  • POST /api/v1/workspaces/{workspace_id}/connections/{connection_id}/disable stops the connection serving traffic and leaves the credential sealed, so reconnecting restores it.
  • POST /api/v1/workspaces/{workspace_id}/connections/{connection_id}/purge-credential destroys the sealed access and refresh envelopes, clears last4 and expires_at, bumps credential_revision, and leaves the row as the audit record of a credential the workspace once held. Irreversible.
Both are documented on /api-reference/catalog.

Token refresh

Refresh has no HTTP surface. A scheduled control worker (refresh_expiring_credentials) claims connections whose credential is nearing expiry under a row lock, calls the gateway’s /internal/v1/credentials/refresh, writes the returned envelope, and republishes the workspace snapshot. connection.refreshable in the projection is true exactly when the connection’s auth profile type is oauth2. See /security/oauth for the credential lifecycle and /security/credential-brokering for how the sealed envelope reaches an outbound call.