# Action Connections (/docs/connectors/connections)



A **connection** is one tenant's authenticated instance of a provider — the row
a step actually dispatches against. This page covers the connection model, how a
step picks which connection to use, and the management API you use to create and
operate them.

## The Connection model [#the-connection-model]

A `Connection` (`domain/connector/connection.go`) is always partitioned by
`TenantID` and belongs to a `(EnvironmentID, ProviderConfigKey)` provider config.
The fields that matter to you:

| Field                                | Purpose                                                                                                                                                                        |
| ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `ID`                                 | UUID; the handle you dispatch against.                                                                                                                                         |
| `TenantID`                           | Owning tenant — the isolation boundary. Every read and write is tenant-scoped.                                                                                                 |
| `ProviderKey`                        | Provider family (`hubspot`).                                                                                                                                                   |
| `EnvironmentID`, `ProviderConfigKey` | The configured provider instance this connection belongs to.                                                                                                                   |
| `AuthType`                           | The auth type this connection was created with.                                                                                                                                |
| `Label`                              | Human-readable, tenant-chosen name.                                                                                                                                            |
| `Status`                             | Lifecycle state (below).                                                                                                                                                       |
| `ExternalAccountID`                  | The provider-side account id (e.g. a HubSpot portal id) used to demux shared webhooks.                                                                                         |
| `ConnectionConfig`                   | **Non-secret** per-connection config (subdomain, region, instance URL), stored as JSONB, referenceable from templated proxy fields via `${connectionConfig.x}`. Not encrypted. |
| `Tags`                               | Low-cardinality searchable attribution (`env`, `region`, `customer_tier`).                                                                                                     |
| `Metadata`                           | Non-secret operator/app context readable by hooks and routing CEL; not returned in ordinary list/get.                                                                          |
| `CredentialsEnc`, `OAuthEnc`         | AEAD-encrypted credential and OAuth-token blobs. Set by the store; decrypted only in a request-scoped `AuthContext`.                                                           |

<Callout type="info" title="Config is wire-safe; credentials never are">
  `ConnectionConfig`, `Tags`, and `Metadata` are non-secret and travel over the
  API. Credentials are a separate, always-encrypted blob — they are never
  returned by any endpoint and only ever exist in plaintext inside the
  short-lived `AuthContext` produced at dispatch. See
  [Authentication](/docs/connectors/authentication).
</Callout>

### Status lifecycle [#status-lifecycle]

`ConnectionStatus` has five values:

| Status    | Meaning                                          |
| --------- | ------------------------------------------------ |
| `active`  | Usable; the happy path.                          |
| `pending` | OAuth started but the callback hasn't completed. |
| `expired` | OAuth token expired and refresh failed.          |
| `revoked` | Revoked by the tenant or provider.               |
| `errored` | A test or dispatch surfaced a hard auth error.   |

Only `active` connections resolve; the rest return a typed error at dispatch.

## Choosing a connection: direct vs. route [#choosing-a-connection-direct-vs-route]

A dispatch request carries a `ConnectionMode` (`domain/connector/connection_mode.go`):

* **`direct`** (default) — resolve the named `ConnectionID`.
* **`route`** — let the connector routing interceptor select a connection at
  execution time, returning the chosen one in `resolved_connection_id`.

Route mode evaluates two mechanisms:

* **Connection routes** (`connection_route.go`) — a per-`(tenant, provider)`
  ordered fallback chain. Each `ConnectionRoute` has a `Priority` and a
  `CELExpression`; routes are scanned by `priority ASC, created_at ASC`, and the
  first whose CEL is true (or empty, meaning match-all) is the primary, the rest
  are fallbacks.
* **Selection strategies** (`connection_selection.go`) — a pluggable
  `ConnectionSelectionPolicy` picks a strategy: `priority_chain` (default),
  `least_throttled`, `healthiest`, `scope_capability_match`, `region_affinity`,
  `cost_aware`, `weighted_canary`, `weighted_failover`, or `sticky_by_entity`.
  Each strategy declares the facts it needs — `least_throttled` needs the
  `connector_quota` fact, `scope_capability_match` needs `oauth_scopes` and
  `operation_capabilities`, `cost_aware` needs `usage_cost`.

The two mechanisms in route mode compose in a fixed order — CEL routes narrow
the candidate chain first, then the selection strategy picks within what
survived:

```mermaid
flowchart TD
  d([dispatch]) --> m{connection mode}
  m -->|direct| id([use named connection id])
  m -->|route| routes[scan routes by priority asc]
  routes --> cel{first CEL true?}
  cel -->|yes| chain[primary + fallbacks]
  cel -->|no match| chain
  chain --> strat[selection strategy] --> pick([resolved_connection_id])
```

<Callout title="Route mode is how you get failover and canaries">
  Direct mode is simplest and best when the caller knows exactly which account to
  use. Route mode is what you reach for when a tenant has several connections for
  the same provider and you want automatic failover, least-throttled selection,
  or a weighted canary — without the caller hard-coding a connection id.
</Callout>

## The management API [#the-management-api]

Connector management lives under `service ConnectorService`
(`api/proto/api/connector_service.proto`), exposed over Connect-RPC and as REST
via grpc-gateway under `/api/v2/connector/...`. The examples below use the REST
surface. All requests are tenant-scoped by the caller's auth.

### Create a connection [#create-a-connection]

<Tabs items="[&#x22;API key or secret&#x22;, &#x22;OAuth2&#x22;]">
  <Tab value="API key or secret">
    For auth types where you already hold the secret (API key, basic, secret text):

    ```bash
    curl -X POST https://your-host/api/v2/connector/connections \
      -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' \
      -d '{
        "provider_key": "hubspot",
        "auth_type": "API_KEY",
        "label": "Acme HubSpot (prod)",
        "environment_id": "prod",
        "provider_config_key": "hubspot-main",
        "credentials": { "api_key": "pat-na1-xxxxxxxx" },
        "connection_config": { "region": "na1" },
        "tags": { "env": "prod", "customer_tier": "enterprise" }
      }'
    ```

    `credentials` values whose fields are marked sensitive are encrypted and never
    returned. `connection_config` is capped (max 16 keys, 4 KB per value, 64 KB
    total). The response is a `Connection` with no credential material on the wire:

    ```json
    {
      "id": "6f6d1c2e-1a2b-4c3d-9e8f-0a1b2c3d4e5f",
      "tenant_id": "acme",
      "provider_key": "hubspot",
      "environment_id": "prod",
      "provider_config_key": "hubspot-main",
      "auth_type": "API_KEY",
      "label": "Acme HubSpot (prod)",
      "status": "active",
      "connection_config": { "region": "na1" },
      "tags": { "env": "prod", "customer_tier": "enterprise" }
    }
    ```
  </Tab>

  <Tab value="OAuth2">
    For OAuth providers you don't hold the secret up front — you start an authorize
    flow and complete it on callback:

    ```bash
    # 1. Start — returns an authorize URL, a pending connection, and a CSRF state.
    curl -X POST https://your-host/api/v2/connector/oauth/start \
      -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' \
      -d '{
        "provider_key": "salesforce",
        "provider_config_key": "sf-main",
        "environment_id": "prod",
        "label": "Acme Salesforce",
        "scopes": ["refresh_token", "api"],
        "connection_config": { "instance": "acme" },
        "tags": { "env": "prod" }
      }'
    ```

    ```json
    {
      "authorize_url": "https://login.salesforce.com/services/oauth2/authorize?...",
      "connection_id": "…pending-uuid…",
      "state": "…csrf…"
    }
    ```

    Redirect the user to `authorize_url`. When the provider calls back with a code,
    complete the exchange:

    ```bash
    # 2. Callback — exchanges the code and activates the pending connection.
    curl -X POST https://your-host/api/v2/connector/oauth/callback \
      -H 'Content-Type: application/json' \
      -d '{ "state": "…csrf…", "code": "…authcode…", "redirect_url": "https://app.example.com/cb" }'
    ```

    The response is an activated `Connection`.

    <Callout type="info" title="connection_config for templated OAuth URLs">
      Some providers template their authorize and token URLs with
      `${connectionConfig.x}` (a per-customer subdomain or region). For those,
      `connection_config` is **required** at `oauth/start` so the URLs can be
      resolved.
    </Callout>
  </Tab>
</Tabs>

### Test, list, update, delete [#test-list-update-delete]

```bash
# Test — actively probes the provider and reports a structured result.
curl -X POST https://your-host/api/v2/connector/connections/{id}/test \
  -H 'Authorization: Bearer <token>'
# → { "ok": true, "tested_at": "...", "error_code": "", "retryable": false, "needs_reconnect": false }

# List — filter by provider, tags, environment, or provider config.
curl 'https://your-host/api/v2/connector/connections?provider_key=hubspot&environment_id=prod' \
  -H 'Authorization: Bearer <token>'

# Update / delete
curl -X PATCH  https://your-host/api/v2/connector/connections/{id} -d '{ "label": "..." }'
curl -X DELETE https://your-host/api/v2/connector/connections/{id}
```

### Execute an action against a connection [#execute-an-action-against-a-connection]

```bash
curl -X POST https://your-host/api/v2/connector/execute-action \
  -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' \
  -d '{
    "action_key": "hubspot.create_contact",
    "connection_id": "6f6d1c2e-1a2b-4c3d-9e8f-0a1b2c3d4e5f",
    "connection_mode": "direct",
    "provider_config_key": "hubspot-main",
    "inputs": { "email": "jane@acme.com", "firstname": "Jane" }
  }'
```

For route mode, send `"connection_id": ""`, `"connection_mode": "route"`, and a
route provider key; the response carries `resolved_connection_id`. To run the
same work durably in the background, `POST /api/v2/connector/action-jobs` instead
(see the async job model in [Architecture](/docs/connectors/architecture)).

## Connect sessions: hosted OAuth for your end users [#connect-sessions-hosted-oauth-for-your-end-users]

When you're embedding Ductor and want *your* end users to link *their* accounts
without your backend proxying secrets, use a **connect session**
(`domain/connector/connect_session.go`, `service ConnectSessionService`). You
create a short-lived, signed session server-side and hand the user a hosted link:

```bash
curl -X POST https://your-host/api/v2/connector/connect-sessions \
  -H 'Authorization: Bearer <token>' -H 'Content-Type: application/json' \
  -d '{
    "tenant_id": "acme",
    "end_user": { "external_id": "user-42", "email": "jane@acme.com", "organization": "Acme" },
    "allowed_providers": ["hubspot", "salesforce"],
    "policy": { "allowed_scopes": ["contacts.read"], "max_connection_ttl": "86400s" },
    "environment_id": "prod",
    "provider_config_key": "hubspot-main",
    "ttl": "900s"
  }'
```

```json
{
  "session_id": "…",
  "token": "dct_cs_…",
  "connect_link": "https://connect.your-host/connect/…",
  "expires_at": "2026-07-11T…Z"
}
```

The `token` is returned exactly once — storage only holds its SHA-256 hash. The
user opens `connect_link`, which is served by a pre-auth `/connect/*` router
(`transport/gateway/connectrouter/`) that runs the OAuth flow and creates the
connection scoped to the session's `allowed_providers` and downscoped
`allowed_scopes`. Sessions are one-shot and revocable.

## Guided setup [#guided-setup]

For artifacts that need more than a single credential to be usable — a provider
config plus a webhook subscription plus a field mapping — Ductor has a resumable
**setup flow** (`domain/connector/setup.go`, `setupstore/`). A
`ConnectorSetupProfile` declares the ordered `ConnectorSetupRequirement`s
(authorization, connection config, webhook subscription, mapping profile,
permission, readiness check, …), and a `ConnectorSetupRun` walks a caller through
submitting values, validating, and applying them, with readiness gates per
surface (`action`, `sync`, `webhook`, `workflow`, `routing`, `mcp`, `promotion`).
The RPCs live under `/api/v2/connector/setup/...`.

## Where to go next [#where-to-go-next]

* [Authentication](/docs/connectors/authentication) — how a resolved connection becomes a usable token.
* [Triggers & Polling](/docs/connectors/triggers-and-polling) — inbound events from a connection.
* [Policies, Quotas & Audit](/docs/connectors/policies-quotas-audit) — governing what a connection may reach.
