---
title: "Create"
method: POST
path: "/tenants/{id}/invites"
tags: ["invites"]
---

# Create

`POST /tenants/{id}/invites`

## Request

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

Invite an email or wallet address into the tenant. Provide exactly one of email or wallet_address. Any member of the tenant may send an invite; callers without tenant authority always invite at the "user" role (passing another role returns 403). A wallet invite admits that address on first SIWE login of an invite-only tenant. Optional group_ids (max 20) also add the invitee to those groups on acceptance; the caller must be allowed to add members to each (403 otherwise; 404 for a missing, retired or other-tenant group). 403 `verified_email_required` when the caller signed up with only a passkey and has not verified an email yet.

## Path parameters

- `id` string, required

## Request body

- InternalDeliveryHttpHandlerCreateInviteRequest
  - `email` string
  - `group_ids` string[]: GroupIDs: groups (0..20 live, in this tenant) the invitee is added to on acceptance. The caller must be allowed to add members to each.
  - `role` string
  - `wallet_address` string

## Response `201`

Created

- InternalDeliveryHttpHandlerInviteResponse
  - `data` EnforcerV3InternalDomainInviteInvite
    - `accepted_account_id` string
    - `accepted_at` string
    - `created_at` string
    - `email` string: Email is the invited inbox. Empty on wallet-only invites (SIWE agents).
    - `expires_at` string
    - `group_ids` string[]: GroupIDs are the groups the invitee is added to on acceptance (0..20).
    - `groups` EnforcerV3InternalDomainInviteGroupRef[]: Groups is GroupIDs with names resolved (live groups only); read-side only.
      - `id` string
      - `name` string
    - `id` string
    - `invited_by` string
    - `role` EnforcerV3InternalDomainRoleRole
      - `admin_managed` boolean
      - `created_at` string
      - `cross_tenant` boolean: Capability grants (additive; all false = baseline-only, like "user").
      - `description` string
      - `id` string
      - `mpp_ops` boolean: MPPOps: read-only MPP deposit-intent operator views (migration 000129).
      - `name` string
      - `slug` string
      - `tenant_manage` boolean
      - `tenant_read` boolean
      - `tenant_write` boolean
      - `updated_at` string
    - `role_id` string
    - `status` string
    - `tenant` 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.
    - `tenant_id` string
    - `updated_at` string
    - `wallet_address` string: WalletAddress is the invited EVM address, stored lowercase. Empty on email-only invites. Exactly one of Email / WalletAddress is set.
  - `success` boolean

## Other responses

- `400`: Bad Request
- `401`: Unauthorized
- `403`: Forbidden
- `404`: group_not_found: a group_ids entry is missing, retired or in another tenant
- `409`: email or wallet already belongs to a member of this tenant
- `422`: service_account: the target email belongs to a service account

---

[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)
