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¶
- Company & Headquarters:
- Every company has exactly one headquarters branch (
isHeadquarters: true), created automatically upon company registration. - The headquarters branch cannot be deactivated or archived.
- Employees & Memberships:
- A user belongs to a company via a
CompanyMembershiprecord (ACTIVE,INVITED,SUSPENDED,REVOKED). - An employee's effective permissions are computed additively from all active role assignments.
- Access Scopes:
COMPANY: Broad access across the entire company.BRANCH: Scoped to a specificbranchId.PROGRAM: Scoped to a specificprogramId.- Ownership Protection:
- The platform strictly enforces that every company must have at least one active
OWNER. - You cannot suspend, revoke, or delete the last remaining owner of a company.
- The
OWNERrole 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¶
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:
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:
emailandmanager.emailare trimmed and lowercased server-side; duplicate emails match case-insensitively (409withpath: 'email'). - Conflict paths: existing mobile →
path: 'mobileNumber'; existing email →path: 'email'; branch-code conflict →path: 'branch'(matches thebranchrequest field).
4.2 Branch management (Superadmin)¶
4.2.1 List branches¶
GET /v2/admin/companies/:companyId/branches
- Query Parameters:
pageNumber(optional, default1): page index.pageSize(optional, default10, max100): items per page.all(optional):trueto fetch the complete catalog for reference pickers (role/branch selects, exact-matchroleNamefilters). Applies all filters/sorting, skips pagination, returnspageNumber: 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, defaultasc):asc|desc. Applied only whensortByis 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: SingleBranchItemobject.
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, default1): page number.pageSize(optional, default10, max100): items per page.status(optional):ACTIVE|INVITED|SUSPENDED|REVOKED.roleId(optional): filter by a specific role ID. IfroleIdsis also sent, both are merged (deduplicatedINfilter).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 withroleIdwhen present.roleName(optional): exact case-insensitive match by role name (e.g.Branch Manager). Not a substring search — usesystemRolefor 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, max150chars): 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: SingleEmployeeItemobject.
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
OWNERof the company, changing their status away fromACTIVEor stripping theirOWNERrole assignment will fail with409 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, default1): page number.pageSize(optional, default10, max100): items per page.status(optional):PENDING|ACCEPTED|REVOKED|EXPIRED.search(optional, max150chars): case-insensitive partial match on the invitee's email or mobile number. Trimmed; an empty value is treated as no filter.sortBy(optional, defaultcreatedAt):createdAt|status|expiresAt.sortOrder(optional, defaultdesc):asc|desc. Applied only whensortByis 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, default1): page number.pageSize(optional, default10, max100): items per page.companyId(optional, non-empty):systemfor system-only roles,<companyId>for that company plus system roles, omitted for all roles. An empty value (?companyId=) returns400.all(optional):trueto fetch the complete role catalog for pickers and exact-matchroleNamefilters (no silent truncation atpageSizecap). Filters/sorting still apply; response keeps the paginated envelope withpageNumber: 1, pageSize: count, totalPages: 1. Omit for paginated management-table views.scopeType(optional):COMPANY|BRANCH|PROGRAM.isSystem(optional):true|falseboolean filter.systemRole(optional): filter by enum (OWNER,COMPANY_ADMIN,BRANCH_MANAGER, etc.).search(optional): search by role name.sortBy(optional, defaultcreatedAt):name|createdAt|scopeType|isSystem.-
sortOrder(optional, defaultasc):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 beownershipOnly, and must match the role scope (COMPANYrole → any permission;BRANCHrole →BRANCH/PROGRAMpermissions;PROGRAMrole →PROGRAMpermissions only).
{
"companyId": "company_123",
"name": "Branch Viewer",
"description": "Read-only access to a branch",
"scopeType": "BRANCH",
"permissionIds": ["perm_branch_read", "perm_auction_read"]
}
- Response:
201with the created role (same shape as the list item above). - Audit:
role.created_by_adminrecorded 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 ornull).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:
200with the updated role. - Audit:
role.updated_by_adminwith 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:
200with{ "roleId": "..." }. - Audit:
role.deleted_by_adminwith 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: truepermissions (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:
COMPANYrole → any permission;BRANCHrole →BRANCH/PROGRAMpermissions;PROGRAMrole →PROGRAMpermissions only. - Ordered by
scopeTypethencode, deterministic across calls.
5. Company Portal APIs¶
All requests in this section require:
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, default1)pageSize(optional, default10, max100)status(optional):ACTIVE|INVITED|SUSPENDED|REVOKEDroleId(optional): exact role ID (merged withroleIdswhen 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|PROGRAMbranchId(optional): exact branch IDsearch(optional, max150chars): 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, defaultcreatedAt):createdAt|status|name|email.nameandemailsort by the linked user profile.sortOrder(optional, defaultasc):asc|desc. Applied only whensortByis provided.- Response: Returns
membershipslist with populatedeffectivePermissionsfor 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: UpdatedMembershipSchema.
5.2.2 Reactivate employee membership¶
POST /v2/companies/employees/:membershipId/reactivate
- Required Permission:
employee.suspend - Response
data: UpdatedMembershipSchemawithstatus: "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: UpdatedMembershipSchemawithstatus: "REVOKED".
5.2.4 Assign scoped role¶
POST /v2/companies/employees/:membershipId/assignments
- Required Permission:
employee.assignment.manage - Request Body:
- Validation Rules:
- If
scopeType === 'BRANCH',branchIdis required and must exist in the company. - If
scopeType === 'PROGRAM',programIdis required and must exist in the company. - You cannot assign the system
OWNERrole (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 activeOWNER). - Target Invariant: Target membership must be
ACTIVEand belong to the same company. - Atomic Execution:
- Grants
role_owner_companyto 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:
5.3.2 List invitations¶
GET /v2/companies/employees/invitations
- Required Permission:
employee.read - Query Parameters:
pageNumber(optional, default1)pageSize(optional, default10, max100)status(optional):PENDING|ACCEPTED|REVOKED|EXPIREDsearch(optional, max150chars): 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 withstatusand sorting.sortBy(optional, defaultcreatedAt):createdAt|status|expiresAt.sortOrder(optional, defaultdesc):asc|desc. Applied only whensortByis 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:
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, default1)pageSize(optional, default10, max100)status(optional):ACTIVE|INACTIVE|ARCHIVED. Archived (soft-deleted) branches are returned only whenARCHIVEDis 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, defaultasc):asc|desc. Applied only whensortByis provided.- Response
data: paginated object with the samebranchesarray 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:
- Check if the employee has the
OWNERrole (assignment.role.systemRole === 'OWNER'). - If yes, query
GET /v2/companies/employees?systemRole=OWNER&status=ACTIVE. - 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. |