Clearing API Reference

Ductor's REST and Connect-RPC API — auth model, tenancy, resource groups, and the full interactive reference.

Ductor exposes one API surface over two protocols, both generated from the same Protocol Buffers definitions, so they never drift. Pick whichever fits your client.

Full interactive reference

The complete, always-current endpoint reference — every schema, request/response shape, and try-it-out — is published with Scalar at api.ductor.io/docs, and the raw spec at api.ductor.io/openapi.yaml. Any running instance serves the same at /docs and /openapi.yaml.

The api.ductor.io host used throughout this page is an illustrative example, not a guaranteed-live endpoint — substitute your own deployment's base URL.

Deep transport reference

This page is the practical tour. For the full transport contract — plane routing, the interceptor chain, the error catalog, and version negotiation — see the API surface reference.

Two protocols, one contract

  • REST / JSON — served through grpc-gateway. Plain HTTP with JSON bodies, ideal for scripts, webhooks, and quick curl checks.
  • Connect-RPC — gRPC-compatible, served on the same port. Use a Connect or gRPC client for typed stubs.

Both share the HTTP listener (:8080 by default); gRPC is additionally exposed on :50051 in the container. The native Connect/gRPC API listener address is api.addr (default :50052).

Machine-readable operation governance

The OpenAPI document carries Ductor's runtime posture on every operation, not just paths and schemas. Client generators, API gateways, documentation tools, and the MCP projection can make the same security and lifecycle decisions as the server without maintaining a second allow-list.

ExtensionMeaning
x-ductor-lifecyclesupported, experimental, internal, operational, or disabled.
x-ductor-audienceIntended caller boundary: public, tenant, operator, or internal.
x-ductor-authentication-requiredWhether the operation requires an authenticated identity.
x-ductor-authorization-requiredWhether the authorization interceptor must approve the call.
x-ductor-authz-resourceCanonical resource type, such as experiment or workflow_run.
x-ductor-authz-actionCanonical action, such as read, write, preview, or delete.
x-ductor-authz-scopesAdditional required scopes.
x-ductor-authz-rolesAny explicit role gate.
x-ductor-internal-onlyWhether the method is restricted to the internal transport plane.

These fields are independent. An operation can be operational and public, supported and tenant-scoped, or internal with an internal-only authorization boundary. Do not infer authorization from the HTTP verb or lifecycle alone.

The generated document also declares production, staging, and local-development servers plus bearer and X-API-Key security schemes. make docs-api rebuilds both OpenAPI copies from protobuf descriptors and the canonical gateway inventory, then checks supported-operation parity. Treat the generated spec as an artifact; make security or lifecycle changes in the protobuf operation metadata or gateway inventory rather than editing openapi.yaml by hand.

Transport planes: external vs internal

The RPC surface is split across two listeners, each with its own interceptor chain:

  • PlaneExternal — the public listener that serves your traffic.
  • PlaneInternal — a loopback (or mTLS-guarded) listener for service-to-service calls inside the cluster.

The split is enforced at the method level. Any method marked (authz).internal_only = true in its proto definition is filtered out of the external plane — the internal listener is the only place those methods are mounted and admitted. This is why the public API never exposes internal service-to-service RPCs even though both planes are generated from the same proto registry: the external plane's registry filter drops them.

Public clients PlaneExternal(public listener) In-cluster services PlaneInternal(loopback / mTLS) External-only methods All methodsincl. internal_only=true

Authentication

Requests authenticate one of two ways (see Security & auth and the API keys guide):

  • OIDC JWT — validated against api.oidc_issuer / api.oidc_audience. The tenant is derived from the token's claims.

    curl -s https://api.ductor.io/api/pools \
      -H "Authorization: Bearer $DUCTOR_JWT"
  • DB-backed API key — for service-to-service calls. Enable with auth.api_key_enabled=true; send the key on X-API-Key.

    curl -s https://api.ductor.io/api/pools \
      -H "X-API-Key: $DUCTOR_API_KEY"

Tenancy

Tenant context comes from the authenticated principal — the JWT claim or the stored API-key record — not from request parameters. You may optionally send an X-Tenant-ID header to select a tenant, but it must match the authenticated tenant or the request is rejected with 403. This is what keeps a caller from acting across tenant boundaries.

Path conventions

REST paths span two generations:

  • /api/... — the core routing plane: pools, recipients, rules, tenants, workflow definitions, events, config.
  • /api/v2/... — newer services: API keys, connectors, connect-sessions, members, environments, billing, secrets.

IDs are ULIDs (lexicographically sortable by creation time). List endpoints are cursor-paginated. Mutating calls accept an idempotency key and record a pendingcompleted transition, so a retried request never double-applies.

Discovery & introspection

Rather than hard-coding assumptions about what a given deployment exposes, ask it. Three auth-gated endpoints let a client discover the live surface and its posture:

EndpointReturns
GET /api/_meta/servicesA ServicesReport listing every mounted Connect service, and for each method its HTTP verb + path and the RBAC scopes captured by the (authz) proto extension. Authenticated callers see the full inventory; unauthenticated callers get 401.
GET /api/capabilitiesThe deployment's per-feature posture: each capability reports a mode of active, disabled, degraded, or unwired, derived from live config and wired dependencies. Answers "which advertised capabilities are actually working here?" without walking the route inventory.
GET /api/v2/capabilitiesThe CapabilityCatalogService catalog — the same posture as a filterable list (include_disabled, include_degraded, include_missing_dependency, and more). Hidden capabilities are never returned by this public API.
GET /api/config/schemaThe resolved configuration schema for this build.
GET /api/config/exportThe effective dynamic configuration as YAML (requires DUCTOR_DYNAMIC_CONFIG_ENABLED=true).

The mode vocabulary is precise: disabled means a feature flag is off, unwired means the flag is on but a required dependency is absent, and degraded means the feature runs but without a guarantee it should have (for example a remote executor without mTLS isolation).

MCP endpoint

Ductor can also serve a Model Context Protocol JSON-RPC 2.0 endpoint so agent harnesses (Claude Code, Codex, Cursor, and others) can discover and call Ductor's read-and-trigger tools — workflows, runs, connectors, pools. It is opt-in (mcp.enabled, default false) and mounts under a configurable path (default /mcp) on the existing API server, reusing the same OIDC / API-key auth chain that guards REST and Connect. See MCP server.

Resource groups

The API is large — dozens of services. The ones you'll reach for most:

Pools & recipients — ManagementService

RPCREST
CreatePoolPOST /api/pools
GetPoolGET /api/pools/{pool_id}
UpdatePoolPATCH /api/pools/{pool_id}
SetPoolEnabledPOST /api/pools/{pool_id}:set_enabled
DeletePoolDELETE /api/pools/{pool_id}
ListPoolsGET /api/pools
CreateRecipientPOST /api/pools/{pool_id}/recipients
GetRecipientGET /api/recipients/{recipient_id}
PauseRecipient / ResumeRecipientPOST /api/recipients/{recipient_id}/pause | /resume

Rules — RulesService

CreateRule POST /api/pools/{pool_id}/rules · GetRule GET /api/rules/{rule_id} · UpdateRule PATCH /api/rules/{rule_id} · DeleteRule DELETE /api/rules/{rule_id} · ValidateRule POST /api/rules/validate.

Experiments — ExperimentService

POST /api/experiments (create a draft) · GET /api/experiments (list) · POST .../{experiment_id}/start / /pause / /complete / /stop · POST .../{experiment_id}/assign (sticky variant and exposure receipt) · POST .../{experiment_id}/scores (idempotent delayed outcome) · GET .../{experiment_id}/results. See Experiments.

Workflow definitions — WorkflowDefinitionService

POST /api/workflow-definitions (create) · GET .../{id} · GET /api/workflow-definitions (list) · PATCH .../{id}/draft · POST .../{id}/publish · POST .../{id}/duplicate · POST .../{id}/archive. See Define & publish a workflow.

Connectors — ConnectorService / ConnectSessionService

All under /api/v2/connector/...: GET /providers, GET /providers/{key}, GET /actions, GET /actions/{key}, GET /triggers, POST /provider-configs, lifecycle transitions (:activate / :disable / :archive), and connect-sessions (POST /connect-sessions, POST /connect-sessions/{id}/revoke). See Add a connector.

Action responses include input properties, output JSON Schema, OAuth scopes, provider-native permissions, related actions, mutation/retry semantics, and runtime_status / runtime_reason. Treat only runtime_status: "executable" as callable; catalog-only actions are discovery contracts, not execution promises.

API keys — TenantApiKeyService

GET /api/v2/api-keys (list) · POST /api/v2/api-keys (create — returns the plaintext secret once) · POST /api/v2/api-keys/{id}/revoke. See Issue & use API keys.

Tenants — TenantService

POST /api/tenants · GET /api/tenants/{tenant_id} · PATCH · DELETE · GET /api/tenants · GET /api/tenants/{tenant_id}/quota/{operation}.

Beyond the core

Ductor also exposes services for routing execution (RoutingService), claims and queues (ClaimQueueService, QueueService, DLQService), events and outcomes (EventsService, OutcomesService, WebhooksService, NotificationService), billing and entitlements (BillingService, EntitlementService, UsageService), members and environments (MemberService, EnvironmentService), secrets and variables, and much more — all in the interactive reference.

Worked examples

Create a pool:

curl -s -X POST https://api.ductor.io/api/pools \
  -H "Authorization: Bearer $DUCTOR_JWT" \
  -H 'Content-Type: application/json' \
  -d '{
    "name": "Premium Buyers Pool",
    "description": "Pool for high-value buyer leads",
    "strategy": "weighted_random",
    "status": "STATUS_ACTIVE"
  }'

The response wraps the created Pool in data alongside metadata. A Pool carries id, name, status, strategy, config, recipient_ids[], rule_ids[], tenant_id, timestamps, and a record_version (the optimistic-concurrency counter).

Create an API key (returns the secret exactly once):

curl -s -X POST https://api.ductor.io/api/v2/api-keys \
  -H "Authorization: Bearer $ADMIN_JWT" \
  -H 'Content-Type: application/json' \
  -d '{ "name": "ci-deploy-key", "roles": ["operator"], "scopes": ["pool:write"], "environment_mode": "production_only" }'

API versioning

Version negotiation runs on an Api-Version request header carrying a date-string version. The current and minimum-supported versions are both 2024-01-01 today, so a caller that omits the header is assumed to be on the current version.

  • Send Api-Version: 2024-01-01 to pin explicitly. A version older than the minimum supported is rejected with 400.
  • The server echoes Api-Version on successful responses.
  • Calling a deprecated method adds Deprecation, Sunset, and Link headers so clients can detect and plan migrations from the response alone.

Errors

Every error resolves to a single canonical WireCode — its Connect code, gRPC code, HTTP status, stable ErrorCode, and a retryable flag — via a classifier that maps domain sentinel errors through one static catalog. Because the catalog is the one source of truth, the Connect/gRPC representation and the REST representation of the same failure never drift.

REST clients receive RFC 9457 application/problem+json:

{
  "type": "https://errors.ductor.io/QUOTA_EXCEEDED",
  "title": "Quota exceeded",
  "status": 429,
  "detail": "daily routing quota exceeded",
  "errorCode": "QUOTA_EXCEEDED",
  "retryable": false
}

The errorCode is the stable identifier to branch on — HTTP status and human titles can change, the code catalog is the contract. retryable tells you whether a retry can succeed: transient classes (RATE_LIMITED, UNAVAILABLE, TIMEOUT, ROUTING_FAILED) are retryable, deterministic ones (QUOTA_EXCEEDED, INVALID_ARGUMENT) are not.

Only errors safe to expose are surfaced; internal failures return a generic INTERNAL / 500 while the real cause is logged with a trace ID. The full code catalog is in the Errors reference, and the classifier and wire mapping are documented in the API surface reference.