Skip to content
SaaS4Builders
API Reference

Tenants & Teams API

Tenant management, team member CRUD, invitation lifecycle (create, accept, revoke, resend), role management, team stats, and admin tenant endpoints.

This section covers tenant management, team members, invitations, and role management. Endpoints are organized into four groups:

  • Tenant endpoints — View and update the current tenant
  • Team member endpoints — List, change roles, and remove members
  • Invitation endpoints — Full invitation lifecycle (create, accept, revoke, resend)
  • Admin endpoints — Platform administration of tenants and their teams

Tenant Endpoints

GET /api/v1/tenant

Returns the current tenant for the authenticated user. The tenant is resolved from the authenticated user's context — no tenant ID is needed in the URL.

Auth: Bearer token + tenant member + onboarding complete Middleware: auth:sanctumtenant.resolvetenant.memberonboarding.complete

Response (200):

{
  "data": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "name": "Acme Corp",
    "slug": "acme-corp",
    "settings": null,
    "owner": {
      "id": 42,
      "name": "John Doe",
      "email": "john@acme.com"
    },
    "users": [
      { "id": 42, "name": "John Doe", "email": "john@acme.com" },
      { "id": 43, "name": "Jane Smith", "email": "jane@acme.com" }
    ],
    "user_joined_at": "2026-01-15T10:00:00.000000Z",
    "user_role": "owner",
    "user_permissions": ["billing.manage", "team.manage", "roles.manage"],
    "has_active_subscription": true,
    "has_billing_details": true,
    "preferred_currency": "EUR",
    "legal_name": "Acme Corporation GmbH",
    "address": "123 Main St",
    "city": "Berlin",
    "postal_code": "10115",
    "country": "DE",
    "vat_number": "DE123456789",
    "billing_email": "billing@acme.com",
    "onboarding_completed_at": "2026-01-15T12:00:00.000000Z",
    "created_at": "2026-01-15T10:00:00.000000Z",
    "updated_at": "2026-03-20T14:30:00.000000Z"
  }
}

Tenant resource fields:

FieldTypeDescription
idUUIDTenant identifier
namestringDisplay name
slugstringURL-safe identifier
settingsobject|nullCustom settings key-value pairs
ownerobject|nullTenant owner (id, name, email)
usersarrayAll tenant members
user_joined_atdatetime|nullWhen the current user joined this tenant
user_rolestring|nullCurrent user's role: owner, admin, or member
user_permissionsarrayCurrent user's resolved permission names
has_active_subscriptionbooleanWhether the tenant has an active subscription
has_billing_detailsbooleanWhether billing information is complete
preferred_currencystringISO 4217 currency code
legal_namestring|nullLegal entity name
addressstring|nullStreet address
citystring|nullCity
postal_codestring|nullPostal/ZIP code
countrystring|nullISO 3166-1 alpha-2 country code
vat_numberstring|nullVAT/tax number
billing_emailstring|nullBilling contact email
onboarding_completed_atdatetime|nullWhen onboarding was completed

PATCH /api/v1/tenant

Update the current tenant. Like the GET endpoint, the tenant is resolved from the authenticated user's context.

Auth: Bearer token + tenant member + update permission on tenant Middleware: auth:sanctumtenant.resolvetenant.memberonboarding.complete

Request Body:

{
  "name": "Acme Corp International",
  "slug": "acme-intl",
  "legal_name": "Acme Corporation International GmbH",
  "address": "456 New St",
  "city": "Munich",
  "postal_code": "80331",
  "country": "DE",
  "vat_number": "DE987654321",
  "billing_email": "finance@acme.com"
}
FieldTypeRequiredValidation
namestringNoMax 255
slugstringNoUnique, alpha_dash, max 255
settingsobjectNoArbitrary key-value pairs
legal_namestring|nullNoMax 255
addressstring|nullNoMax 500
citystring|nullNoMax 255
postal_codestring|nullNoMax 20
countrystring|nullNo2-char uppercase ISO code (e.g., DE, US)
vat_numberstring|nullNoMax 50
billing_emailstring|nullNoValid email, max 255

All fields are optional — only include the fields you want to change.

Response (200):

{
  "tenant": { ... },
  "message": "tenants.updated"
}

Team Member Endpoints

All team member endpoints use the base path /api/v1/tenant/{tenantId}/team/... and require the standard tenant middleware chain.

GET /api/v1/tenant/{tenantId}/team/members

Returns all members of the tenant, sorted by role hierarchy (owner first) then alphabetically.

Auth: Bearer token + tenant member + onboarding complete

Response (200):

{
  "data": [
    {
      "id": 42,
      "name": "John Doe",
      "email": "john@acme.com",
      "avatar": "https://app.example.com/storage/avatars/42.jpg",
      "role": {
        "id": 1,
        "name": "owner"
      },
      "joined_at": "2026-01-15T10:00:00.000000Z"
    },
    {
      "id": 43,
      "name": "Jane Smith",
      "email": "jane@acme.com",
      "avatar": null,
      "role": {
        "id": 2,
        "name": "admin"
      },
      "joined_at": "2026-02-01T09:00:00.000000Z"
    }
  ]
}

Team member fields:

FieldTypeDescription
idintUser ID
namestringUser's name
emailstringUser's email
avatarstring|nullFull URL to avatar image (null if no avatar set)
roleobjectRole with id (int) and name (string)
joined_atdatetime|nullWhen the user joined the tenant

GET /api/v1/tenant/{tenantId}/team/roles

Returns all assignable roles for the tenant. Includes both built-in roles (admin, member) and any custom tenant-specific roles.

Auth: Bearer token + tenant member + onboarding complete

Response (200):

{
  "data": [
    { "id": 2, "name": "admin" },
    { "id": 3, "name": "member" },
    { "id": 15, "name": "billing-manager" }
  ]
}
The owner role is excluded from this list because it cannot be assigned to other users.

GET /api/v1/tenant/{tenantId}/team/stats

Returns team size statistics including seat quota information from the plan.

Auth: Bearer token + tenant member + onboarding complete

Response (200):

{
  "data": {
    "members": 8,
    "pending_invitations": 2,
    "total": 10,
    "limit": 25,
    "available": 15
  }
}
FieldTypeDescription
membersintCurrent number of team members
pending_invitationsintNumber of pending (valid) invitations
totalintmembers + pending_invitations
limitint|nullSeat limit from plan entitlements (null = unlimited)
availableint|nullRemaining seats (limit - total, null if unlimited)

This endpoint is useful for showing seat usage on billing or team management pages. The limit is resolved from the team-members feature entitlement on the tenant's active plan.


PATCH /api/v1/tenant/{tenantId}/team/members/{member}/role

Change a team member's role.

Auth: Bearer token + tenant member + onboarding complete

Request Body:

{
  "role_id": 3
}
FieldTypeRequiredValidation
role_idintYesMust reference an existing role (not owner)

Response (200):

{
  "message": "team.role_changed"
}

Error Responses:

StatusScenario
403Cannot change your own role
403Cannot change the owner's role
403Insufficient permissions
404Member not found in tenant

DELETE /api/v1/tenant/{tenantId}/team/members/{member}

Remove a member from the tenant.

Auth: Bearer token + tenant member + onboarding complete

Response (200):

{
  "message": "team.member_removed"
}

Error Responses:

StatusScenario
403Cannot remove yourself
403Cannot remove the team owner
403Insufficient permissions
404Member not found in tenant

Invitation Endpoints

Invitations allow tenant owners and admins to invite new users to join the team. The invitation lifecycle:

  1. Create — An invitation is sent to an email address with a unique token
  2. Accept — The invitee uses the token to join the tenant (existing user or new registration)
  3. Revoke — The inviter cancels the invitation before it's accepted
  4. Resend — A new token is generated and sent

Invitation Statuses

StatusDescription
pendingInvitation is valid and waiting to be accepted
acceptedInvitation has been accepted by the invitee
revokedInvitation was cancelled by the inviter
expiredInvitation has passed its expires_at date

These statuses are defined in backend/app/Domain/Team/Enums/InvitationStatus.php.

Invitation Resource

All invitation endpoints return the same resource shape:

{
  "id": "880e8400-e29b-41d4-a716-446655440020",
  "email": "new-member@example.com",
  "role": "member",
  "status": "pending",
  "expires_at": "2026-04-10T10:00:00.000000Z",
  "is_expired": false,
  "is_valid": true,
  "tenant": {
    "id": "550e8400-e29b-41d4-a716-446655440000",
    "name": "Acme Corp"
  },
  "inviter": {
    "id": 42,
    "name": "John Doe"
  },
  "created_at": "2026-03-27T10:00:00.000000Z",
  "updated_at": "2026-03-27T10:00:00.000000Z"
}
FieldTypeDescription
idUUIDInvitation identifier
emailstringInvitee's email (normalized to lowercase)
rolestringRole to be assigned on acceptance
statusenumpending, accepted, revoked, expired
expires_atdatetimeWhen the invitation expires
is_expiredbooleanWhether the expiry date has passed
is_validbooleantrue if status is pending and not expired
tenantobjectTenant name and ID
inviterobjectWho created the invitation

GET /api/v1/invitations/{token}

View invitation details by token. This is a public endpoint — no authentication required.

Auth: None Rate limit: 30/minute

Response (200): InvitationResource

Response (404): If the token is invalid or does not match any invitation.


POST /api/v1/invitations/{token}/accept

Accept an invitation as an existing authenticated user.

Auth: Bearer token (no tenant context required) Rate limit: 30/minute

Request Body: None — the authenticated user's identity is used.

Response (200):

{
  "data": {
    "invitation": { ... },
    "tenant": { ... }
  },
  "message": "team.invitation_accepted"
}

The user is added to the tenant with the role specified in the invitation.

In V1, a user can only belong to one tenant. If the authenticated user already belongs to a tenant, this endpoint returns a 409 Conflict error.

Error Responses:

StatusCodeDescription
403EMAIL_MISMATCHUser's email does not match the invitation email
404INVITATION_NOT_FOUNDToken is invalid
409USER_BELONGS_TO_ANOTHER_TENANTUser already has a tenant (V1 restriction)
409ALREADY_MEMBERUser is already a member of this tenant
410INVITATION_EXPIREDInvitation has expired
410INVITATION_ALREADY_ACCEPTEDInvitation was already accepted
410INVITATION_REVOKEDInvitation was revoked

POST /api/v1/invitations/{token}/accept-with-registration

Accept an invitation and create a new user account in one step. This is a public endpoint.

Auth: None Rate limit: 30/minute

Request Body:

{
  "name": "New Member",
  "email": "new-member@example.com",
  "password": "securepassword",
  "password_confirmation": "securepassword"
}
FieldTypeRequiredValidation
namestringYesMax 255
emailstringYesValid email, max 255, must match invitation email
passwordstringYesMin 8 chars, must match password_confirmation

Response (201):

{
  "data": {
    "user": {
      "id": 44,
      "name": "New Member",
      "email": "new-member@example.com"
    },
    "invitation": { ... },
    "tenant": { ... }
  },
  "meta": {
    "access_token": "1|abc123...",
    "refresh_token": "2|def456...",
    "token_type": "Bearer"
  }
}

In SPA mode (cookie-based auth), the meta tokens are omitted and a session cookie is set instead.

Unlike regular registration, accepting an invitation with registration does not create a personal tenant for the new user. The user is added directly to the inviting tenant.

Error Responses: Same as the accept endpoint, plus:

StatusCodeDescription
409ACCOUNT_ALREADY_EXISTSAn account with this email already exists
422SEAT_LIMIT_REACHEDThe tenant's plan seat quota has been reached

GET /api/v1/tenant/{tenantId}/team/invitations

List all invitations for the tenant.

Auth: Bearer token + tenant member + onboarding complete

Query Parameters:

ParamTypeDefaultDescription
pending_onlybooleanfalseIf true, only return valid pending invitations

Response (200): Array of InvitationResource objects, sorted by created_at descending.


POST /api/v1/tenant/{tenantId}/team/invitations

Create a new invitation.

Auth: Bearer token + tenant member (owner or admin role) + onboarding complete

Request Body:

{
  "email": "new-member@example.com",
  "role": "member",
  "expires_in_days": 7
}
FieldTypeRequiredValidation
emailstringYesValid email, max 255
rolestringYesMust be a valid assignable role for the tenant
expires_in_daysintNo1–30 days (defaults to system setting)

Response (201):

{
  "data": { ... },
  "message": "team.invitation_sent"
}

Error Responses:

StatusCodeDescription
403INSUFFICIENT_PERMISSIONSUser is not an owner or admin
409ALREADY_MEMBEREmail belongs to an existing tenant member
409ALREADY_INVITEDA pending invitation already exists for this email
409USER_BELONGS_TO_ANOTHER_TENANTEmail belongs to a user in another tenant (V1)
422SEAT_LIMIT_REACHEDPlan seat quota would be exceeded
Invitation creation uses a cache lock to prevent race conditions when multiple invitations are created simultaneously. Emails are normalized to lowercase before checking for duplicates.

DELETE /api/v1/tenant/{tenantId}/team/invitations/{invitation}

Revoke a pending invitation.

Auth: Bearer token + tenant member + onboarding complete

Response (200):

{
  "message": "team.invitation_revoked"
}

POST /api/v1/tenant/{tenantId}/team/invitations/{invitation}/resend

Resend an invitation with a new token.

Auth: Bearer token + tenant member + onboarding complete

Response (200):

{
  "data": { ... },
  "message": "team.invitation_resent"
}

A new token is generated, replacing the previous one. The old token becomes invalid.


Role Management Endpoints

Custom roles allow tenants to define fine-grained access beyond the built-in owner, admin, and member roles.

All role management endpoints require the roles.manage permission and use the base path /api/v1/tenant/{tenantId}/roles.

GET /api/v1/tenant/{tenantId}/roles

List all roles available in the tenant, including both built-in and custom roles.

Auth: Bearer token + tenant member + roles.manage permission

Response (200):

{
  "data": [
    {
      "id": 1,
      "name": "owner",
      "guard_name": "web",
      "tenant_id": null,
      "is_builtin": true,
      "permissions": ["billing.manage", "team.manage", "roles.manage"],
      "users_count": 1,
      "created_at": "2026-01-15T10:00:00.000000Z",
      "updated_at": "2026-01-15T10:00:00.000000Z"
    },
    {
      "id": 15,
      "name": "billing-manager",
      "guard_name": "web",
      "tenant_id": "550e8400-e29b-41d4-a716-446655440000",
      "is_builtin": false,
      "permissions": ["billing.manage"],
      "users_count": 2,
      "created_at": "2026-03-20T14:30:00.000000Z",
      "updated_at": "2026-03-20T14:30:00.000000Z"
    }
  ]
}
FieldTypeDescription
idintRole identifier
namestringRole name (e.g., admin, billing-manager)
guard_namestringLaravel auth guard (web)
tenant_idUUID|nullnull for built-in roles, tenant UUID for custom roles
is_builtinbooleanWhether this is a system-defined role
permissionsarraySorted list of permission names
users_countintNumber of users assigned this role

GET /api/v1/tenant/{tenantId}/roles/permissions

Returns all available permissions that can be assigned to roles.

Auth: Bearer token + tenant member + roles.manage permission

Response (200):

{
  "data": ["billing.manage", "roles.manage", "team.manage"]
}

POST /api/v1/tenant/{tenantId}/roles

Create a custom role for the tenant.

Auth: Bearer token + tenant member + roles.manage permission

Response (201): GlobalRoleResource

PATCH /api/v1/tenant/{tenantId}/roles/{role}

Update a custom role.

Auth: Bearer token + tenant member + roles.manage permission

Response (200): GlobalRoleResource

Built-in roles (owner, admin, member) cannot be updated or deleted. Attempting to do so returns a 403 error.

DELETE /api/v1/tenant/{tenantId}/roles/{role}

Delete a custom role. Users currently assigned this role are reassigned to the default member role.

Auth: Bearer token + tenant member + roles.manage permission

Response (200):

{
  "message": "team.role_deleted"
}

Admin Tenant Endpoints

Platform administrators can manage all tenants. Admin endpoints require the platform.admin middleware and are blocked during impersonation sessions.

Base path: /api/v1/admin/...Middleware: auth:sanctumimpersonation.preventplatform.admin

GET /api/v1/admin/tenants

List all tenants with search and pagination.

Auth: Bearer token + platform admin

Query Parameters:

ParamTypeDefaultDescription
searchstringSearch by tenant name
per_pageint25Items per page (1–100)

Response (200):

{
  "data": [
    {
      "id": "550e8400-e29b-41d4-a716-446655440000",
      "name": "Acme Corp",
      "slug": "acme-corp",
      "legal_name": "Acme Corporation GmbH",
      "address": "123 Main St",
      "city": "Berlin",
      "postal_code": "10115",
      "country": "DE",
      "vat_number": "DE123456789",
      "billing_email": "billing@acme.com",
      "owner": {
        "id": 42,
        "name": "John Doe",
        "email": "john@acme.com"
      },
      "member_count": 8,
      "subscription_status": "active",
      "plan_name": "Pro",
      "onboarding_completed_at": "2026-01-15T12:00:00.000000Z",
      "created_at": "2026-01-15T10:00:00.000000Z",
      "roles": [
        { "id": 1, "name": "owner" },
        { "id": 2, "name": "admin" },
        { "id": 3, "name": "member" }
      ]
    }
  ],
  "meta": {
    "current_page": 1,
    "last_page": 3,
    "per_page": 25,
    "total": 72
  }
}

MethodPathDescription
GET/api/v1/admin/tenants/{tenant}Get tenant details (AdminTenantResource with roles)
PATCH/api/v1/admin/tenants/{tenant}Update tenant (same fields as tenant update, except slug)
GET/api/v1/admin/tenants/search-with-rolesSearch tenants with roles (requires users.create permission). Query: search (required). Returns {id, name, roles[]}.

Admin Team Member Endpoints

All require platform.admin middleware. Responses use the same TeamMemberResource shape as tenant-scoped endpoints.

MethodPathDescription
GET.../tenants/{tenant}/membersList all members of a tenant
PATCH.../tenants/{tenant}/members/{user}Update member profile (name, password)
PATCH.../tenants/{tenant}/members/{user}/roleChange member role. Body: {"role_id": 3}
DELETE.../tenants/{tenant}/members/{user}Remove member from tenant

Admin Invitation Endpoints

All require platform.admin middleware. Responses use InvitationResource.

MethodPathDescription
GET.../tenants/{tenant}/invitationsList invitations (supports pending_only query param)
POST.../tenants/{tenant}/invitationsCreate invitation. Body: {email, role, expires_in_days?}
DELETE.../tenants/{tenant}/invitations/{invitation}Revoke invitation
POST.../tenants/{tenant}/invitations/{invitation}/resendResend with new token

What's Next