# Clearing API Reference (/docs/api-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.

<Callout title="Full interactive reference">
  The complete, always-current endpoint reference — every schema,
  request/response shape, and try-it-out — is published with Scalar at
  &#x2A;*[api.ductor.io/docs](https://api.ductor.io/docs)*&#x2A;, and the raw spec at
  &#x2A;*[api.ductor.io/openapi.yaml](https://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.
</Callout>

<Callout type="info" title="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](/docs/reference/api-surface).
</Callout>

<Cards>
  <Card title="Open the API Reference ↗" href="https://api.ductor.io/docs">
    Browse every service and message, generated from the live proto definitions.
  </Card>

  <Card title="Raw OpenAPI spec ↗" href="https://api.ductor.io/openapi.yaml">
    The machine-readable spec for codegen and tooling.
  </Card>
</Cards>

## Two protocols, one contract [#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 [#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 [#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.

```mermaid
flowchart LR
  PC["Public clients"] --> PE["PlaneExternal<br/>(public listener)"]
  IC["In-cluster services"] --> PI["PlaneInternal<br/>(loopback / mTLS)"]
  PE --> EM["External-only methods"]
  PI --> AM["All methods<br/>incl. internal_only=true"]
```

## Authentication [#authentication]

Requests authenticate one of two ways (see
[Security & auth](/docs/operations/security) and the
[API keys guide](/docs/guides/api-keys)):

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

  ```bash
  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`.

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

## Tenancy [#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 [#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](/docs/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 [#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 [#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](/docs/ai/mcp-server).

## Resource groups [#resource-groups]

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

### Pools & recipients — `ManagementService` [#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` [#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` [#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](/docs/management/experiments).

### Workflow definitions — `WorkflowDefinitionService` [#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](/docs/guides/define-workflow).

### Connectors — `ConnectorService` / `ConnectSessionService` [#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](/docs/guides/add-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` [#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](/docs/guides/api-keys).

### Tenants — `TenantService` [#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 [#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`](/docs/billing),
[`EntitlementService`](/docs/concepts/entitlements),
[`UsageService`](/docs/billing)), members and environments (`MemberService`,
`EnvironmentService`), secrets and variables, and much more — all in the
[interactive reference](https://api.ductor.io/docs).

## Worked examples [#worked-examples]

**Create a pool:**

```bash
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):

```bash
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 [#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 [#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](https://datatracker.ietf.org/doc/html/rfc9457)
`application/problem+json`:

```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](/docs/reference/errors), and the classifier
and wire mapping are documented in the
[API surface reference](/docs/reference/api-surface).
