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.
Open the API Reference ↗
Browse every service and message, generated from the live proto definitions.
Raw OpenAPI spec ↗
The machine-readable spec for codegen and tooling.
Two protocols, one contract
- REST / JSON — served through grpc-gateway. Plain HTTP with JSON bodies,
ideal for scripts, webhooks, and quick
curlchecks. - 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.
| Extension | Meaning |
|---|---|
x-ductor-lifecycle | supported, experimental, internal, operational, or disabled. |
x-ductor-audience | Intended caller boundary: public, tenant, operator, or internal. |
x-ductor-authentication-required | Whether the operation requires an authenticated identity. |
x-ductor-authorization-required | Whether the authorization interceptor must approve the call. |
x-ductor-authz-resource | Canonical resource type, such as experiment or workflow_run. |
x-ductor-authz-action | Canonical action, such as read, write, preview, or delete. |
x-ductor-authz-scopes | Additional required scopes. |
x-ductor-authz-roles | Any explicit role gate. |
x-ductor-internal-only | Whether 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.
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 onX-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 pending
→ completed 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:
| Endpoint | Returns |
|---|---|
GET /api/_meta/services | A 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/capabilities | The 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/capabilities | The 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/schema | The resolved configuration schema for this build. |
GET /api/config/export | The 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
| RPC | REST |
|---|---|
CreatePool | POST /api/pools |
GetPool | GET /api/pools/{pool_id} |
UpdatePool | PATCH /api/pools/{pool_id} |
SetPoolEnabled | POST /api/pools/{pool_id}:set_enabled |
DeletePool | DELETE /api/pools/{pool_id} |
ListPools | GET /api/pools |
CreateRecipient | POST /api/pools/{pool_id}/recipients |
GetRecipient | GET /api/recipients/{recipient_id} |
PauseRecipient / ResumeRecipient | POST /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-01to pin explicitly. A version older than the minimum supported is rejected with400. - The server echoes
Api-Versionon successful responses. - Calling a deprecated method adds
Deprecation,Sunset, andLinkheaders 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.