switching clears the token & responses
No token
35

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.

  1. GET /v1/admin/tenants → review existing tenants (status filter, cursor-paginated)
  2. 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)
  3. POST /v1/auth/invitations/accept (founding admin, PUBLIC profile) → sets their password with either half and is signed in immediately
  4. GET /v1/admin/tenants/{tenantId} → inspect the tenant and its member count once the admin has accepted
GET/v1/admin/tenantsadminList tenants

Review existing tenants before provisioning a new one.

Query parameters
ℹ︎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 choose
You choose the page size. Integer between 1 and 100; defaults to 20 when omitted. Controls how many tenant items are returned per page.
cursorquerystringoptionalfrom a response
Opaque pagination token (1-512 chars). Do not type it yourself: copy it from a prior response's meta.nextCursor of this same endpoint to fetch the next page. A hand-crafted/malformed cursor yields 404 NOT_FOUND.
statusqueryenumoptionalyou choose
You pick which lifecycle status to filter the tenant list by. Omit to return tenants of all statuses.
  • provisioningReturns only tenants still being provisioned (not yet fully active).
  • activeReturns only active, fully operational tenants.
  • frozenReturns only frozen tenants (suspended access).
  • archivedReturns only archived tenants.
Bearer token
GET https://api.kerja.team/v1/admin/tenants?status=active&limit=20
↩︎Response 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[]arrayalways
Array of tenant summary objects matching the optional status filter, capped by `limit` (default 20). Empty array if no tenants match.
data[].idUUIDalways
Tenant identifier. Feed into per-tenant admin calls (e.g. GET/PATCH /v1/admin/tenants/{id}, freeze/archive).
data[].namestringalways
Human-readable tenant display name (1–200 chars), as supplied at provisioning.
data[].slugstringalways
Lowercase DNS-label slug for the tenant (e.g. "acme"). Stable URL-safe handle.
data[].statusenumalways
Lifecycle state of the tenant. Reflects the optional ?status filter when one was supplied.
  • provisioningTenant is being set up; not yet fully active.
  • activeTenant is live and fully usable.
  • frozenTenant access is suspended/locked but data retained.
  • archivedTenant is decommissioned/retired.
data[].createdAtISO-8601always
Timestamp when the tenant was created (UTC, millisecond precision).
data[].updatedAtISO-8601always
Timestamp when the tenant record was last modified (UTC, millisecond precision).
meta.countintegeralways
Number of tenant items returned in this page (length of data[]).
meta.nextCursorstringalways
Opaque keyset cursor for the next page, or null when there are no more results. Pass back as ?cursor= to fetch the following page.
  • nullNo further pages — this is the last page of results.
POST/v1/admin/tenantsadminProvision tenant (admin)

Needs Idempotency-Key: <uuid>. 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.

Request body (JSON)
ℹ︎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-generated
Generate a fresh UUID for each new provisioning request. The server uses it for replay-safety: the same key with the same body returns the original result, while the same key with a different body is rejected (409 CONFLICT). Missing or malformed keys fail with 400 VALIDATION_ERROR.
namebodystringrequiredyou choose
You type the tenant's display name. Trimmed, must be 1-200 characters.
slugbodystringoptionalyou choose
Optional URL-safe identifier you choose for the tenant. Must be a 1-63 char lowercase DNS label matching ^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$. Omit to let the system handle it.
foundingAdminEmailbodystringrequiredyou choose
You type the email address of the founding admin to invite. Trimmed, lowercased, must be a valid email of at most 320 chars. An out-of-band invitation token (7-day TTL) is emailed to this address; the admin later sets their password via the public profile flow.
Bearer token
POST https://api.kerja.team/v1/admin/tenants
↩︎Response 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.idUUIDalways
The new tenant's unique identifier. Feed into tenant-addressed admin calls (e.g. GET/PATCH /v1/admin/tenants/{tenantId}).
data.namestringalways
The tenant's display name as submitted (trimmed, 1-200 chars).
data.slugstringalways
The tenant's DNS-label slug (lowercase, 1-63 chars). Auto-derived if not supplied in the request.
data.statusenumalways
The tenant's lifecycle state. A freshly provisioned tenant is returned as active.
  • provisioningTenant record is being set up; not yet generally usable.
  • activeTenant is live and usable; the value returned by this endpoint on success.
  • frozenTenant is suspended; access blocked pending restore.
  • archivedTenant is soft-deleted/retired.
data.createdAtISO-8601always
Timestamp when the tenant was created.
data.updatedAtISO-8601always
Timestamp of the tenant's last update; equals createdAt on a fresh provision.
meta.invitationSentbooleanalways
Whether the founding-admin invitation email (7-day TTL, out-of-band token) was dispatched. Expected true on success.
  • trueFounding-admin invitation was sent; the admin sets their password via the public profile flow (§3.15).
POST/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 44.

⛓ Needs an out-of-band value?
Request body (JSON)
ℹ︎Field guide — what each value means & where it comes from7 fields

Public endpoint (no Authorization needed) that finalizes a tenant invitation by setting the invited user's password and activating their membership — and, since Enhancement 4, 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').

tokenbodystringconditionalout-of-band
The single-use invitation LINK token (7-day TTL). The user does not pick this; they obtain it from the invitation message's accept link and paste it here. Supply this OR an identifier + code, never both — redeeming either half kills the other. 1–512 chars.
emailbodystringconditionalyou choose
The invited email address. Required when redeeming an EMAIL invitation by short code; omit it when submitting the link token or when the invite went out by phone.
phonebodystringconditionalyou choose
The invited E.164 number. Required when redeeming a WHATSAPP invitation (R3) by short code. The phone starts UNVERIFIED on a phone invite — accepting with the code is exactly what proves it, which is why the shell identity is created without a password.
codebodystringconditionalout-of-band
The 6-digit SHORT CODE from the invitation message, valid ~15 min while the link keeps its full 7 days — an invitee who lets the code lapse still has a working link, which is intentional. Attempt-capped: exhausting the cap consumes the row and takes the link with it — at which point there is nothing left to resend either (POST /v1/invitations/{userId}/resend answers 404 on a consumed row), so the admin must remove the invited membership and re-invite. Short of that, an invite that never arrived is re-delivered with fresh secrets by the resend endpoint (scenario 47), not by a second POST /v1/invitations.
passwordbodystringconditionalyou choose
CONDITIONAL, AND BOTH MISMATCHES ARE REFUSED. REQUIRED when the identity has no credential yet (a brand-new invitee freely chooses one here, 12-1024 chars); REJECTED 422 when the identity already has one — ask POST /v1/auth/invitations/describe first (scenario 46), whose needsPassword answers exactly this. Optional in the schema only because the service is the one that can see which case applies. Neither mismatch is silently ignored: a dropped password would leave the client believing it had set one, and a silently-missing one would leave an account with no way in. AN INVITATION IS NOT A PASSWORD RESET — for an existing identity 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.
displayNamebodystringoptionalyou choose
Optional display name the user types for their account. If supplied, it must be 1-255 chars. Ignored for an identity that already exists — accept writes nothing global for those.
clientIdbodystringoptionalyou choose
RESERVED for enhancement-5 and currently REJECTED with 422 while multi-app acceptance is absent — leave it out.
POST https://api.kerja.team/v1/auth/invitations/accept
↩︎Response guide — what comes back & what each value means6 fields

200 OK with the { data } envelope. The 200 means the invitation (link OR short code — one row, either half) was valid and consumed, and the invited membership is now active. 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 46) 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.accessTokenstringalways
The bearer token for the tenant surface, auto-captured by this tester. amr is [pwd, email] for an email invitation (R4/R5) and [pwd, sms] for a WhatsApp phone invitation (R3).
data.tokenTypestringalways
Always "Bearer".
data.expiresInnumberalways
Access-token TTL in seconds (ACCESS_TOKEN_TTL_SECONDS, default 600).
data.userobjectalways
The now-active invitee: id, email or phone, displayName, and the inviter's tenantId. A phone invitee additionally has phone_verified_at set and login_mode = phone, so they subsequently sign in at scenario 10.
Set-Cookie: refresh_tokencookiealways
The rotating refresh token, HttpOnly.
Set-Cookie: device_tokencookieconditional
Device trust earned by accepting — the channel was just proven on this browser, so the next login here can skip the new-device challenge (scenario 22 lists it).
GET/v1/admin/tenants/{tenantId}adminGet one tenant

Returns the tenant plus its active member count (TenantDetail).

Path parameters
ℹ︎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 response
The addressed tenant's own UUID (the tenant-as-resource carve-out, not a tenant-scoping segment). Obtain it from a prior response such as the admin tenant list (each item's id field) or another get; the user does not type it freely. Must be a valid UUID or the request fails with VALIDATION_ERROR (400).
Bearer token
GET https://api.kerja.team/v1/admin/tenants/1c2d0000-0000-7000-8000-0000000000ac
↩︎Response 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).

dataobjectalways
The TenantDetail object for the addressed tenant. The {tenantId} path segment is the tenant's own resource UUID (tenant-as-resource carve-out), not a tenant-scoping segment.
data.idUUIDalways
The tenant's unique identifier. Use this as {tenantId} in follow-up admin calls (e.g. PATCH /v1/admin/tenants/{tenantId}).
data.namestringalways
Human-readable tenant display name (e.g. 'Acme Inc'), shown in the tester UI.
data.slugstringalways
URL-safe short identifier for the tenant (e.g. 'acme').
data.statusenumalways
Current lifecycle state of the tenant. Reflects lifecycle transitions made via PATCH (e.g. suspend → frozen).
  • provisioningTenant is being set up and is not yet fully active.
  • activeTenant is live and operational.
  • frozenTenant is suspended (e.g. after a suspend transition); access is blocked until restored.
  • archivedTenant has been archived/soft-deleted and is no longer in active use.
data.createdAtISO-8601always
Timestamp when the tenant was created.
data.updatedAtISO-8601always
Timestamp when the tenant was last modified.
data.memberCountintegeralways
Number of active members belonging to this tenant (e.g. 42).