Connectors

Action Connections

Creating and resolving connections, direct vs. route mode, connect sessions, and the management API with real request/response payloads.

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

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:

FieldPurpose
IDUUID; the handle you dispatch against.
TenantIDOwning tenant — the isolation boundary. Every read and write is tenant-scoped.
ProviderKeyProvider family (hubspot).
EnvironmentID, ProviderConfigKeyThe configured provider instance this connection belongs to.
AuthTypeThe auth type this connection was created with.
LabelHuman-readable, tenant-chosen name.
StatusLifecycle state (below).
ExternalAccountIDThe provider-side account id (e.g. a HubSpot portal id) used to demux shared webhooks.
ConnectionConfigNon-secret per-connection config (subdomain, region, instance URL), stored as JSONB, referenceable from templated proxy fields via ${connectionConfig.x}. Not encrypted.
TagsLow-cardinality searchable attribution (env, region, customer_tier).
MetadataNon-secret operator/app context readable by hooks and routing CEL; not returned in ordinary list/get.
CredentialsEnc, OAuthEncAEAD-encrypted credential and OAuth-token blobs. Set by the store; decrypted only in a request-scoped AuthContext.

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.

Status lifecycle

ConnectionStatus has five values:

StatusMeaning
activeUsable; the happy path.
pendingOAuth started but the callback hasn't completed.
expiredOAuth token expired and refresh failed.
revokedRevoked by the tenant or provider.
erroredA 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

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:

direct route yes no match dispatch connection mode use named connection id scan routes by priority asc first CEL true? primary + fallbacks selection strategy resolved_connection_id

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.

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

For auth types where you already hold the secret (API key, basic, secret text):

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:

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

Test, list, update, delete

# 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

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": "[email protected]", "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).

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:

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": "[email protected]", "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"
  }'
{
  "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

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 ConnectorSetupRequirements (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