switching clears the token & responses
No token
41

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.
  • provisioning — 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.
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.
  • provisioning — 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-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.
  • null — No further pages — this is the last page of results.
POST/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.

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
REQUIRED — the tester pre-fills a fresh UUID on this card; replace it for each new provisioning request, keep it to retry the same one. 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, or one still in flight, 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.
Idempotency-Key header — required
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.
  • provisioning — 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-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.
  • true — Founding-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 50.

⛓ Needs an out-of-band value?
Request body (JSON)
ℹ︎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-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 (trimmed, 1–16 chars), 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. A code with no identifier, or email AND phone together, is 422. 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 54), 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 52), 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.
acceptTokenbodystringconditionalfrom a response
PROOF OF BEING THE INVITED IDENTITY (§139), 1–4096 chars. REQUIRED when the identity already has a credential (describe said needsPassword: false) UNLESS the request carries that same user's verified gateway context instead — i.e. you signed in and send the call with the bearer. Ignored for a brand-new identity. Where it comes from: POST /v1/auth/login as the invitee answers 200 { invitationPending: true, acceptToken, expiresIn, invitations[] } when their ONLY seats are unaccepted invitations — the tester shows it in the capture line; paste it here. It is purpose-scoped (token_use: accept_invitation): NOT interchangeable with the bootstrapToken of scenario 15, and useless anywhere an access token is expected. Invalid, expired or minted for another purpose → 401 invalid_credentials; absent for an existing identity with no matching gateway context, or naming a different user → 401 invitation_requires_signin. There is deliberately no field for NAMING the caller — .strict() refuses one, so the only way to be somebody is to present something the server signed.
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 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.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 23 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).
  • provisioning — 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-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).