# Tenants (/docs/management/tenants)



A **tenant** is Ductor's top-level isolation boundary. Every pool, recipient,
rule, connection, workflow definition, and run belongs to exactly one tenant,
and that identity flows through storage queries, cache keys, and capacity
counters so isolation is a structural property, not a filter you might forget
(see [Tenancy](/docs/concepts/tenancy)). Managing tenants is therefore the first
thing you do on a fresh deployment and the boundary within which every other
resource in this section is created.

## What a tenant carries [#what-a-tenant-carries]

A tenant is more than an ID. Each one bundles three things you manage over its
lifetime:

* **Quotas** — hard ceilings on how much routing topology and traffic the tenant
  may consume.
* **Metadata** — arbitrary string key/value pairs for your own bookkeeping
  (billing account, region, owner).
* **Feature flags** — a list of capability names that gate optional behavior for
  this tenant (`advanced_routing`, `webhooks`, …).

<Callout title="Where it lives">
  Tenants are served by `TenantService` and persisted to the `tenants` table in
  Postgres. The application aggregate is at
  `application/tenant/service.go`; quota and config defaults are applied there
  when you omit them at creation.
</Callout>

## Why and when you manage tenants [#why-and-when-you-manage-tenants]

You create a tenant whenever you onboard a new customer, environment, or
isolated workload. You update one to raise a quota as a customer grows, to flip
on a [feature they've purchased](/docs/billing), or to suspend an account. Because a tenant is
the isolation boundary, these operations are **admin-gated**: every mutating
tenant RPC requires the `admin` role.

## The tenant object [#the-tenant-object]

<TypeTable
  type="{
  id: { description: &#x22;Output-only. Generated on create if you don't supply one.&#x22;, type: &#x22;string (uuid)&#x22; },
  name: { description: &#x22;Required, unique across the system.&#x22;, type: &#x22;string&#x22; },
  status: { description: &#x22;One of: active, suspended, pending, or deleted.&#x22;, type: &#x22;string&#x22; },
  quotas: { description: &#x22;Ceilings; see below.&#x22;, type: &#x22;TenantQuotas&#x22; },
  metadata: { description: &#x22;Your key/value bookkeeping.&#x22;, type: &#x22;map<string,string>&#x22; },
  features: { description: &#x22;Enabled feature-flag names.&#x22;, type: &#x22;repeated string&#x22; },
  created_at: { description: &#x22;Output-only.&#x22;, type: &#x22;timestamp&#x22; },
  updated_at: { description: &#x22;Output-only.&#x22;, type: &#x22;timestamp&#x22; },
}"
/>

### Quotas [#quotas]

| Quota field               | Default | Governs                                   |
| ------------------------- | ------- | ----------------------------------------- |
| `max_pools`               | 10      | Number of pools the tenant may create.    |
| `max_recipients_per_pool` | 100     | Recipients in any one pool.               |
| `max_rules_per_pool`      | 50      | Rules attached to any one pool.           |
| `daily_routes_limit`      | 10000   | Routing decisions per rolling 24h window. |

<Callout type="info">
  The daily route counter resets automatically 24 hours after it was last reset.
  When a tenant hits a quota, the offending operation fails with
  `429`/`ErrQuotaExceeded` rather than silently dropping work — consistent with
  Ductor's fail-visibly principle.
</Callout>

## Provision a tenant [#provision-a-tenant]

`CreateTenant` — `POST /api/tenants` — requires `tenant:write` and the `admin`
role. Omit `quotas` to inherit system defaults.

```bash
curl -s -X POST https://api.ductor.io/api/tenants \
  -H "Authorization: Bearer $DUCTOR_ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "acme-production",
    "quotas": {
      "max_pools": 50,
      "max_recipients_per_pool": 500,
      "max_rules_per_pool": 100,
      "daily_routes_limit": 5000000
    },
    "metadata": { "billing_account": "acct_123", "region": "us-east" },
    "features": ["advanced_routing", "webhooks"]
  }'
```

```json
{
  "data": {
    "id": "9c8b7a6d-1234-4e5f-8a9b-0c1d2e3f4a5b",
    "name": "acme-production",
    "status": "active",
    "quotas": { "max_pools": 50, "max_recipients_per_pool": 500, "max_rules_per_pool": 100, "daily_routes_limit": 5000000 },
    "metadata": { "billing_account": "acct_123", "region": "us-east" },
    "features": ["advanced_routing", "webhooks"],
    "created_at": "2026-07-11T10:30:00Z",
    "updated_at": "2026-07-11T10:30:00Z"
  },
  "metadata": { "trace_id": "trace-...", "request_id": "req-...", "fetched_at": "2026-07-11T10:30:00Z" }
}
```

New tenants default to `status: active`. The returned `id` is what you put in
the `X-Tenant-ID` header for every subsequent call under this tenant.

## Read tenants [#read-tenants]

`GetTenant` — `GET /api/tenants/{tenant_id}` (`tenant:read`):

```bash
curl -s https://api.ductor.io/api/tenants/$DUCTOR_TENANT \
  -H "Authorization: Bearer $DUCTOR_ADMIN_TOKEN"
```

`ListTenants` — `GET /api/tenants` (`tenant:read`, `admin` role) — paginates with
plain `limit`/`offset` (default `limit` 50, max 1000):

```bash
curl -s "https://api.ductor.io/api/tenants?limit=100&offset=0" \
  -H "Authorization: Bearer $DUCTOR_ADMIN_TOKEN"
```

The response carries `data` (the tenants), `count`, and `metadata`.

## Update a tenant [#update-a-tenant]

`UpdateTenant` — `PATCH /api/tenants/{tenant_id}` (`tenant:write`, `admin`). It's
a partial update, but note two replace-not-merge behaviors:

* `quotas` — only the sub-fields you supply are modified.
* `metadata` — **replaces the entire map**. To add one key, send the full map.
* `features` — **replaces the entire list**. To add one feature, send them all.

```bash
# Raise the daily route ceiling and suspend the tenant, leaving other quotas intact.
curl -s -X PATCH https://api.ductor.io/api/tenants/$DUCTOR_TENANT \
  -H "Authorization: Bearer $DUCTOR_ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "status": "suspended", "quotas": { "daily_routes_limit": 10000000 } }'
```

<Callout title="Metadata and features are whole-value writes">
  Because `metadata` and `features` overwrite entirely, always read-modify-write:
  `GET` the tenant, mutate the map/list client-side, then `PATCH` the complete
  value. Sending a partial map silently drops the keys you left out.
</Callout>

## Check a quota before you act [#check-a-quota-before-you-act]

`CheckQuota` — `GET /api/tenants/{tenant_id}/quota/{operation}` (`tenant:read`) —
tells you whether an operation is currently allowed and how much headroom
remains, without performing it. Operations include `pools`, `recipients`, and
`daily_routes`.

```bash
curl -s https://api.ductor.io/api/tenants/$DUCTOR_TENANT/quota/daily_routes \
  -H "Authorization: Bearer $DUCTOR_ADMIN_TOKEN"
```

```json
{ "allowed": true, "current_usage": 41234, "limit": 5000000,
  "metadata": { "trace_id": "trace-...", "request_id": "req-...", "fetched_at": "2026-07-11T10:31:00Z" } }
```

## Retire a tenant [#retire-a-tenant]

`DeleteTenant` — `DELETE /api/tenants/{tenant_id}` (`tenant:delete`, `admin`) — is
a **soft delete**. The tenant's `status` moves to `deleted` and its rows are
retained for audit rather than dropped; the row stops serving as an active
boundary.

```bash
curl -s -X DELETE https://api.ductor.io/api/tenants/$DUCTOR_TENANT \
  -H "Authorization: Bearer $DUCTOR_ADMIN_TOKEN"
```

## Defaults applied on create [#defaults-applied-on-create]

When you omit `quotas` (or the deeper tenant config), Ductor fills in system
defaults at creation time. These are the effective starting ceilings and
capabilities for a bare `CreateTenant`:

| Setting                    | Default                             |
| -------------------------- | ----------------------------------- |
| Status                     | `active`                            |
| Default selection strategy | `smooth_weighted_round_robin`       |
| Max pools                  | 100                                 |
| Max recipients per pool    | 1000                                |
| Max rules per pool         | 100                                 |
| Requests per second        | 1000                                |
| Requests per day           | 1,000,000                           |
| Enabled features           | `basic_routing`, `rules`, `queuing` |

<Callout type="info">
  The proto-level `TenantQuotas` you set on `CreateTenant` (the four fields in
  the table above) and the richer internal tenant config (which also carries the
  default strategy and rate limits) are related but not identical shapes. Set
  what you need explicitly at create time; anything omitted takes the system
  default.
</Callout>

## Per-tenant configuration and routing weights [#per-tenant-configuration-and-routing-weights]

Two adjacent surfaces refine a tenant without going through `TenantService`:

* **Per-tenant config overrides** — `TenantConfigService` at
  `/api/tenants/{tenant_id}/config`, including a *resolved* view that merges
  system defaults + tenant + pool overrides. See
  [Configuration](/docs/management/configuration).
* **Routing weights** — `TenantWeightsService` tunes how a tenant's traffic is
  weighted in selection. See [Strategies](/docs/strategies).

## Where to go next [#where-to-go-next]

<Cards>
  <Card title="Mint an API key" href="/docs/management/api-keys">
    Give this tenant a credential scoped to least privilege.
  </Card>

  <Card title="Build routing topology" href="/docs/management/pools-and-recipients">
    Create the pools and recipients this tenant will route to.
  </Card>

  <Card title="Tenancy, in depth" href="/docs/concepts/tenancy">
    Why tenant isolation is an end-to-end invariant.
  </Card>
</Cards>
