# Action Connections (/docs/management/connections)



A **connection** is a tenant-scoped, encrypted binding to a third-party provider:
the credentials and configuration that let a workflow action step call HubSpot,
Salesforce, Stripe, or any of Ductor's 100+ [connectors](/docs/connectors). You
manage connections here — create, test, list, update, revoke — and the runtime
resolves them into an auth context at dispatch time. This page is the operational
slice; the [Connectors](/docs/connectors) section covers providers, actions, and
execution.

<Callout title="Where it lives">
  Connections are served by `ConnectorService` under
  `/api/v2/connector/connections` and persisted to the `connector_connection`
  table with credentials **encrypted at rest** (XChaCha20-Poly1305 AEAD). The
  application service is at `application/connector/`; encryption at
  `infrastructure/connector/crypto/`.
</Callout>

## What a connection binds together [#what-a-connection-binds-together]

| Concept           | Field                 | What it is                                                         |
| ----------------- | --------------------- | ------------------------------------------------------------------ |
| Provider family   | `provider_key`        | Which connector, e.g. `hubspot`, `salesforce`.                     |
| Provider instance | `provider_config_key` | A configured instance of that provider (distinct from the family). |
| Auth type         | `auth_type`           | `secret_text`, `basic`, `oauth2`, `two_step`, `jwt`, …             |
| Credentials       | (write-only)          | The secret material — **encrypted, never returned**.               |
| Non-secret config | `connection_config`   | Region, subdomain, instance URL, etc.                              |
| Environment       | `environment_id`      | Which environment the connection belongs to.                       |
| Label / tags      | `label`, `tags`       | Human identification and filtering.                                |

The stored `Connection` object you read back contains everything **except** the
credentials:

<TypeTable
  type="{
  id: { description: &#x22;Output-only.&#x22;, type: &#x22;string (uuid)&#x22; },
  tenant_id: { description: &#x22;Output-only; from the request context.&#x22;, type: &#x22;string&#x22; },
  provider_key: { description: &#x22;The bound provider.&#x22;, type: &#x22;string&#x22; },
  provider_config_key: { description: &#x22;The bound provider.&#x22;, type: &#x22;string&#x22; },
  auth_type: { description: &#x22;Credential scheme.&#x22;, type: &#x22;string&#x22; },
  label: { description: &#x22;Human label.&#x22;, type: &#x22;string&#x22; },
  status: { description: &#x22;One of: active, pending, expired, revoked, errored.&#x22;, type: &#x22;string&#x22; },
  connection_config: { description: &#x22;Non-secret config.&#x22;, type: &#x22;map<string,string>&#x22; },
  tags: { description: &#x22;Filter labels.&#x22;, type: &#x22;map<string,string>&#x22; },
  last_tested_at: { description: &#x22;Output-only health of the last test.&#x22;, type: &#x22;ts&#x22; },
  last_test_error: { description: &#x22;Output-only health of the last test.&#x22;, type: &#x22;string&#x22; },
  created_at: { description: &#x22;Output-only.&#x22;, type: &#x22;timestamp&#x22; },
  updated_at: { description: &#x22;Output-only.&#x22;, type: &#x22;timestamp&#x22; },
}"
/>

<Callout title="Credentials are one-way">
  Credential values are encrypted with per-connection AEAD the moment they're
  received and are **never** returned on any read. The encryption is bound to
  `tenant_id` + `provider_key` as additional authenticated data, so a ciphertext
  from one tenant cannot be decrypted under another — cross-tenant credential
  swaps fail loudly. You can replace credentials (via update) but never read them
  back.
</Callout>

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

`CreateConnection` — `POST /api/v2/connector/connections`
(`connector:write`). Supply the provider, a label, the credentials, and any
non-secret config.

```bash
curl -s -X POST https://api.ductor.io/api/v2/connector/connections \
  -H "Authorization: Bearer $DUCTOR_API_KEY" \
  -H "X-Tenant-ID: $DUCTOR_TENANT" \
  -H "Content-Type: application/json" \
  -d '{
    "provider_key": "hubspot",
    "provider_config_key": "hubspot-prod",
    "environment_id": "env_9a1b",
    "auth_type": "secret_text",
    "label": "Acme HubSpot (prod)",
    "credentials": { "api_key": "pat-na1-...." },
    "connection_config": { "region": "na1" },
    "tags": { "env": "prod", "team": "sales" }
  }'
```

```json
{
  "id": "c1a2b3d4-...",
  "tenant_id": "9c8b7a6d-...",
  "provider_key": "hubspot",
  "provider_config_key": "hubspot-prod",
  "auth_type": "secret_text",
  "label": "Acme HubSpot (prod)",
  "status": "active",
  "connection_config": { "region": "na1" },
  "tags": { "env": "prod", "team": "sales" },
  "created_at": "2026-07-11T11:20:00Z"
}
```

On create, Ductor validates the credential payload against the provider's
schema, encrypts it, and immediately runs the provider's auth test. A connection
that fails the test lands in `errored` status with the reason on
`last_test_error`.

## OAuth2 providers [#oauth2-providers]

For OAuth2 providers you don't send raw credentials — you run the authorize
flow:

```mermaid
sequenceDiagram
    participant App
    participant Ductor
    participant User
    participant Provider
    App->>Ductor: OAuthStart (POST /oauth/start)
    Ductor-->>App: authorize_url + pending connection_id + CSRF state
    App->>User: Redirect to authorize_url
    User->>Provider: Consent
    Provider-->>User: code + state
    User->>App: Return code + state
    App->>Ductor: OAuthCallback (POST /oauth/callback)
    Ductor->>Provider: Exchange code + state for tokens
    Ductor-->>App: Completed Connection
```

1. `OAuthStart` — `POST /api/v2/connector/oauth/start` (`connector:write`) —
   returns an `authorize_url`, a pending `connection_id`, and a CSRF `state`.
2. Redirect the user to `authorize_url`; they consent at the provider.
3. `OAuthCallback` — `POST /api/v2/connector/oauth/callback` (`connector:write`) —
   exchange the returned `code` + `state` for tokens; the response is the
   completed `Connection`.

```bash
curl -s -X POST https://api.ductor.io/api/v2/connector/oauth/start \
  -H "Authorization: Bearer $DUCTOR_API_KEY" \
  -H "X-Tenant-ID: $DUCTOR_TENANT" \
  -H "Content-Type: application/json" \
  -d '{
    "provider_key": "salesforce",
    "provider_config_key": "sf-prod",
    "environment_id": "env_9a1b",
    "label": "Acme Salesforce",
    "scopes": ["refresh_token", "api"]
  }'
```

<Callout type="info">
  To let an **end user** connect their own account without exposing your admin
  API, mint a short-lived Connect Session
  (`POST /api/v2/connector/connect-sessions`). It returns a one-time token and a
  `connect_link` you hand to the user; they complete OAuth against a hosted
  connect page that drives `OAuthStart`/`OAuthCallback` for you. See
  [Connectors](/docs/connectors) for the embeddable flow.
</Callout>

## Test a connection [#test-a-connection]

`TestConnection` — `POST /api/v2/connector/connections/{id}/test`
(`connector:write`) — re-runs the provider's auth probe on demand and updates
`last_tested_at` / `last_test_error`.

```bash
curl -s -X POST https://api.ductor.io/api/v2/connector/connections/$CONN_ID/test \
  -H "Authorization: Bearer $DUCTOR_API_KEY" -H "X-Tenant-ID: $DUCTOR_TENANT"
```

```json
{ "ok": false, "error_message": "401 from provider", "error_code": "unauthorized",
  "error_category": "auth", "retryable": false, "needs_reconnect": true,
  "tested_at": "2026-07-11T11:25:00Z" }
```

`needs_reconnect: true` is the signal to re-run OAuth or replace credentials;
`retryable` distinguishes a transient failure from a bad credential.

## Read, update, delete [#read-update-delete]

```bash
# One connection
curl -s https://api.ductor.io/api/v2/connector/connections/$CONN_ID \
  -H "Authorization: Bearer $DUCTOR_API_KEY" -H "X-Tenant-ID: $DUCTOR_TENANT"

# List, filter by provider and/or tags
curl -s "https://api.ductor.io/api/v2/connector/connections?provider_key=hubspot&page_size=50" \
  -H "Authorization: Bearer $DUCTOR_API_KEY" -H "X-Tenant-ID: $DUCTOR_TENANT"
```

`GetConnection` and `ListConnections` require `connector:read`. List supports
`provider_key`, `environment_id`, `provider_config_key`, and `tags` filters
(all supplied tags must match) with the standard `page_size`/`page_token`
cursor.

`UpdateConnection` — `PATCH /api/v2/connector/connections/{id}`
(`connector:write`) — replaces the fields you send. Supplying `credentials`
re-encrypts a fresh secret; supplying `connection_config` replaces the whole map
(an empty map clears it).

```bash
curl -s -X PATCH https://api.ductor.io/api/v2/connector/connections/$CONN_ID \
  -H "Authorization: Bearer $DUCTOR_API_KEY" \
  -H "X-Tenant-ID: $DUCTOR_TENANT" \
  -H "Content-Type: application/json" \
  -d '{ "credentials": { "api_key": "pat-na1-rotated" } }'
```

`DeleteConnection` — `DELETE /api/v2/connector/connections/{id}`
(`connector:write`) — removes the connection.

## Browsing the catalog [#browsing-the-catalog]

Two read-only surfaces let you discover what you can connect to, both
`connector:read`:

* **Providers** — `GET /api/v2/connector/providers` (and
  `/providers/{key}`) — the connector families, their auth types, categories,
  and capabilities (`supports_oauth`, `supports_refresh`, …).
* **Actions** — `GET /api/v2/connector/actions?provider_key=hubspot` (and
  `/actions/{key}`) — the fully-qualified actions (e.g. `hubspot.create_contact`)
  a provider exposes, which are what a workflow action step's `action` field
  references.

## How the runtime resolves a connection [#how-the-runtime-resolves-a-connection]

You never fetch credentials yourself. When a workflow action step dispatches, the
runtime resolves the referenced connection into an **auth context**:

1. Load the `connector_connection` row (from a short-lived cache, with
   single-flight de-duplication of misses).
2. Gate on status — only `active` proceeds; `pending`/`expired`/`revoked`/
   `errored` fail with a typed error.
3. Decrypt the credentials with the tenant + provider AAD.
4. For OAuth2, refresh the token if it's within \~60s of expiry, per-connection
   single-flighted so concurrent steps don't stampede the refresh.
5. Build the auth context the connector uses to make the call.

That auth context is ephemeral and per-dispatch; the plaintext secret never
leaves this path and is never persisted decrypted.

<Callout title="Encryption key is required in production">
  Credential encryption is keyed by `connector.encryption_key`
  (`DUCTOR_CONNECTOR_ENCRYPTION_KEY`, a base64 32-byte key). In production the
  process refuses to start without it. Rotating the key is supported through a keyring so old
  ciphertexts stay decryptable during the rotation window. See
  [Configuration](/docs/management/configuration).
</Callout>

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

<Cards>
  <Card title="Connectors" href="/docs/connectors">
    Providers, actions, the execution model, and the embeddable connect flow.
  </Card>

  <Card title="Workflows" href="/docs/management/workflows">
    Action steps reference a connection to dispatch through.
  </Card>

  <Card title="Configuration" href="/docs/management/configuration">
    The encryption key and other runtime configuration.
  </Card>
</Cards>
