Managing Resources

Action Connections

Manage connector connections operationally — create, test, list, and resolve tenant-scoped encrypted credential bindings that connector steps dispatch through.

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. 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 section covers providers, actions, and execution.

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/.

What a connection binds together

ConceptFieldWhat it is
Provider familyprovider_keyWhich connector, e.g. hubspot, salesforce.
Provider instanceprovider_config_keyA configured instance of that provider (distinct from the family).
Auth typeauth_typesecret_text, basic, oauth2, two_step, jwt, …
Credentials(write-only)The secret material — encrypted, never returned.
Non-secret configconnection_configRegion, subdomain, instance URL, etc.
Environmentenvironment_idWhich environment the connection belongs to.
Label / tagslabel, tagsHuman identification and filtering.

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

Prop

Type

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.

Create a connection

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

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" }
  }'
{
  "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

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

OAuthStart (POST /oauth/start) authorize_url + pending connection_id + CSRF state Redirect to authorize_url Consent code + state Return code + state OAuthCallback (POST /oauth/callback) Exchange code + state for tokens Completed Connection App Ductor User Provider
  1. OAuthStartPOST /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. OAuthCallbackPOST /api/v2/connector/oauth/callback (connector:write) — exchange the returned code + state for tokens; the response is the completed Connection.
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"]
  }'

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 for the embeddable flow.

Test a connection

TestConnectionPOST /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.

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"
{ "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

# 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.

UpdateConnectionPATCH /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).

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" } }'

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

Browsing the catalog

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

  • ProvidersGET /api/v2/connector/providers (and /providers/{key}) — the connector families, their auth types, categories, and capabilities (supports_oauth, supports_refresh, …).
  • ActionsGET /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

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.

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.

Where to go next