Skip to content

Staff, branch, and role management — frontend integration

This guide covers the complete frontend integration for managing company branches, employees, roles, permissions, and invitations across both the Superadmin Portal and the Company Portal.

All endpoints use API version v2.


1. Architecture & mental model

flowchart TD
    subgraph Superadmin["Superadmin Portal (Role: SUPERADMIN)"]
        SA_Comp["Create & Manage Company"]
        SA_Branch["Create, Bulk-Create, Edit Branches"]
        SA_Emp["Create, Bulk-Create, Edit Employees"]
        SA_Roles["Query All System & Custom Roles"]
    end

    subgraph Company["Company Portal (Role: COMPANY)"]
        CP_Emp["List & Filter Employees by Role/Branch"]
        CP_Access["Manage Scoped Assignments & Status"]
        CP_Own["Transfer Ownership"]
        CP_Invite["Send, List, Revoke, Resend Invitations"]
    end

    subgraph Onboarding["Employee Acceptance (Public Auth)"]
        Accept["Accept Invitation with Token + OTP"]
    end

    SA_Comp --> Company
    SA_Branch --> CP_Emp
    SA_Emp --> CP_Access
    CP_Invite --> Accept

1.1 Tenant scoping & personas

Portal Authentication Base paths Tenant scoping
Superadmin Portal Bearer <superadmin-token> (Role.SUPERADMIN) /v2/admin/companies/:companyId/...
/v2/admin/roles
Explicit via :companyId path parameter or query parameter
Company Portal Bearer <company-token> (Role.COMPANY) /v2/companies/employees/... Implicit; derived from the authenticated actor's active company membership
Public Onboarding None (public endpoint with signature tokens) /v2/auth/employee-invitations/accept Derived securely from the 64-character invitation token hash

1.2 Core entities & invariants

  1. Company & Headquarters:
  2. Every company has exactly one headquarters branch (isHeadquarters: true), created automatically upon company registration.
  3. The headquarters branch cannot be deactivated or archived.
  4. Employees & Memberships:
  5. A user belongs to a company via a CompanyMembership record (ACTIVE, INVITED, SUSPENDED, REVOKED).
  6. An employee's effective permissions are computed additively from all active role assignments.
  7. Access Scopes:
  8. COMPANY: Broad access across the entire company.
  9. BRANCH: Scoped to a specific branchId.
  10. PROGRAM: Scoped to a specific programId.
  11. Ownership Protection:
  12. The platform strictly enforces that every company must have at least one active OWNER.
  13. You cannot suspend, revoke, or delete the last remaining owner of a company.
  14. The OWNER role cannot be assigned via standard role assignment or invitation endpoints — it can only be transferred using the atomic ownership transfer API.

2. Common headers & envelopes

2.1 Request headers

Authorization: Bearer <access-token>
Content-Type: application/json

2.2 Standard success response envelope

All successful responses return HTTP 200 (or 201 for creation) with:

interface ApiResponse<T> {
  status: 'success';
  code: number;
  message: string;
  data: T;
  error: null;
}

2.3 Standard error response envelope

All errors follow the centralized error shape:

interface ApiErrorResponse {
  status: 'error';
  code: number;
  message: string;
  data: null;
  error: {
    message: string;
    code: string;
    details?: unknown;
  };
}

2.4 Sorting conventions

Every paginated list endpoint accepts the same optional pair:

Parameter Values Notes
sortBy Endpoint-specific field name Omit to keep the endpoint's default order.
sortOrder asc | desc Only applied when sortBy is also provided.
Endpoint Sortable sortBy values Default order when sortBy is omitted
GET /v2/admin/companies/:companyId/branches name, branchCode, createdAt, status Headquarters first, then name ascending
GET /v2/companies/branches name, branchCode, createdAt, status Headquarters first, then name ascending
GET /v2/admin/companies/:companyId/employees createdAt, status, name, email createdAt ascending
GET /v2/companies/employees createdAt, status, name, email createdAt ascending
GET /v2/companies/employees/invitations createdAt, status, expiresAt createdAt descending

sortOrder defaults to asc, except on the invitations list where it defaults to desc so that omitted values stay consistent with that endpoint's newest-first default. Rows are always deterministically tie-broken by id (ascending, except descending on the invitations list), so pages stay stable while paginating. An unsupported sortBy or sortOrder value fails fast with HTTP 400 VALIDATION_ERROR.


3. Copy-ready TypeScript types

Export and use these types in your frontend application (e.g. src/types/staff-management.ts):

// Enums
export type BranchStatus = 'ACTIVE' | 'INACTIVE' | 'ARCHIVED';

export type CompanyMembershipStatus = 'ACTIVE' | 'INVITED' | 'SUSPENDED' | 'REVOKED';

export type EmployeeInvitationStatus = 'PENDING' | 'ACCEPTED' | 'REVOKED' | 'EXPIRED';

export type AccessScopeType = 'COMPANY' | 'BRANCH' | 'PROGRAM';

export type SystemAccessRole =
  | 'OWNER'
  | 'COMPANY_ADMIN'
  | 'BRANCH_MANAGER'
  | 'PROGRAM_MANAGER'
  | 'AUCTION_OPERATOR'
  | 'SUBSCRIPTION_MANAGER'
  | 'VIEWER';

// Granular Company Permissions
export type CompanyPermissionCode =
  | 'company.profile.read'
  | 'company.profile.update'
  | 'company.banking.read'
  | 'company.banking.manage'
  | 'branch.create'
  | 'branch.read'
  | 'branch.profile.manage'
  | 'branch.address.manage'
  | 'branch.banking.manage'
  | 'branch.lifecycle.manage'
  | 'employee.read'
  | 'employee.invite'
  | 'employee.suspend'
  | 'employee.assignment.manage'
  | 'role.manage'
  | 'ownership.transfer'
  | 'program.create'
  | 'program.read'
  | 'program.settings.manage'
  | 'program.lifecycle.manage'
  | 'program.enrollment.manage'
  | 'auction.read'
  | 'auction.start'
  | 'auction.bid'
  | 'auction.finalize'
  | 'auction.dispute'
  | 'cycle.read'
  | 'cycle.invoice.create'
  | 'cycle.invoice.read'
  | 'cycle.transaction.manage'
  | 'subscriber.request.read'
  | 'subscriber.request.decide';

// Address model
export interface BranchAddress {
  id?: string;
  branchId?: string;
  line1: string;
  line2?: string | null;
  pincode?: string | null;
  postOffice?: string | null;
  city?: string | null;
  district?: string | null;
  state?: string | null;
  cityId?: number | null;
  postOfficeId?: number | null;
}

// Branch model
export interface BranchItem {
  id: string;
  companyId: string;
  name: string;
  branchCode: string | null;
  status: BranchStatus;
  isHeadquarters: boolean;
  email: string | null;
  mobileNumber: string | null;
  countryCode: string | null;
  address: BranchAddress | null;
  isProgramReady: boolean;
  createdAt: string;
  updatedAt: string;
  deletedAt: string | null;
}

// Role & Permission models
export interface AccessPermission {
  id: string;
  code: string;
  description: string | null;
  scopeType: AccessScopeType;
  ownershipOnly: boolean;
  createdAt: string;
}

export interface AccessRole {
  id: string;
  companyId: string | null;
  name: string;
  description: string | null;
  scopeType: AccessScopeType;
  systemRole: SystemAccessRole | null;
  isSystem: boolean;
  createdAt: string;
  updatedAt: string;
  permissions: AccessPermission[];
  assignmentCount: number;
}

export interface RoleAssignment {
  id: string;
  membershipId: string;
  roleId: string;
  scopeType: AccessScopeType;
  branchId: string | null;
  programId: string | null;
  createdById: string;
  createdAt: string;
  role: AccessRole;
}

export interface RoleAssignmentInput {
  roleId: string;
  scopeType: AccessScopeType;
  branchId?: string;
  programId?: string;
}

// Employee models
export interface EmployeeUser {
  name: string | null;
  email: string | null;
  mobileNumber: string | null;
  countryCode: string | null;
}

export interface EffectivePermissions {
  company: string[];
  branches: Record<string, string[]>;
  programs: Record<string, string[]>;
}

export interface EmployeeItem {
  id: string; // membershipId
  userId: string;
  companyId: string;
  status: CompanyMembershipStatus;
  invitedById: string | null;
  acceptedAt: string | null;
  authorizationVersion: number;
  createdAt: string;
  updatedAt: string;
  user: EmployeeUser;
  assignments: RoleAssignment[];
  effectivePermissions?: EffectivePermissions;
}

// Employee Invitation model
export interface EmployeeInvitationItem {
  id: string;
  companyId: string;
  email: string;
  mobileNumber: string;
  countryCode: string;
  status: EmployeeInvitationStatus;
  expiresAt: string;
  invitedById: string;
  proposedAssignments: RoleAssignmentInput[];
  acceptedAt: string | null;
  revokedAt: string | null;
  sendCount: number;
  lastSentAt: string | null;
  createdAt: string;
  updatedAt: string;
}

// Pagination generic
export interface PaginatedResult<T> {
  count: number;
  pageNumber: number;
  pageSize: number;
  totalPages: number;
  hasPreviousPage: boolean;
  hasNextPage: boolean;
  [key: string]: T[] | number | boolean | undefined;
}

// List sorting (see §2.4 for per-endpoint fields and default order)
export type SortOrder = 'asc' | 'desc';

export type BranchSortBy = 'name' | 'branchCode' | 'createdAt' | 'status';
export type EmployeeSortBy = 'createdAt' | 'status' | 'name' | 'email';
export type InvitationSortBy = 'createdAt' | 'status' | 'expiresAt';

export interface ListQuerySort<TSortBy extends string> {
  sortBy?: TSortBy;
  sortOrder?: SortOrder;
}

4. Superadmin Portal APIs

All requests in this section require:

Authorization: Bearer <superadmin-token>

4.1 Company creation

POST /v2/admin/companies creates the company aggregate, automatically provisions its initial headquarters branch (isHeadquarters: true), configures banking and addresses, and provisions the initial manager user.

  • Status Code: 201 Created
  • Request Body:
{
  "name": "Acme Chits Private Limited",
  "mobileNumber": "9876543210",
  "countryCode": "IN",
  "email": "contact@acmechits.com",
  "cin": "U65992KL2020PTC061234",
  "pan": "ABCDE1234F",
  "registrationNumber": "REG-KL-2020-001",
  "sroId": 12,
  "branch": "Kochi Main Branch",
  "hasGstin": true,
  "gstin": "32ABCDE1234F1Z5",
  "billingAddress": {
    "line1": "4th Floor, FinTech Tower",
    "line2": "MG Road",
    "pincode": "682016",
    "postOffice": "Ernakulam",
    "city": "Kochi",
    "district": "Ernakulam",
    "state": "Kerala",
    "cityId": 101,
    "postOfficeId": 204
  },
  "registeredSameAsBillingAddress": true,
  "yearOfEstablishment": 2020,
  "noOfOngoingPrograms": 5,
  "noOfCompletedPrograms": 12,
  "manager": {
    "name": "Jane Doe",
    "mobileNumber": "9876543211",
    "email": "jane.doe@acmechits.com",
    "countryCode": "IN",
    "pan": "ABCDE5678G",
    "aadhaarNumber": "123456789012"
  },
  "requiresSubAccount": false
}
  • Response data:
{
  "companyId": "cuid_comp_123",
  "mobileNumber": "9876543210",
  "profileStatus": "INCOMPLETE",
  "nextStep": "UPLOAD_MOU",
  "requiresSubAccount": false
}
  • Normalization: email and manager.email are trimmed and lowercased server-side; duplicate emails match case-insensitively (409 with path: 'email').
  • Conflict paths: existing mobile → path: 'mobileNumber'; existing email → path: 'email'; branch-code conflict → path: 'branch' (matches the branch request field).

4.2 Branch management (Superadmin)

4.2.1 List branches

GET /v2/admin/companies/:companyId/branches

  • Query Parameters:
  • pageNumber (optional, default 1): page index.
  • pageSize (optional, default 10, max 100): items per page.
  • all (optional): true to fetch the complete catalog for reference pickers (role/branch selects, exact-match roleName filters). Applies all filters/sorting, skips pagination, returns pageNumber: 1, pageSize: count, totalPages: 1. Omit for paginated table views.
  • status (optional): ACTIVE | INACTIVE | ARCHIVED.
  • search (optional): case-insensitive search by branch name or branch code.
  • sortBy (optional): name | branchCode | createdAt | status. When omitted, the default order applies: headquarters first, then name ascending.
  • sortOrder (optional, default asc): asc | desc. Applied only when sortBy is provided.
  • Example Response:
{
  "status": "success",
  "code": 200,
  "message": "Branches fetched successfully.",
  "data": {
    "count": 2,
    "pageNumber": 1,
    "pageSize": 10,
    "totalPages": 1,
    "hasPreviousPage": false,
    "hasNextPage": false,
    "branches": [
      {
        "id": "br_001",
        "companyId": "cuid_comp_123",
        "name": "Kochi Head Office",
        "branchCode": "KL-KOC-01",
        "status": "ACTIVE",
        "isHeadquarters": true,
        "email": "kochi@acmechits.com",
        "mobileNumber": "9876543210",
        "countryCode": "IN",
        "address": {
          "id": "addr_001",
          "branchId": "br_001",
          "line1": "FinTech Tower, MG Road",
          "city": "Kochi",
          "district": "Ernakulam",
          "state": "Kerala",
          "pincode": "682016"
        },
        "isProgramReady": true,
        "createdAt": "2026-03-01T10:00:00.000Z",
        "updatedAt": "2026-03-01T10:00:00.000Z",
        "deletedAt": null
      }
    ]
  },
  "error": null
}

4.2.2 Get branch details

GET /v2/admin/companies/:companyId/branches/:branchId

  • Response data: Single BranchItem object.

4.2.3 Create branch

POST /v2/admin/companies/:companyId/branches

  • Status Code: 201 Created
  • Request Body:
{
  "name": "Calicut Branch",
  "branchCode": "KL-CLT-01",
  "email": "calicut@acmechits.com",
  "mobileNumber": "9876543222",
  "countryCode": "IN",
  "status": "ACTIVE",
  "address": {
    "line1": "2nd Floor, City Center",
    "city": "Kozhikode",
    "district": "Kozhikode",
    "state": "Kerala",
    "pincode": "673001"
  }
}

4.2.4 Bulk create branches

POST /v2/admin/companies/:companyId/branches/bulk

Enables creating up to 100 branches in a single atomic transaction.

  • Status Code: 201 Created
  • Request Body:
{
  "branches": [
    {
      "name": "Thrissur Branch",
      "branchCode": "KL-TCR-01",
      "email": "thrissur@acmechits.com",
      "mobileNumber": "9876543233",
      "status": "ACTIVE"
    },
    {
      "name": "Palakkad Branch",
      "branchCode": "KL-PLK-01",
      "email": "palakkad@acmechits.com",
      "mobileNumber": "9876543244",
      "status": "ACTIVE"
    }
  ]
}
  • Response data:
{
  "count": 2,
  "branches": [
    { "id": "br_002", "name": "Thrissur Branch", "branchCode": "KL-TCR-01", ... },
    { "id": "br_003", "name": "Palakkad Branch", "branchCode": "KL-PLK-01", ... }
  ]
}

4.2.5 Update branch

PATCH /v2/admin/companies/:companyId/branches/:branchId

  • At least one field is required.
  • Request Body:
{
  "name": "Kozhikode Central Branch",
  "branchCode": "KL-CLT-02",
  "email": "support.calicut@acmechits.com",
  "status": "INACTIVE"
}

4.3 Employee management (Superadmin)

4.3.1 List employees with role filters

GET /v2/admin/companies/:companyId/employees

Enables superadmins to inspect all company personnel and filter directly by roles, system roles, scope types, branches, statuses, and free-text search over the person's profile.

  • Query Parameters:
  • pageNumber (optional, default 1): page number.
  • pageSize (optional, default 10, max 100): items per page.
  • status (optional): ACTIVE | INVITED | SUSPENDED | REVOKED.
  • roleId (optional): filter by a specific role ID. If roleIds is also sent, both are merged (deduplicated IN filter).
  • roleIds (optional): comma-separated role IDs (e.g. role_1,role_2) or repeated params (e.g. ?roleIds=a&roleIds=b). Both forms are accepted and merged with roleId when present.
  • roleName (optional): exact case-insensitive match by role name (e.g. Branch Manager). Not a substring search — use systemRole for enum filtering or fetch the role catalog for partial matching.
  • systemRole (optional): OWNER | COMPANY_ADMIN | BRANCH_MANAGER | PROGRAM_MANAGER | AUCTION_OPERATOR | SUBSCRIPTION_MANAGER | VIEWER.
  • scopeType (optional): COMPANY | BRANCH | PROGRAM.
  • branchId (optional): filter employees assigned to a specific branch.
  • search (optional, max 150 chars): case-insensitive partial match on the linked user's name, email, or mobile number. The value is trimmed; an empty value is treated as no filter. Combinable with every other parameter, including role filters and sorting.

Company-portal equivalent (GET /v2/companies/employees) supports the same parameter. - sortBy (optional, default createdAt): createdAt | status | name | email. name and email sort by the linked user profile, not the membership record. - sortOrder (optional, default asc): asc | desc. Applied only when sortBy is provided.

  • Example Response:
{
  "status": "success",
  "code": 200,
  "message": "Employees fetched successfully.",
  "data": {
    "count": 1,
    "pageNumber": 1,
    "pageSize": 10,
    "totalPages": 1,
    "hasPreviousPage": false,
    "hasNextPage": false,
    "memberships": [
      {
        "id": "mem_001",
        "userId": "usr_001",
        "companyId": "cuid_comp_123",
        "status": "ACTIVE",
        "invitedById": null,
        "acceptedAt": "2026-03-01T10:00:00.000Z",
        "authorizationVersion": 1,
        "createdAt": "2026-03-01T10:00:00.000Z",
        "updatedAt": "2026-03-01T10:00:00.000Z",
        "user": {
          "name": "Jane Doe",
          "email": "jane.doe@acmechits.com",
          "mobileNumber": "9876543211",
          "countryCode": "IN"
        },
        "assignments": [
          {
            "id": "asgn_001",
            "membershipId": "mem_001",
            "roleId": "role_owner_company",
            "scopeType": "COMPANY",
            "branchId": null,
            "programId": null,
            "createdById": "usr_system",
            "createdAt": "2026-03-01T10:00:00.000Z",
            "role": {
              "id": "role_owner_company",
              "companyId": null,
              "name": "Owner",
              "description": "Full company access including ownership transfer",
              "scopeType": "COMPANY",
              "systemRole": "OWNER",
              "isSystem": true,
              "assignmentCount": 1,
              "permissions": []
            }
          }
        ],
        "effectivePermissions": {
          "company": [
            "company.profile.read",
            "company.profile.update",
            "branch.create",
            "branch.read",
            "employee.read",
            "employee.invite",
            "ownership.transfer"
          ],
          "branches": {},
          "programs": {}
        }
      }
    ]
  },
  "error": null
}

4.3.2 Get employee details

GET /v2/admin/companies/:companyId/employees/:membershipId

  • Response data: Single EmployeeItem object.

4.3.3 Create employee

POST /v2/admin/companies/:companyId/employees

Provisions a new staff user or connects an existing user, creates an ACTIVE company membership, and attaches role assignments.

  • Status Code: 201 Created
  • Request Body:
{
  "name": "Alex Mercer",
  "email": "alex.mercer@acmechits.com",
  "mobileNumber": "9876543255",
  "countryCode": "IN",
  "assignments": [
    {
      "roleId": "role_branch_manager_branch",
      "scopeType": "BRANCH",
      "branchId": "br_001"
    }
  ]
}

4.3.4 Bulk create employees

POST /v2/admin/companies/:companyId/employees/bulk

Creates up to 100 employees in a single transaction.

  • Status Code: 201 Created
  • Request Body:
{
  "employees": [
    {
      "name": "Sarah Connor",
      "email": "sarah.connor@acmechits.com",
      "mobileNumber": "9876543266",
      "countryCode": "IN",
      "assignments": [
        {
          "roleId": "role_auction_operator_branch",
          "scopeType": "BRANCH",
          "branchId": "br_001"
        }
      ]
    },
    {
      "name": "Kyle Reese",
      "email": "kyle.reese@acmechits.com",
      "mobileNumber": "9876543277",
      "countryCode": "IN",
      "assignments": [
        {
          "roleId": "role_subscription_manager_branch",
          "scopeType": "BRANCH",
          "branchId": "br_001"
        }
      ]
    }
  ]
}
  • Response data:
{
  "count": 2,
  "employees": [
    { "id": "mem_002", "userId": "usr_002", "status": "ACTIVE", ... },
    { "id": "mem_003", "userId": "usr_003", "status": "ACTIVE", ... }
  ]
}

4.3.5 Edit employee

PATCH /v2/admin/companies/:companyId/employees/:membershipId

Updates employee profile fields, membership status (ACTIVE, SUSPENDED, REVOKED), or completely replaces role assignments.

  • Protection Rule: If the employee is the sole active OWNER of the company, changing their status away from ACTIVE or stripping their OWNER role assignment will fail with 409 Conflict.
  • Request Body:
{
  "name": "Alex Mercer Jr.",
  "email": "alex.new@acmechits.com",
  "status": "ACTIVE",
  "assignments": [
    {
      "roleId": "role_branch_manager_branch",
      "scopeType": "BRANCH",
      "branchId": "br_001"
    },
    {
      "roleId": "role_auction_operator_branch",
      "scopeType": "BRANCH",
      "branchId": "br_001"
    }
  ]
}

4.3.6 List employee invitations

GET /v2/admin/companies/:companyId/invitations

  • Query Parameters:
  • pageNumber (optional, default 1): page number.
  • pageSize (optional, default 10, max 100): items per page.
  • status (optional): PENDING | ACCEPTED | REVOKED | EXPIRED.
  • search (optional, max 150 chars): case-insensitive partial match on the invitee's email or mobile number. Trimmed; an empty value is treated as no filter.
  • sortBy (optional, default createdAt): createdAt | status | expiresAt.
  • sortOrder (optional, default desc): asc | desc. Applied only when sortBy is provided.

The response shape matches the company-portal invitation list (count, pageNumber, pageSize, invitations[]); invitation tokens are never serialized.


4.4 Roles management (Superadmin)

The superadmin portal can query every role on the platform and manage company custom roles on behalf of a company. System roles are immutable. Roles are always company-scoped: a custom role belongs to exactly one company (companyId), and there is no platform-level custom role.

4.4.1 List roles

GET /v2/admin/roles

Search, filter, and sort all roles across the platform (system default roles + company-specific custom roles).

  • Query Parameters:
  • pageNumber (optional, default 1): page number.
  • pageSize (optional, default 10, max 100): items per page.
  • companyId (optional, non-empty): system for system-only roles, <companyId> for that company plus system roles, omitted for all roles. An empty value (?companyId=) returns 400.
  • all (optional): true to fetch the complete role catalog for pickers and exact-match roleName filters (no silent truncation at pageSize cap). Filters/sorting still apply; response keeps the paginated envelope with pageNumber: 1, pageSize: count, totalPages: 1. Omit for paginated management-table views.
  • scopeType (optional): COMPANY | BRANCH | PROGRAM.
  • isSystem (optional): true | false boolean filter.
  • systemRole (optional): filter by enum (OWNER, COMPANY_ADMIN, BRANCH_MANAGER, etc.).
  • search (optional): search by role name.
  • sortBy (optional, default createdAt): name | createdAt | scopeType | isSystem.
  • sortOrder (optional, default asc): asc | desc.

  • Example Response:

{
  "status": "success",
  "code": 200,
  "message": "Roles fetched successfully.",
  "data": {
    "count": 12,
    "pageNumber": 1,
    "pageSize": 10,
    "totalPages": 2,
    "hasPreviousPage": false,
    "hasNextPage": true,
    "roles": [
      {
        "id": "role_owner_company",
        "companyId": null,
        "name": "Owner",
        "description": "Full company access including ownership transfer",
        "scopeType": "COMPANY",
        "systemRole": "OWNER",
        "isSystem": true,
        "createdAt": "2026-01-01T00:00:00.000Z",
        "updatedAt": "2026-01-01T00:00:00.000Z",
        "assignmentCount": 15,
        "permissions": [
          {
            "id": "perm_01",
            "code": "ownership.transfer",
            "description": "Transfer company ownership",
            "scopeType": "COMPANY",
            "ownershipOnly": true,
            "createdAt": "2026-01-01T00:00:00.000Z"
          }
        ]
      }
    ]
  },
  "error": null
}

4.4.2 Create a custom role

POST /v2/admin/roles

Creates a custom role (isSystem: false) for the target company. The role is immediately assignable to that company's members.

  • Body:
  • companyId (required): the company the role belongs to; must exist.
  • name (required, 1–100 chars, trimmed).
  • description (optional, max 255 chars).
  • scopeType (required): COMPANY | BRANCH | PROGRAM.
  • permissionIds (required, min 1): permission catalog IDs. Must exist, must not be ownershipOnly, and must match the role scope (COMPANY role → any permission; BRANCH role → BRANCH/PROGRAM permissions; PROGRAM role → PROGRAM permissions only).
{
  "companyId": "company_123",
  "name": "Branch Viewer",
  "description": "Read-only access to a branch",
  "scopeType": "BRANCH",
  "permissionIds": ["perm_branch_read", "perm_auction_read"]
}
  • Response: 201 with the created role (same shape as the list item above).
  • Audit: role.created_by_admin recorded against the target company.

4.4.3 Update a custom role

PATCH /v2/admin/roles/:roleId

  • Body (at least one field required):
  • name (1–100 chars, trimmed).
  • description (string or null).
  • permissionIds (min 1): replaces the full permission set; validated like create.

Custom roles only — system roles respond 404. When permissions change, the target company's authorization cache is invalidated, so members gain or lose access immediately.

  • Response: 200 with the updated role.
  • Audit: role.updated_by_admin with before/after payloads.

4.4.4 Delete a custom role

DELETE /v2/admin/roles/:roleId

Custom roles only — system roles respond 404. A role with active assignments cannot be deleted: remove the assignments first (409).

  • Response: 200 with { "roleId": "..." }.
  • Audit: role.deleted_by_admin with the deleted role snapshot.

4.4.5 List the permission catalog

GET /v2/admin/roles/permissions

Returns the immutable permission catalog used to build custom roles (create/update permissionIds). No pagination or filters — the catalog is intentionally small.

  • Example Response:
{
  "status": "success",
  "code": 200,
  "message": "Permissions fetched.",
  "data": [
    {
      "id": "perm_01",
      "code": "ownership.transfer",
      "description": "Transfer company ownership",
      "scopeType": "COMPANY",
      "ownershipOnly": true,
      "createdAt": "2026-01-01T00:00:00.000Z"
    }
  ],
  "error": null
}
  • Notes:
  • Fetch this catalog before role create/update calls to map permission codes to permissionIds.
  • ownershipOnly: true permissions (e.g. ownership.transfer) are system-role only and cannot be attached to custom roles.
  • Scope rules still apply when assigning permissions to a role: COMPANY role → any permission; BRANCH role → BRANCH/PROGRAM permissions; PROGRAM role → PROGRAM permissions only.
  • Ordered by scopeType then code, deterministic across calls.

5. Company Portal APIs

All requests in this section require:

Authorization: Bearer <company-token>

The company identity is automatically resolved from the actor's session.

5.1 Staff list & role filtering

GET /v2/companies/employees

  • Required Permission: employee.read
  • Query Parameters:
  • pageNumber (optional, default 1)
  • pageSize (optional, default 10, max 100)
  • status (optional): ACTIVE | INVITED | SUSPENDED | REVOKED
  • roleId (optional): exact role ID (merged with roleIds when both are sent)
  • roleIds (optional): comma-separated role IDs (role_1,role_2) or repeated params (?roleIds=a&roleIds=b)
  • roleName (optional): exact case-insensitive role-name match (not substring)
  • systemRole (optional): OWNER | COMPANY_ADMIN | BRANCH_MANAGER | etc.
  • scopeType (optional): COMPANY | BRANCH | PROGRAM
  • branchId (optional): exact branch ID
  • search (optional, max 150 chars): case-insensitive partial match on the linked user's name, email, or mobile number. The value is trimmed; an empty value is treated as no filter. Combinable with every other parameter, including role filters and sorting.
  • sortBy (optional, default createdAt): createdAt | status | name | email. name and email sort by the linked user profile.
  • sortOrder (optional, default asc): asc | desc. Applied only when sortBy is provided.
  • Response: Returns memberships list with populated effectivePermissions for each employee.

5.2 Access & membership management

5.2.1 Suspend employee membership

POST /v2/companies/employees/:membershipId/suspend

  • Required Permission: employee.suspend
  • Rule: Fails if attempting to suspend the last active OWNER.
  • Response data: Updated MembershipSchema.

5.2.2 Reactivate employee membership

POST /v2/companies/employees/:membershipId/reactivate

  • Required Permission: employee.suspend
  • Response data: Updated MembershipSchema with status: "ACTIVE".

5.2.3 Revoke employee membership

POST /v2/companies/employees/:membershipId/revoke

  • Required Permission: employee.suspend
  • Rule: Fails if attempting to revoke the last active OWNER.
  • Response data: Updated MembershipSchema with status: "REVOKED".

5.2.4 Assign scoped role

POST /v2/companies/employees/:membershipId/assignments

  • Required Permission: employee.assignment.manage
  • Request Body:
{
  "roleId": "role_auction_operator_branch",
  "scopeType": "BRANCH",
  "branchId": "br_001"
}
  • Validation Rules:
  • If scopeType === 'BRANCH', branchId is required and must exist in the company.
  • If scopeType === 'PROGRAM', programId is required and must exist in the company.
  • You cannot assign the system OWNER role (role_owner_company) through this endpoint.
  • Response data: Created assignment record (CreatedRoleAssignmentSchema).

5.2.5 Remove scoped role assignment

DELETE /v2/companies/employees/:membershipId/assignments/:assignmentId

  • Required Permission: employee.assignment.manage
  • Rule: Cannot remove an assignment that would leave the company without an active owner.
  • Response data: { "assignmentId": "asgn_123" }.

5.2.6 Transfer company ownership

POST /v2/companies/employees/:membershipId/transfer-ownership

  • Required Permission: ownership.transfer (Actor must currently be an active OWNER).
  • Target Invariant: Target membership must be ACTIVE and belong to the same company.
  • Atomic Execution:
  • Grants role_owner_company to the target membership.
  • Demotes the calling owner to role_company_admin_company.
  • Increments authorization versions on both memberships to immediately invalidate cached token claims.
  • Response data:
{
  "status": "success",
  "code": 200,
  "message": "Company ownership transferred.",
  "data": {
    "ownerMembershipId": "mem_target_123"
  },
  "error": null
}

5.3 Employee invitations lifecycle

sequenceDiagram
    autonumber
    actor Admin as Company Admin
    participant API as Dichit Backend
    participant SMS as SMS / Email Provider
    actor Employee as New Employee

    Admin->>API: POST /v2/companies/employees/invitations
    API->>API: Generate 64-char token & 6-digit OTP
    API-->>SMS: Deliver magic link + OTP
    API-->>Admin: 201 Created (invitationId, expiresAt)
    SMS-->>Employee: "You have been invited! Token: <token>, OTP: <otp>"
    Employee->>API: POST /v2/auth/employee-invitations/accept (token, otp, name)
    API->>API: Verify token & OTP, activate membership, attach roles
    API-->>Employee: 200 OK (userId, membershipId)

5.3.1 Send invitation

POST /v2/companies/employees/invitations

  • Required Permission: employee.invite
  • Request Body:
{
  "name": "Robert Vance",
  "email": "robert.vance@vancerefrigeration.com",
  "mobileNumber": "9876543288",
  "countryCode": "IN",
  "assignments": [
    {
      "roleId": "role_branch_manager_branch",
      "scopeType": "BRANCH",
      "branchId": "br_001"
    }
  ]
}
  • Response data:
{
  "invitationId": "inv_001",
  "expiresAt": "2026-03-08T10:00:00.000Z"
}

5.3.2 List invitations

GET /v2/companies/employees/invitations

  • Required Permission: employee.read
  • Query Parameters:
  • pageNumber (optional, default 1)
  • pageSize (optional, default 10, max 100)
  • status (optional): PENDING | ACCEPTED | REVOKED | EXPIRED
  • search (optional, max 150 chars): case-insensitive partial match on the invitee's email or mobile number. The value is trimmed; an empty value is treated as no filter. Combinable with status and sorting.
  • sortBy (optional, default createdAt): createdAt | status | expiresAt.
  • sortOrder (optional, default desc): asc | desc. Applied only when sortBy is provided.
  • Response data:
{
  "count": 1,
  "invitations": [
    {
      "id": "inv_001",
      "companyId": "cuid_comp_123",
      "email": "robert.vance@vancerefrigeration.com",
      "mobileNumber": "9876543288",
      "countryCode": "IN",
      "status": "PENDING",
      "expiresAt": "2026-03-08T10:00:00.000Z",
      "invitedById": "usr_inviter",
      "proposedAssignments": [
        {
          "roleId": "role_branch_manager_branch",
          "scopeType": "BRANCH",
          "branchId": "br_001"
        }
      ],
      "acceptedAt": null,
      "revokedAt": null,
      "sendCount": 1,
      "lastSentAt": "2026-03-01T10:00:00.000Z",
      "createdAt": "2026-03-01T10:00:00.000Z",
      "updatedAt": "2026-03-01T10:00:00.000Z"
    }
  ]
}

5.3.3 Revoke invitation

POST /v2/companies/employees/invitations/:invitationId/revoke

  • Required Permission: employee.invite
  • Response data: { "invitationId": "inv_001" }.

5.3.4 Resend invitation

POST /v2/companies/employees/invitations/:invitationId/resend

  • Required Permission: employee.invite
  • Rate Limit / Cooldown: 60 seconds between resend requests. Calling within 60 seconds returns HTTP 429 Too Many Requests.
  • Response data:
{
  "invitationId": "inv_001",
  "expiresAt": "2026-03-08T10:30:00.000Z"
}

5.4 Branch listing & sorting

GET /v2/companies/branches

  • Required Permission: branch.read. Results are additionally scoped to the branches the authenticated actor can access.
  • Query Parameters:
  • pageNumber (optional, default 1)
  • pageSize (optional, default 10, max 100)
  • status (optional): ACTIVE | INACTIVE | ARCHIVED. Archived (soft-deleted) branches are returned only when ARCHIVED is requested explicitly.
  • search (optional): case-insensitive match on branch name or branch code.
  • sortBy (optional): name | branchCode | createdAt | status. When omitted, the default order applies: headquarters first, then name ascending.
  • sortOrder (optional, default asc): asc | desc. Applied only when sortBy is provided.
  • Response data: paginated object with the same branches array shape shown in §4.2.1.

6. Public Employee Onboarding

6.1 Accept invitation

POST /v2/auth/employee-invitations/accept

This is an unauthenticated endpoint. The candidate submits the 64-character token received in the invitation email/link, along with the 6-digit OTP received via SMS.

  • Request Body:
{
  "token": "a1b2c3d4e5f60718293a4b5c6d7e8f90123456789abcdef0123456789abcdef0",
  "otp": "459123",
  "name": "Robert Vance"
}
  • Validation Rules:
  • token: Exactly 64 characters (hexadecimal).
  • otp: Exactly 6 digits (/^\d{6}$/).
  • name: String between 1 and 150 characters.
  • Response data:
{
  "status": "success",
  "code": 200,
  "message": "Employee invitation accepted.",
  "data": {
    "userId": "usr_vance_123",
    "membershipId": "mem_vance_123"
  },
  "error": null
}
  • Next Frontend Step: The employee can now log in via standard mobile OTP login (POST /v2/auth/request-otp -> POST /v2/auth/verify-otp) using their mobile number.

7. Frontend Integration Patterns

7.1 Permission-based UI guards

Every employee response includes an effectivePermissions object:

const permissions = employee.effectivePermissions;

// 1. Company-wide permission check
const canCreateBranch = permissions.company.includes('branch.create');

// 2. Branch-specific permission check
function canOperateAuctionsAtBranch(branchId: string): boolean {
  if (permissions.company.includes('auction.start')) return true;
  return permissions.branches[branchId]?.includes('auction.start') ?? false;
}

// 3. Ownership transfer button visibility
const canTransferOwnership = permissions.company.includes('ownership.transfer');

7.2 Safe employee deactivation checklist

Before presenting the "Suspend" or "Revoke" action on an employee in the UI:

  1. Check if the employee has the OWNER role (assignment.role.systemRole === 'OWNER').
  2. If yes, query GET /v2/companies/employees?systemRole=OWNER&status=ACTIVE.
  3. If count <= 1, disable the button and show a tooltip:

    "This employee is the sole active Owner. Transfer company ownership before deactivating or removing them."

7.3 Invitation resend cooldown timer

Handle the 60-second cooldown in the UI state:

const [cooldownSeconds, setCooldownSeconds] = useState(0);

const handleResend = async (invitationId: string) => {
  try {
    await api.post(`/v2/companies/employees/invitations/${invitationId}/resend`);
    setCooldownSeconds(60);
  } catch (err: any) {
    if (err.response?.status === 429) {
      toast.error('Please wait before requesting another invitation resend.');
    }
  }
};

useEffect(() => {
  if (cooldownSeconds > 0) {
    const timer = setTimeout(() => setCooldownSeconds(s => s - 1), 1000);
    return () => clearTimeout(timer);
  }
}, [cooldownSeconds]);

7.4 One query builder for every list endpoint

Every list endpoint takes the same pagination and sort pair plus its own filters, so describe each endpoint's contract once and build the query string in a single place. That way a screen can never send a sortOrder without a sortBy (the API ignores it) or a sortBy the endpoint does not support (HTTP 400).

import type {
  BranchSortBy,
  EmployeeSortBy,
  InvitationSortBy,
  SortOrder
} from './staff-management';

interface EndpointSortSpec<TSortBy extends string> {
  /** Fields the API accepts for this endpoint; anything else returns HTTP 400. */
  readonly sortable: readonly TSortBy[];
  /** Sort applied when the user has not picked a column; `null` keeps the endpoint's natural order. */
  readonly defaultSort: {
    readonly sortBy: TSortBy;
    readonly sortOrder: SortOrder;
  } | null;
}

export const BRANCHES_LIST = {
  sortable: ['name', 'branchCode', 'createdAt', 'status'],
  defaultSort: null // headquarters first, then name ascending
} as const satisfies EndpointSortSpec<BranchSortBy>;

export const EMPLOYEES_LIST = {
  sortable: ['createdAt', 'status', 'name', 'email'],
  defaultSort: { sortBy: 'createdAt', sortOrder: 'asc' }
} as const satisfies EndpointSortSpec<EmployeeSortBy>;

export const INVITATIONS_LIST = {
  sortable: ['createdAt', 'status', 'expiresAt'],
  defaultSort: { sortBy: 'createdAt', sortOrder: 'desc' }
} as const satisfies EndpointSortSpec<InvitationSortBy>;

export interface ListQueryState<
  TSortBy extends string,
  TFilters extends Record<string, unknown>
> {
  pageNumber?: number;
  pageSize?: number;
  sort?: { sortBy: TSortBy; sortOrder?: SortOrder };
  filters?: TFilters;
}

export function buildListQuery<
  TSortBy extends string,
  TFilters extends Record<string, unknown>
>(spec: EndpointSortSpec<TSortBy>, state: ListQueryState<TSortBy, TFilters>): string {
  const params = new URLSearchParams();

  if (state.pageNumber !== undefined) params.set('pageNumber', String(state.pageNumber));
  if (state.pageSize !== undefined) params.set('pageSize', String(state.pageSize));

  // Never emit `sortOrder` on its own: the API ignores an order without a field.
  const sort = state.sort ?? spec.defaultSort;
  if (sort !== null) {
    params.set('sortBy', sort.sortBy);
    params.set('sortOrder', sort.sortOrder ?? spec.defaultSort?.sortOrder ?? 'asc');
  }

  for (const [key, value] of Object.entries(state.filters ?? {})) {
    if (value === undefined || value === null || value === '') continue;
    params.set(key, Array.isArray(value) ? value.join(',') : String(value));
  }

  return params.toString();
}

Usage from either portal:

const query = buildListQuery(BRANCHES_LIST, {
  pageNumber: 2,
  pageSize: 10,
  sort: { sortBy: 'branchCode', sortOrder: 'desc' },
  filters: { status: 'ACTIVE', search: term }
});

await api.get(`/v2/companies/branches?${query}`);

await api.get(
  `/v2/admin/companies/${companyId}/employees?${buildListQuery(EMPLOYEES_LIST, {
    pageNumber: 1,
    filters: { roleId, branchId },
    sort: { sortBy: 'name' } // falls back to the endpoint's ascending order
  })}`
);

The specs carry the two defaults the API applies: buildListQuery(INVITATIONS_LIST, { pageNumber: 1 }) emits pageNumber=1&sortBy=createdAt&sortOrder=desc, while buildListQuery(BRANCHES_LIST, { pageNumber: 1 }) emits only pageNumber=1 so the branches endpoint keeps its headquarters-first order. Array.isArray values are joined for comma-separated filters such as roleIds.


8. Error handling & HTTP status matrix

HTTP Code Error Code / Scenario Cause & Frontend Remediation
400 INVALID_INPUT / VALIDATION_ERROR Schema validation failure (invalid mobile, mismatched postal code, missing scope targets, unsupported sortBy/sortOrder value). Display inline field error.
400 SCOPE_TARGET_MISMATCH scopeType: BRANCH was provided without branchId, or scopeType: COMPANY was provided with branchId. Ensure target field is included.
401 UNAUTHORIZED Token missing, expired, or invalid. Redirect user to login.
403 FORBIDDEN / INSUFFICIENT_PERMISSIONS Actor lacks the required CompanyPermission code for this action. Hide or disable UI action buttons.
404 COMPANY_NOT_FOUND Company ID does not exist or user lacks access. Show "Company not found" screen.
404 BRANCH_NOT_FOUND Branch ID does not exist within the scoped company.
404 MEMBERSHIP_NOT_FOUND Employee membership does not exist within the scoped company.
404 INVITATION_NOT_FOUND Invitation ID does not exist or has already been consumed.
409 LAST_OWNER_PROTECTION Attempted to suspend, revoke, or delete the last active company owner. Display alert prompting ownership transfer first.
409 CANNOT_ASSIGN_OWNER_ROLE Attempted to assign role_owner_company directly. Prompt user to use the Transfer Ownership workflow.
404 ROLE_NOT_FOUND Role ID does not exist, or it is a system role (immutable via the admin roles API). Refresh the role list.
409 DUPLICATE_ROLE_NAME A role with this name and scope already exists in the company. Choose a different name.
409 ROLE_HAS_ASSIGNMENTS Attempted to delete a role that is still assigned to members. Remove the assignments first.
409 DUPLICATE_BRANCH_CODE A branch with this code already exists in this company.
409 DUPLICATE_INVITATION An active invitation already exists for this email/mobile. Use the Resend endpoint instead.
409 INVITATION_ALREADY_ACCEPTED The invitation has already been accepted. Refresh the invitation list.
410 INVITATION_EXPIRED The 7-day invitation window has passed. Issue a new invitation.
429 RATE_LIMITED / COOLDOWN_ACTIVE Resend invitation was called within the 60-second cooldown window. Show remaining seconds countdown.