Provision a tenant
Review existing tenants, create a tenant + founding-admin membership in one audited transaction (single-use 7-day invite out-of-band), then inspect it. Requires tenant-lifecycle-operator.
- GET /v1/admin/tenants → review existing tenants (status filter, cursor-paginated)
- POST /v1/admin/tenants → 201 (tenant + founding-admin membership; single-use invitation — 7-day link AND a 6-digit code — out-of-band; meta.invitationSent = true)
- POST /v1/auth/invitations/accept (founding admin, PUBLIC profile) → sets their password with either half and is signed in immediately
- GET /v1/admin/tenants/{tenantId} → inspect the tenant and its member count once the admin has accepted
/v1/admin/tenantsadminList tenantsReview existing tenants before provisioning a new one.
Field guide — what each value means & where it comes from3 fields
No request body. Lists tenants (cursor-paginated) and requires a gateway context whose principal holds the tenant-lifecycle-operator role (Authorization / x-gateway-context are handled by the tester + gateway). All three query params are optional.
limitqueryintegeroptionalyou choosecursorquerystringoptionalfrom a responsestatusqueryenumoptionalyou chooseprovisioning— Returns only tenants still being provisioned (not yet fully active).active— Returns only active, fully operational tenants.frozen— Returns only frozen tenants (suspended access).archived— Returns only archived tenants.
GET https://api.kerja.team/v1/admin/tenants?status=active&limit=20Response guide — what comes back & what each value means9 fields
200 OK with the standard { data, meta } envelope (no special response headers — no Set-Cookie, no Cache-Control). `data` is an ARRAY of tenant summary objects; `meta` carries the cursor-pagination info (count and nextCursor).
data[]arrayalwaysdata[].idUUIDalwaysdata[].namestringalwaysdata[].slugstringalwaysdata[].statusenumalwaysprovisioning— Tenant is being set up; not yet fully active.active— Tenant is live and fully usable.frozen— Tenant access is suspended/locked but data retained.archived— Tenant is decommissioned/retired.
data[].createdAtISO-8601alwaysdata[].updatedAtISO-8601alwaysmeta.countintegeralwaysmeta.nextCursorstringalwaysnull— No further pages — this is the last page of results.
/v1/admin/tenantsadminProvision tenant (admin)Idempotency-Key is REQUIRED (a fresh UUID is pre-filled; a missing or malformed one is 400). slug is an optional lowercase DNS label. Creates the tenant (status active) and its founding-admin membership; a single-use invitation carrying a 7-day link AND a 6-digit code is delivered out-of-band, and meta.invitationSent = true. The control plane stores an unusable placeholder hash and NEVER learns the founding admin's password — they set it themselves on the public profile below. Same-key + same-body retry replays the 201.
Field guide — what each value means & where it comes from4 fields
Provisions a tenant and invites its founding admin in one transaction; requires the tenant-lifecycle-operator role (Authorization / x-gateway-context handled by the tester+gateway) and a fresh Idempotency-Key header per request. The invite token is delivered out-of-band (email) and never returned.
Idempotency-KeyheaderUUIDrequiredclient-generatednamebodystringrequiredyou chooseslugbodystringoptionalyou choosefoundingAdminEmailbodystringrequiredyou choosePOST https://api.kerja.team/v1/admin/tenantsResponse guide — what comes back & what each value means7 fields
201 Created. Body is the standard { data } envelope (plus a meta object); data is the newly provisioned Tenant object. No notable response headers (no Set-Cookie / Cache-Control); the invite token is delivered out-of-band and never returned.
data.idUUIDalwaysdata.namestringalwaysdata.slugstringalwaysdata.statusenumalwaysprovisioning— Tenant record is being set up; not yet generally usable.active— Tenant is live and usable; the value returned by this endpoint on success.frozen— Tenant is suspended; access blocked pending restore.archived— Tenant is soft-deleted/retired.
data.createdAtISO-8601alwaysdata.updatedAtISO-8601alwaysmeta.invitationSentbooleanalwaystrue— Founding-admin invitation was sent; the admin sets their password via the public profile flow (§3.15).
/v1/auth/invitations/acceptpublicFounding admin accepts (public profile)Cross-profile hop: this call runs on the PUBLIC surface, not the control plane. Submit either { token, password } (the 7-day link) or { email, code, password } (the 6-digit code). Since Enhancement 4 acceptance RETURNS AN ACCESS TOKEN (auto-captured), so the founding admin does not have to log in separately afterwards — see scenario 50.
Field guide — what each value means & where it comes from8 fields
Public and unguarded (no Authorization REQUIRED) — it finalizes a tenant invitation by activating the invited membership (and, for a brand-new identity only, setting its password) and ENDS IN A SESSION, so the invitee is not asked to log in with a password they created seconds earlier. The message carries a 7-day link AND a 6-digit code; submit either. Works for an email invite (R4/R5, amr [pwd, email]) and a WhatsApp phone invite (R3, amr [pwd, sms] — which also sets phone_verified_at and login_mode='phone'). §139: for an identity that ALREADY EXISTS the caller must also PROVE it is that identity — the acceptToken from the invitationPending login answer, or that user's own gateway context (send the call with their bearer) — because the session this route ends in reaches switch-tenant, so the link or the 6 digits alone would be possession of that identity in EVERY tenant it belongs to, and any tenant admin can have the message sent to any address they can name. No proof, or proof belonging to a different user → 401 invitation_requires_signin (audited invitation_accept_unproven). A NEW invitee has no account to prove and presents nothing — that is why the route stays public. Walk-throughs: scenario 50 / 51 (new), 51 (existing account); a caller already signed in can accept in place at POST /v1/me/memberships/{tenantId}/accept (scenario 53).
tokenbodystringconditionalout-of-bandemailbodystringconditionalyou choosephonebodystringconditionalyou choosecodebodystringconditionalout-of-bandpasswordbodystringconditionalyou chooseacceptTokenbodystringconditionalfrom a responsedisplayNamebodystringoptionalyou chooseclientIdbodystringoptionalyou choosePOST https://api.kerja.team/v1/auth/invitations/acceptResponse guide — what comes back & what each value means6 fields
200 OK with the { data } envelope and Cache-Control: no-store. The 200 means the invitation (link OR short code — one row, either half) was valid and consumed, and the invited membership is now active. An identity that ALREADY EXISTS reaches this 200 only by also proving it is that identity (§139) — the acceptToken from the invitationPending login outcome, or its own verified gateway context; without it the answer is 401 invitation_requires_signin (scenario 52; signed-in callers can instead use POST /v1/me/memberships/{tenantId}/accept, scenario 53). WHAT ELSE IT WROTE DEPENDS ON WHO THE INVITEE IS. For a NEW identity: the password is set, the channel marked verified, the status set to active (user.upserted enqueued, invitation_accepted audited). For an identity that ALREADY EXISTS, accept writes NOTHING global — not the password, not the status, not the login mode, not the display name — because the invitation is tenant-scoped while the credential is global; a globally disabled identity therefore stays disabled. POST /v1/auth/invitations/describe (scenario 52) is what tells the client which branch it is in, via needsPassword, and sending the wrong shape is a 422 either way. SINCE ENHANCEMENT 4 IT ENDS IN A SESSION, so the invitee is not asked to log in with a password they created seconds earlier. The membership is in the INVITER's tenant and the invitee gets no tenant of their own — a regression test pins this. Accepting also earns device trust on that browser, since the channel was just proven.
data.accessTokenstringalwaysdata.tokenTypestringalwaysdata.expiresInnumberalwaysdata.userobjectalwaysSet-Cookie: refresh_tokencookiealwaysSet-Cookie: device_tokencookieconditional/v1/admin/tenants/{tenantId}adminGet one tenantReturns the tenant plus its active member count (TenantDetail).
Field guide — what each value means & where it comes from1 field
No request body. A bodyless read returning one tenant plus its active member count; requires gateway auth (Authorization / x-gateway-context, handled by the tester) and the caller must hold the tenant-lifecycle-operator role. The only caller-supplied input is the tenantId path param.
tenantIdpathUUIDrequiredfrom a responseGET https://api.kerja.team/v1/admin/tenants/1c2d0000-0000-7000-8000-0000000000acResponse guide — what comes back & what each value means8 fields
200 OK with the standard { data } envelope; data is a single TenantDetail object (no pagination, no special response headers like Set-Cookie or Cache-Control).
dataobjectalwaysdata.idUUIDalwaysdata.namestringalwaysdata.slugstringalwaysdata.statusenumalwaysprovisioning— Tenant is being set up and is not yet fully active.active— Tenant is live and operational.frozen— Tenant is suspended (e.g. after a suspend transition); access is blocked until restored.archived— Tenant has been archived/soft-deleted and is no longer in active use.
data.createdAtISO-8601alwaysdata.updatedAtISO-8601alwaysdata.memberCountintegeralways