---
title: "Create"
method: POST
path: "/tenants"
tags: ["tenants"]
---

# Create

`POST /tenants`

## Request

- Base URL: `https://api.instruxi.dev/api/v1/enforcer`
- URL: `https://api.instruxi.dev/api/v1/enforcer/tenants`
- Auth: one of:
  - API key in header `Authorization`
  - API key in header `X-API-Key`

Provisions a new tenant. System-admin only — 403 for any other caller, since there is no existing tenant row yet for a resource-level check to apply to. **`owner_person_id` is required**: it names the person this workspace belongs to for workspace-plan accounting, and a workspace created without one would count against nobody's plan permanently (400 `owner_person_id_required`; 400 `owner_person_not_found` if it names no person). An operator provisioning a workspace FOR a customer names the customer — to own it themselves they pass their own person id. The owner's plan allowance is deliberately NOT enforced on this path: an operator grant may legitimately exceed a plan (a comp, a pilot), and the excess is then visible as `owned` above `workspace_slots` on `getSelfServeTenantAllowance` rather than hidden as an unowned workspace.

## Request body

- EnforcerV3InternalUsecaseTenantsCreateInput
  - `auth_provider` string
  - `description` string
  - `generate_code` boolean
  - `name` string
  - `owner_person_id` string: OwnerPersonID is the PERSON this workspace belongs to for workspace-plan accounting (tenants.owner_person_id, 000104). REQUIRED — a required field on an existing endpoint, added deliberately. WHY IT IS REQUIRED. Until now this path wrote no owner at all, so every workspace an operator created was born belonging to nobody and counted against nobody's plan, permanently and silently. That is a revenue leak that grows with every operator action, and it is invisible: the customer sees "0 of 2 used" while running workspaces. WHY NOT DEFAULT TO THE CREATING OPERATOR. It is the tempting non-breaking answer and it is worse than the leak. An operator provisioning fifty customer workspaces would appear to own fifty; and because a workspace can only be CLAIMED while it is unowned (claiming an owned one is a transfer, which does not exist yet), a wrongly-attributed workspace would be wrong FOREVER, with raw SQL the only remedy — the exact situation this change exists to end. An unowned workspace is at least repairable. So the operator is made to answer the question rather than have it answered wrongly on their behalf: to own it themselves they pass their own person id, which is a statement, not an accident. An operator creating a workspace FOR a customer names the customer, and consumes none of their own slots — see Create for why no cap applies here.
  - `privy_app_id` string
  - `self_join_policy` 'open' | 'invite_only'
  - `wallet_provider` string

## Response `201`

Created

- InternalDeliveryHttpHandlerTenantResponse
  - `data` EnforcerV3InternalDomainTenantTenant
    - `account_abstraction` boolean
    - `allow_user_discovery` boolean: AllowUserDiscovery gates contact discovery for the whole tenant: when true, accounts may be discoverable (and new accounts default to discoverable); when false, discovery is off tenant-wide regardless of account.is_discoverable.
    - `app_url` string
    - `auth_provider` string: AuthProvider / WalletProvider: nil = inherit the instance default.
    - `banner_url` string
    - `billing_status` string: BillingStatus is the tenant's platform-billing dunning state, PROJECTED from enforcer-billing (migration 000087): "" | active | past_due | restricted | suspended | collections. enforcer-billing is the source of truth; this is a copy the billing.status_changed consumer keeps current so the value rides Accessor.billing_status on every resolve with no hop. "" means UNKNOWN and readers MUST treat it as active (fail open). `<-:false` makes GORM never write these two columns: Repository.Update is a whole-row Save, so without it an admin editing the tenant's name from a stale struct would silently roll the projection back. Only BillingStatusStore.ApplyBillingStatus writes them, by targeted UPDATE under the monotonic entered_at guard.
    - `billing_status_entered_at` string
    - `code` string
    - `code_expires_at` string: CodeExpiresAt is when the join code stops working everywhere a code is accepted, signing in and joining alike, as if it had been rotated away. Absent: the code never expires. Set by POST /tenants/{id}/code.
    - `created_at` string
    - `description` string
    - `id` string
    - `logo_url` string
    - `metadata` EnforcerV3InternalDomainTenantJSONBMap
    - `metered_sends_enabled` boolean: MeteredSendsEnabled turns on per-transaction limit metering: every value leg leaving the platform is measured against the caller's live cap before the server co-signs. Meaningful only while Web3Enabled is true — the pair (false, true) is refused on write; see ValidateWeb3Gates.
    - `modules` string[]: Modules is the tenant's MODULE entitlements as group slugs, prefixed `module_` (migration 000094, V3-02). Which HALO-Lego surfaces this tenant has bought — NOT which of them any given account may use, which is the role's question. It lives on the tenant rather than in account_groups because it is a fact about the TENANT: a module per account would mean auto-joining every new account and would make "remove one person from a module the tenant paid for" expressible. The resolver UNIONS this into Accessor.GroupSlugs, so every leaf's RequireModule reads it with no new field and no extra hop. Same `<-:false` guard and the same targeted-write rule as BillingStatus above: Repository.Update is a whole-row Save, so without it an admin editing the tenant's name from a stale struct would silently revoke every module. Only ModuleStore writes these two columns.
    - `modules_updated_at` string
    - `name` string
    - `payment_link_url` string
    - `privy_app_id` string
    - `self_join_policy` 'open' | 'invite_only'
    - `status` 'active' | 'inactive' | 'suspended'
    - `theme_colors` EnforcerV3InternalDomainTenantJSONBMap
    - `thumbnail_url` string
    - `updated_at` string
    - `wallet_provider` string
    - `web3_enabled` boolean: Web3Enabled is the tenant-wide web3 kill switch. False rejects EVERY web3 transaction for the tenant — guarded sends, signing, and RPC writes alike — so it is deliberately a gate rather than an enforcement mode. Defaults true (migration 000066) because false would disable web3 for every existing tenant on deploy.
  - `success` boolean

## Other responses

- `400`: Bad Request
- `401`: Unauthorized
- `403`: Forbidden
- `409`: Conflict

---

[API](https://skmtc.dev/instruxi/apis/enforcer-api.md) · [All operations](https://skmtc.dev/instruxi/apis/enforcer-api/llms.txt) · [OpenAPI document](https://skmtc.dev/instruxi/apis/enforcer-api/revisions/fa82bc1becc0?raw)
