Managing Resources

API Keys

Mint, list, and revoke tenant-scoped API keys — roles, per-key scopes, expiry, and environment constraints — the operational workflow.

API keys are the fallback credential that authenticates calls when you're not presenting an OIDC JWT. Each key is tenant-owned, carries a role/scope bundle, and can be constrained by expiry and environment. This page covers the operational lifecycle API: mint, list, rotate, revoke, and inspect the metadata operators need to run those credentials safely.

Choose the right API-key page

Auth · API keys owns the verification model, storage/hash/prefix scheme, roles and scopes, and threat model. Management · API keys owns mint, list, rotate, revoke, and the operational lifecycle API. Issue & use API keys owns the end-to-end task sequence and validation path.

Where it lives

Keys are served by TenantApiKeyService under /api/v2/api-keys and stored in the tenant_api_key table. Secrets are bcrypt-hashed at rest — the plaintext key is returned exactly once, at creation, and is never stored, logged, or recoverable afterward. The application aggregate is at application/tenantapikey/service.go.

The key record

ListApiKeys and the create response return an ApiKeyRecord — metadata only, never the secret or its hash:

Prop

Type

Roles and scopes

  • roles — validated against Ductor's RBAC catalog. Built-ins are viewer, operator, admin, and service, plus any roles a module registers. An unknown role is rejected.
  • scopes — optional, fine-grained resource:action grants (e.g. pool:read, rule:write) that are unioned with whatever the roles expand to.

Scope a key to the least privilege its integration needs. A read-only dashboard key gets viewer; a service that only creates runs gets a narrow scope set, not admin. For the full verification and scope-expansion model, see Auth · API keys.

Environment constraints

environment_mode limits which environments a key may mutate:

ModeEffect
all (default)No environment restriction.
selectedOnly the environments listed in environment_keys.
production_onlyOnly production environments.
non_production_onlyEverything except production.

Supplying environment_keys with an empty mode is treated as selected.

Mint a key

CreateApiKeyPOST /api/v2/api-keys — requires tenant:write and the admin role. The plaintext secret is in the response once.

curl -s -X POST https://api.ductor.io/api/v2/api-keys \
  -H "Authorization: Bearer $DUCTOR_ADMIN_TOKEN" \
  -H "X-Tenant-ID: $DUCTOR_TENANT" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "routing-service-prod",
    "roles": ["operator"],
    "scopes": ["pool:read", "recipient:read", "rule:read"],
    "environment_mode": "production_only",
    "expires_at": "2027-01-01T00:00:00Z"
  }'
{
  "key": {
    "id": "b2c3d4e5-...",
    "name": "routing-service-prod",
    "key_prefix": "duk_9f2a",
    "roles": ["operator"],
    "scopes": ["pool:read", "recipient:read", "rule:read"],
    "status": "active",
    "environment_mode": "production_only",
    "expires_at": "2027-01-01T00:00:00Z",
    "created_at": "2026-07-11T10:35:00Z"
  },
  "secret": "duk_9f2a1b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a"
}

Capture the secret now — there is no second chance

The secret field is output-only and returned exactly once. If you lose it, you cannot recover it: revoke the key and mint a new one. Store it in a secrets manager the moment you receive it; never commit it or log it.

Present the secret on subsequent calls as the bearer token:

curl -s https://api.ductor.io/api/pools \
  -H "Authorization: Bearer duk_9f2a1b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a" \
  -H "X-Tenant-ID: $DUCTOR_TENANT"

List keys

ListApiKeysGET /api/v2/api-keys (tenant:read) — returns the tenant's keys, active and revoked, newest first. Records never include the secret or hash.

curl -s https://api.ductor.io/api/v2/api-keys \
  -H "Authorization: Bearer $DUCTOR_ADMIN_TOKEN" \
  -H "X-Tenant-ID: $DUCTOR_TENANT"
{
  "keys": [
    { "id": "b2c3d4e5-...", "name": "routing-service-prod", "key_prefix": "duk_9f2a",
      "status": "active", "roles": ["operator"], "last_used_at": "2026-07-11T10:40:00Z" }
  ],
  "total_count": 1
}

Use key_prefix and last_used_at to reconcile which key is which and spot stale credentials.

Revoke a key

RevokeApiKeyPOST /api/v2/api-keys/{id}/revoke (tenant:write, admin) — marks the key revoked; it stops authenticating immediately. Revoking an already-revoked key is a no-op, and revocation cannot be undone.

curl -s -X POST https://api.ductor.io/api/v2/api-keys/b2c3d4e5-.../revoke \
  -H "Authorization: Bearer $DUCTOR_ADMIN_TOKEN" \
  -H "X-Tenant-ID: $DUCTOR_TENANT"

Because revocation is instant and irreversible, rotation is mint-then-revoke: create the replacement key, cut traffic over to it, confirm it's serving (watch last_used_at), then revoke the old one. Never revoke first — you'll create an outage window.

Operational checklist

  • Rotate on a schedule using expiry (expires_at) so keys age out even if no one remembers to rotate them. Expired keys are rejected at the auth path.
  • One key per integration, named for the integration, so revocation has a blast radius of one consumer.
  • Least privilege: prefer explicit scopes over broad roles; reserve admin for genuine tenant/key administration.
  • Audit every lifecycle change — creation, status change, and revocation are recorded in a hash-chained trail keyed on tenant_api_key.

Where to go next