Company tenancy, branches, employees, and roles — frontend integration¶
This guide covers the complete frontend flow for the company tenant model: branch management, branch bank accounts, required branchId on program creation, draft program transfer, employee invitations, employee and assignment administration, custom roles, complete permission catalog, real-time WebSocket auction subscription, and company profile/MOU/dashboard authorization gates.
This is the complete frontend flow for the company tenant model: branch management, branch bank accounts, required branchId on program creation, draft program transfer, employee invitations, employee and assignment administration, custom roles, complete permission catalog, real-time WebSocket auction subscription, and company profile/MOU/dashboard authorization gates.
See the company tenancy migration for the deployment contract.
1. Tenant model¶
| Area | Behavior |
|---|---|
| Tenant identity | Company.id is the only tenant key; derived by the server from the actor's membership — never sent by the frontend |
| Company persona | One active CompanyMembership per user; permissions and scoped assignments decide access |
| Branches | Branches are company-owned resources with codes, addresses, banking, and lifecycle |
| Program creation | branchId is required in step 1 of every new and existing program creation, including superadmin |
| Staff access | Employee invitations with scoped role assignments; one user belongs to one company |
| Program lists | Filtered by the employee's effective branch/program grants |
| Real-time sockets | Sockets authenticate via active membership; join via auction.subscribe only (direct room.join rejected with 403) |
| Company profile | Scoped permissions required for profile read/update, MOU acceptance, and dashboard endpoints |
Mental model:
Company
├── Branches
│ ├── Operational address
│ ├── Bank accounts
│ └── Programs
│ ├── Subscriptions
│ ├── Cycles
│ └── Auctions
└── Employees (CompanyMemberships)
└── Scoped role assignments (Role + Scope + Target)
Every company has exactly one live headquarters branch (isHeadquarters: true). The headquarters branch cannot be deactivated or archived. A program belongs to exactly one company and one branch inside that company.
Authorization primitives¶
The JWT role COMPANY only marks the persona; it grants nothing by itself. All company permissions are resolved from the actor's membership and role assignments. Scopes are additive:
scopeType | Required target | Effective reach |
|---|---|---|
COMPANY | no branchId or programId | all company resources |
BRANCH | exactly one branchId | that branch and its programs |
PROGRAM | exactly one programId | that program only |
Built-in system roles¶
The platform provisions 12 immutable system roles across three scopes:
| Role ID | Name | Scope | System role enum | Core purpose |
|---|---|---|---|---|
role_owner_company | Owner | COMPANY | OWNER | Full company access including ownership transfer |
role_company_admin_company | Company Admin | COMPANY | COMPANY_ADMIN | Full company access except ownership transfer |
role_branch_manager_branch | Branch Manager | BRANCH | BRANCH_MANAGER | Manage branch profile, address, banking, and programs at branch |
role_program_manager_branch | Program Manager (Branch) | BRANCH | PROGRAM_MANAGER | Manage all programs and program lifecycle under an assigned branch |
role_program_manager_program | Program Manager (Program) | PROGRAM | PROGRAM_MANAGER | Manage program lifecycle, status, documents for a specific program |
role_auction_operator_branch | Auction Operator (Branch) | BRANCH | AUCTION_OPERATOR | Schedule, operate, and bid manage auctions across a branch |
role_auction_operator_program | Auction Operator (Program) | PROGRAM | AUCTION_OPERATOR | Schedule, operate, and bid manage auctions for a specific program |
role_subscription_manager_branch | Subscription Manager (Branch) | BRANCH | SUBSCRIPTION_MANAGER | Manage subscribers, payments, and statements across a branch |
role_subscription_manager_program | Subscription Manager (Program) | PROGRAM | SUBSCRIPTION_MANAGER | Manage subscribers, payments, and statements for a specific program |
role_viewer_company | Viewer (Company) | COMPANY | VIEWER | Read-only access across all company resources |
role_viewer_branch | Viewer (Branch) | BRANCH | VIEWER | Read-only access across assigned branch and its programs |
role_viewer_program | Viewer (Program) | PROGRAM | VIEWER | Read-only access for a specific program and its auctions |
OWNER is never assignable through the API or invitations — only through atomic ownership transfer. Companies can also define custom roles with a fixed scope using permissions the actor already possesses.
Out-of-scope guessed IDs return 404, not 403, so tenants cannot probe other companies' resources. Build error states that never claim whether a resource exists in another company.
2. Conventions used in this document¶
- Send
Authorization: Bearer <access-token>and JSON bodies. - Company routes derive the tenant from the token. There is no
companyIdin any company-side path or body. - Success responses use the common envelope
{ status, code, message, data, error }. Successful creations return HTTP201with the envelopecode: 201; everything else returns200. Do not branch on human-readablemessage. - Paginated list endpoints accept
pageNumber(integer ≥ 1, default 1) andpageSize(integer 1–100, default 10) as query parameters and return:
{
"status": "success",
"code": 200,
"message": "...",
"data": {
"count": 23,
"pageNumber": 1,
"pageSize": 10,
"totalPages": 3,
"hasPreviousPage": false,
"hasNextPage": true,
"...": "resource array (see each endpoint)"
},
"error": null
}
- All IDs are opaque strings. Never construct or guess IDs.
3. Branches¶
Base path: /v2/companies/branches
| Method | Path | Permission | Success | Behavior |
|---|---|---|---|---|
POST | / | branch.create | 201 | create an active branch |
GET | / | branch.read (scoped) | 200 | paginated, searchable list within the actor's grants |
GET | /:branchId | branch.read | 200 | branch detail |
PATCH | /:branchId | branch.profile.manage | 200 | update name, code, or contact fields |
PUT | /:branchId/address | branch.address.manage | 200 | create or replace the operational address |
POST | /:branchId/deactivate | branch.lifecycle.manage | 200 | set status INACTIVE |
POST | /:branchId/reactivate | branch.lifecycle.manage | 200 | set status ACTIVE |
DELETE | /:branchId | branch.lifecycle.manage | 200 | archive an eligible branch (soft delete) |
3.1 Branch object¶
Returned by every branch endpoint:
{
"id": "branch_cuid2",
"companyId": "company_cuid2",
"name": "Kochi Central",
"branchCode": "KL-01",
"status": "ACTIVE",
"isHeadquarters": false,
"email": "kochi@example.com",
"mobileNumber": "9876543210",
"countryCode": "IN",
"address": {
"id": "addr_cuid2",
"branchId": "branch_cuid2",
"line1": "MG Road",
"line2": null,
"pincode": "682016",
"postOffice": null,
"city": "Kochi",
"district": "Ernakulam",
"state": "Kerala",
"cityId": null,
"postOfficeId": null
},
"isProgramReady": true,
"createdAt": "2026-09-16T09:00:00.000Z",
"updatedAt": "2026-09-16T09:00:00.000Z",
"deletedAt": null
}
statusisACTIVE,INACTIVE, orARCHIVED.addressisnulluntil an operational address is set.isProgramReadyis computed, never stored. It istrueonly when the branch is active, not archived/deleted, and has an operational address. A verified bank account is not required for readiness.
3.2 Create a branch¶
{
"name": "Kochi Central",
"branchCode": "kl-01",
"email": "kochi@example.com",
"mobileNumber": "9876543210",
"countryCode": "IN",
"address": {
"line1": "MG Road",
"pincode": "682016",
"city": "Kochi",
"district": "Ernakulam",
"state": "Kerala"
}
}
Validation:
nameis required, trimmed, 1–150 characters.branchCode,email,mobileNumber,countryCode, andaddressare optional.countryCodedefaults toIN.pincodemust be exactly 6 digits when present.- Returns
201with the branch object. All mutations and the audit record are committed in one transaction.
3.3 List branches¶
| Parameter | Type | Notes |
|---|---|---|
pageNumber | integer ≥ 1 | default 1 |
pageSize | integer 1–100 | default 10 |
search | string, max 150 | matches branch name and normalized code |
status | ACTIVE | INACTIVE | ARCHIVED | optional filter |
Response data contains the pagination fields plus a branches array of branch objects. Results are restricted to the actor's effective company/branch grants — a branch-scoped employee sees only their assigned branches, while a company-scoped employee sees everything.
3.4 Update a branch¶
All fields are optional, but at least one must be present:
Omitted fields keep their stored values. Use "branchCode": null to clear the code. Updating the address is not part of PATCH — use the address endpoint.
3.5 Replace the address¶
{
"line1": "MG Road",
"pincode": "682016",
"city": "Kochi",
"district": "Ernakulam",
"state": "Kerala"
}
line1 is required; all other fields are optional or nullable. This endpoint creates or fully replaces the branch's single operational address and returns the updated branch object.
3.6 Lifecycle: deactivate, reactivate, archive¶
POST /:branchId/deactivatesetsstatus: INACTIVE. Authorization caches are invalidated immediately, so branch-scoped access that depends on the branch's active state stops working right away.POST /:branchId/reactivatesetsstatus: ACTIVE. Reactivating an archived branch is rejected with409.DELETE /:branchIdarchives the branch (status: ARCHIVED,deletedAtset). The row is never hard-deleted. Rejections with409:- the branch is already archived;
- the branch is the headquarters branch;
- the branch still has non-terminal programs.
Archive is irreversible through the API. Require explicit user confirmation and show the blocking reason for headquarters/program conflicts.
3.7 Branch code rules¶
branchCode is optional on create and update:
- surrounding whitespace is trimmed and letters are uppercased, so
kl-01is stored asKL-01; - an empty string is stored as
null(the UI can send""ornullto clear); - 1–20 characters when present;
- only uppercase letters, digits, and internal hyphens;
- leading, trailing, or repeated hyphens are rejected;
- unique among non-deleted branches within the company — the same code may exist in another company;
- a duplicate returns
409 Conflictwith abranchCodefield path.
The headquarters branch may be renamed and given a code, but it always exists and cannot be removed.
4. Branch bank accounts¶
Base path: /v2/companies/branches/:branchId/bank-accounts
All operations require branch.banking.manage on the selected branch.
| Method | Path | Success | Behavior |
|---|---|---|---|
GET | / | 200 | paginated list for the branch |
POST | / | 201 | create an account |
PATCH | /:bankAccountId | 200 | update an account |
DELETE | /:bankAccountId | 200 | soft-delete an account |
Create/update body:
{
"accountHolder": "Example Chits Private Limited",
"accountNumber": "123456789012",
"accountType": "SAVINGS",
"bankName": "HDFC Bank",
"branchName": "Kochi",
"ifsc": "HDFC0001234",
"countryCode": "IN",
"currency": "INR",
"isPrimary": true
}
accountHolder, accountNumber, bankName, and ifsc are required on create; every field is optional on PATCH (at least one required). Response objects include verificationStatus (PENDING/VERIFIED/... per the platform's verification lifecycle), verifiedAt, and isPrimary.
Constraints the UI must anticipate:
- account numbers are unique per branch;
- at most one non-deleted primary account exists per branch — making an account primary demotes the previous primary;
- the list response's
datacontains pagination fields plus abankAccountsarray; create/update responses return{ "bankAccountId": "..." }.
Bank accounts are managed under branches.
5. Program ownership and branchId¶
5.1 Required branchId at creation¶
branchId is required in step 1 of every program creation flow:
| Flow | Route |
|---|---|
| Company new program | POST /v2/companies/programs/new/steps/1 |
| Company existing program | POST /v2/companies/programs/existing/steps/1 |
| Superadmin new program | POST /v2/admin/programs/new/steps/1 |
| Superadmin existing import | POST /v2/admin/programs/existing/steps/1 |
The branch must:
- belong to the target company (admin sends
companyIdplusbranchId); - be active, not archived/deleted;
- be program-ready (
isProgramReady === true— it has an operational address); - be inside the employee's effective scope for company-side creation.
Load branches with GET /v2/companies/branches?status=ACTIVE and present only branches where isProgramReady is true as creation targets. Show a clear "this branch has no address yet" state that links to the branch address form instead of a generic validation error.
Full field-by-field creation validation lives in the program creation and editing integration guide; this section only covers the branch ownership contract.
5.2 Branch data in program responses¶
Company and admin program responses now include the owning branch:
branchId;- branch name;
- optional branch code.
Display the branch name/code in program lists, program detail, and dependent auction/subscription/request/payment/cycle screens. Program lists and all dependent company operations are filtered by the actor's effective branch/program access.
5.3 Transfer a draft program between branches¶
POST /v2/companies/programs/:programId/transfer
Content-Type: application/json
{ "branchId": "<destination-branch-id>" }
Success response:
{
"status": "success",
"code": 200,
"message": "Program branch transferred.",
"data": {
"programId": "prg_cm7a1b2c3d4e5f6g7h8i9j99",
"branchId": "brn_cm7a1b2c3d4e5f6g7h8i9j0k"
},
"error": null
}
Rules:
- The actor needs
program.transferon both the source and destination branches. - The destination branch must be program-ready (
isProgramReady === true). - Only
INCOMPLETEorDRAFTEDprograms with no enrolled subscribers, cycles, or auctions can be transferred. - The update and its audit record are committed atomically.
UI: offer transfer only for eligible drafts, disable destinations that are not program-ready, and refetch the program after a successful transfer.
6. Employee invitations¶
Invitations replace branch onboarding entirely. An invitation carries proposed role assignments; the invitee sets only their display name on acceptance.
Base path: /v2/companies/employees/invitations (authenticated company routes)
| Method | Path | Permission | Success | Behavior |
|---|---|---|---|---|
POST | / | employee.invite | 201 | create token + OTP invitation |
GET | / | employee.read | 200 | paginated list, optional status filter |
POST | /:invitationId/resend | employee.invite | 200 | rotate token/OTP, extend expiry |
POST | /:invitationId/revoke | employee.invite | 200 | revoke a pending invitation |
Invitation statuses: PENDING, ACCEPTED, REVOKED, EXPIRED.
6.1 Invite an employee¶
{
"name": "Auction Operator",
"email": "operator@example.com",
"mobileNumber": "9876543210",
"countryCode": "IN",
"assignments": [
{
"roleId": "role_auction_operator_branch",
"scopeType": "BRANCH",
"branchId": "brn_cm7a1b2c3d4e5f6g7h8i9j0k"
}
]
}
Validation and behavior:
name1–150 characters;emailrequired;mobileNumberrequired;countryCodedefaults toIN.assignmentsrequires at least one entry. Each entry is{ roleId, scopeType, branchId?, programId? }and must match its scope:COMPANYtakes no resource ID,BRANCHexactly onebranchId,PROGRAMexactly oneprogramId.- The role's own scope must equal the assignment's
scopeType. - Every proposed role and resource must be assignable by the inviter — the backend checks that the inviter holds every permission in the proposed role at the proposed scope.
OWNERroles are always rejected. - The email/mobile must not belong to a subscriber, an employee of another company, or an existing member of the same company; otherwise
409 Conflict. - A pending invitation already existing for the same identity returns
409 Conflict. - The token is a 64-character random string delivered only via the invitation link; the server stores only an HMAC-SHA256 hash. Expiry is 72 hours.
- The OTP is delivered by SMS to the invitee's mobile; the email (with the invitation link) is queued.
- Response
data:
{
"status": "success",
"code": 201,
"message": "Employee invitation created.",
"data": {
"invitationId": "inv_cm7a1b2c3d4e5f6g7h8i9j05",
"expiresAt": "2026-09-19T09:00:00.000Z"
},
"error": null
}
For the assignment picker UI, load roles with GET /v2/companies/roles?all=true (full catalog, no truncation) and filter to roles the inviter can actually delegate at the chosen scope; surface 400 scope-mismatch errors against the specific assignment row. Paginated table views omit all.
6.2 List employee invitations¶
Response data:
{
"status": "success",
"code": 200,
"message": "Employee invitations fetched.",
"data": {
"count": 1,
"pageNumber": 1,
"pageSize": 10,
"totalPages": 1,
"hasPreviousPage": false,
"hasNextPage": false,
"invitations": [
{
"id": "inv_cm7a1b2c3d4e5f6g7h8i9j05",
"companyId": "cmp_cm7a1b2c3d4e5f6g7h8i9j00",
"email": "operator@example.com",
"mobileNumber": "9876543210",
"countryCode": "IN",
"status": "PENDING",
"expiresAt": "2026-09-19T09:00:00.000Z",
"invitedById": "usr_cm7a1b2c3d4e5f6g7h8i9j00",
"proposedAssignments": [
{
"roleId": "role_auction_operator_branch",
"scopeType": "BRANCH",
"branchId": "brn_cm7a1b2c3d4e5f6g7h8i9j0k"
}
],
"acceptedAt": null,
"revokedAt": null,
"sendCount": 1,
"lastSentAt": "2026-09-16T09:00:00.000Z",
"createdAt": "2026-09-16T09:00:00.000Z",
"updatedAt": "2026-09-16T09:00:00.000Z"
}
]
},
"error": null
}
6.3 Resend and revoke¶
POST /:invitationId/resend— rate limited to once per minute (429 Too Many Requestsotherwise, enforced both in Redis and database timestamps). Rotates the token and OTP, extends expiry to 72 hours from the resend, and re-sends both channels. Returns{ invitationId, expiresAt }. OnlyPENDINGinvitations can be resent; anything else returns404 Not Found.POST /:invitationId/revoke— revokes aPENDINGinvitation. Returns{ invitationId }. Revoking a non-pending invitation returns404 Not Found.
Disable the resend button for one minute after a successful send; display a live countdown timer in the UI.
6.4 Accept an invitation (auth route)¶
This is a public auth route — no session or bearer token is required:
POST /v2/auth/employee-invitations/accept
Content-Type: application/json
{
"token": "a1b2c3d4e5f6... (64 hex characters from invitation link)",
"otp": "123456",
"name": "Employee Name"
}
tokenis exactly 64 characters,otpis exactly 6 digits,nameis 1–150 characters.- Checks, in order: token exists, invitation is
PENDINGand unexpired, OTP matches the invitee's mobile, then identity uniqueness, company state, membership, and assignments. - On success, in one atomic transaction: the user is created or attached (an existing
COMPANY-role user with matching email and mobile and no membership can be claimed; everything else conflicts), their mobile provider is marked verified, the membership is createdACTIVE, all proposed assignments are created, and the invitation is consumed. - Replays return
409 Conflict; a consumed invitation can never create a second membership. - Response data:
{
"status": "success",
"code": 200,
"message": "Employee invitation accepted.",
"data": {
"userId": "usr_cm7a1b2c3d4e5f6g7h8i9j01",
"membershipId": "mem_cm7a1b2c3d4e5f6g7h8i9j02"
},
"error": null
}
This route does not log the user in. The frontend must inform the user that their account is created and route them to standard mobile OTP login (POST /v2/auth/request-otp).
Frontend acceptance screen requirements:
- Read the token from the invitation link's query parameter (
?token=...). - Collect the OTP and the display name only; never re-collect email/mobile.
- Show distinct errors for invalid/expired token, wrong OTP, and identity conflict; offer the resend path via the inviter for expiry cases.
- Handle
409as terminal — the invitation was consumed or the identity cannot be used.
6.5 Admin invitation list (superadmin)¶
Superadmin consoles use an explicit company path instead of the membership-derived tenant:
GET /v2/admin/companies/:companyId/invitations?pageNumber=1&pageSize=10&status=PENDING&sortBy=createdAt&sortOrder=desc
status accepts PENDING, ACCEPTED, REVOKED, or EXPIRED (invitation statuses — distinct from membership statuses). The response returns { count, pageNumber, pageSize, invitations } with the same invitation objects as the company list. A missing company returns 404; an invalid status returns 400.
7. Employees and assignments¶
Base path: /v2/companies/employees
| Method | Path | Permission | Behavior |
|---|---|---|---|
GET | / | employee.read | Paginated memberships with roles and effective access |
POST | /:membershipId/suspend | employee.suspend | Suspend and revoke sessions |
POST | /:membershipId/reactivate | employee.suspend | Set status back to ACTIVE |
POST | /:membershipId/revoke | employee.suspend | Revoke membership and sessions |
POST | /:membershipId/assignments | employee.assignment.manage | Add a scoped role assignment |
DELETE | /:membershipId/assignments/:assignmentId | employee.assignment.manage | Remove an assignment |
POST | /:membershipId/transfer-ownership | ownership.transfer | Atomically transfer OWNER |
7.1 List employees¶
Query parameters:
pageNumber: integer ≥ 1 (default 1)pageSize: integer 1–100 (default 10)status: optional filter (ACTIVE|SUSPENDED|REVOKED)
Success response data:
{
"status": "success",
"code": 200,
"message": "Employees fetched.",
"data": {
"count": 1,
"memberships": [
{
"id": "mem_cm7a1b2c3d4e5f6g7h8i9j02",
"userId": "usr_cm7a1b2c3d4e5f6g7h8i9j01",
"companyId": "cmp_cm7a1b2c3d4e5f6g7h8i9j00",
"status": "ACTIVE",
"authorizationVersion": 1,
"user": {
"name": "Jane Doe",
"email": "jane@example.com",
"mobileNumber": "9876543210",
"countryCode": "IN"
},
"assignments": [
{
"id": "asg_cm7a1b2c3d4e5f6g7h8i9j03",
"scopeType": "BRANCH",
"branchId": "brn_cm7a1b2c3d4e5f6g7h8i9j0k",
"programId": null,
"role": {
"id": "role_branch_manager_branch",
"companyId": null,
"name": "Branch Manager",
"description": null,
"scopeType": "BRANCH",
"systemRole": "BRANCH_MANAGER",
"isSystem": true,
"assignmentCount": 1,
"permissions": []
}
}
],
"effectivePermissions": {
"company": [],
"branches": {
"brn_cm7a1b2c3d4e5f6g7h8i9j0k": [
"branch.read",
"branch.profile.manage",
"branch.address.manage",
"branch.banking.manage",
"branch.lifecycle.manage",
"program.create"
]
},
"programs": {}
}
}
]
},
"error": null
}
Notice:
data.membershipscontains the membership records.- Each item provides both
assignments(explains which roles were assigned) andeffectivePermissions(pre-computed map acrosscompany,branches, andprograms). - Use
effectivePermissionsto toggle UI actions client-side.
7.2 Membership status changes¶
Endpoints:
POST /v2/companies/employees/:membershipId/suspendPOST /v2/companies/employees/:membershipId/reactivatePOST /v2/companies/employees/:membershipId/revoke
No body required. Suspending or revoking an employee:
- Immediately invalidates the employee's cached permissions.
- Terminates all active user sessions (
userSessionsRepository.revokeAll), causing the next request from any of their devices to fail authentication (401 Unauthorized). - Revoking also automatically revokes any pending invitations associated with the user's email/mobile number.
- Returns the updated
ManagedMembershipobject.
Last-owner guard: The backend prevents suspending or revoking the sole active owner with 409 Conflict ("Every company must retain at least one active owner.").
7.3 Assign and remove roles¶
Assign a scoped role:
Valid body shapes (target must strictly match scope):
Success response:
{
"status": "success",
"code": 200,
"message": "Role assigned.",
"data": {
"id": "asg_cm7a1b2c3d4e5f6g7h8i9j04",
"membershipId": "mem_cm7a1b2c3d4e5f6g7h8i9j02",
"roleId": "role_program_manager_program",
"scopeType": "PROGRAM",
"branchId": null,
"programId": "prg_cm7a1b2c3d4e5f6g7h8i9j99",
"createdById": "usr_cm7a1b2c3d4e5f6g7h8i9j00",
"createdAt": "2026-09-16T10:00:00.000Z"
},
"error": null
}
Remove a role assignment:
Success response:
{
"status": "success",
"code": 200,
"message": "Role assignment removed.",
"data": {
"assignmentId": "asg_cm7a1b2c3d4e5f6g7h8i9j04"
},
"error": null
}
Rules:
- The actor must hold every permission in the target role at the requested scope.
- Removing an assignment invalidates the target employee's authorization cache immediately.
- Duplicate role assignments for the same scope and resource return
409 Conflict. OWNERcannot be assigned via this route (403 Forbidden).
7.4 Transfer ownership¶
No body required. Requirements:
- The caller must hold
ownership.transfer(i.e. be an owner). - The target
membershipIdmust belong to an active employee of the same company. - Atomic mutation: the target employee becomes
OWNERand the caller is demoted toCOMPANY_ADMIN. - Returns
{ "ownerMembershipId": "<target-membership-id>" }. - Prompt the user for explicit confirmation with the target employee's name. Refetch current user permissions after success.
8. Roles and permission catalog¶
Base path: /v2/companies/roles
| Method | Path | Permission | Behavior |
|---|---|---|---|
GET | /permissions | role.manage | Immutable permission catalog |
GET | / | role.manage | Built-in + custom roles, paginated (same envelope as admin; all=true for full catalog) |
POST | / | role.manage | Create a reusable custom role |
PATCH | /:roleId | role.manage | Update custom role name/description/permissions |
DELETE | /:roleId | role.manage | Delete an unassigned custom role |
GET /v2/companies/roles accepts pageNumber (default 1), pageSize (default 10, max 100), filters (scopeType, isSystem, systemRole, search max 100, sortBy: name|createdAt|scopeType|isSystem, sortOrder), and all=true for the complete reference catalog (role pickers, exact-match roleName filters). Tenant-scoped to the caller's company (own + system roles). Response is { count, pageNumber, pageSize, totalPages, hasPreviousPage, hasNextPage, roles[] } — previously a bare array, so update list parsing accordingly.
8.1 Complete permission catalog¶
GET /v2/companies/roles/permissions returns the flat, immutable catalog. Use it to build permission selection controls dynamically; never hardcode permission IDs in frontend code.
[
{
"id": "perm_company_profile_read",
"code": "company.profile.read",
"scopeType": "COMPANY",
"ownershipOnly": false
},
{
"id": "perm_company_profile_update",
"code": "company.profile.update",
"scopeType": "COMPANY",
"ownershipOnly": false
},
{
"id": "perm_company_compliance_manage",
"code": "company.compliance.manage",
"scopeType": "COMPANY",
"ownershipOnly": false
},
{
"id": "perm_company_mou_manage",
"code": "company.mou.manage",
"scopeType": "COMPANY",
"ownershipOnly": false
},
{
"id": "perm_company_policy_manage",
"code": "company.policy.manage",
"scopeType": "COMPANY",
"ownershipOnly": false
},
{
"id": "perm_company_billing_manage",
"code": "company.billing.manage",
"scopeType": "COMPANY",
"ownershipOnly": false
},
{
"id": "perm_branch_read",
"code": "branch.read",
"scopeType": "BRANCH",
"ownershipOnly": false
},
{
"id": "perm_branch_create",
"code": "branch.create",
"scopeType": "COMPANY",
"ownershipOnly": false
},
{
"id": "perm_branch_profile_manage",
"code": "branch.profile.manage",
"scopeType": "BRANCH",
"ownershipOnly": false
},
{
"id": "perm_branch_address_manage",
"code": "branch.address.manage",
"scopeType": "BRANCH",
"ownershipOnly": false
},
{
"id": "perm_branch_banking_manage",
"code": "branch.banking.manage",
"scopeType": "BRANCH",
"ownershipOnly": false
},
{
"id": "perm_branch_lifecycle_manage",
"code": "branch.lifecycle.manage",
"scopeType": "BRANCH",
"ownershipOnly": false
},
{
"id": "perm_employee_read",
"code": "employee.read",
"scopeType": "COMPANY",
"ownershipOnly": false
},
{
"id": "perm_employee_invite",
"code": "employee.invite",
"scopeType": "COMPANY",
"ownershipOnly": false
},
{
"id": "perm_employee_suspend",
"code": "employee.suspend",
"scopeType": "COMPANY",
"ownershipOnly": false
},
{
"id": "perm_employee_assign",
"code": "employee.assignment.manage",
"scopeType": "COMPANY",
"ownershipOnly": false
},
{
"id": "perm_role_manage",
"code": "role.manage",
"scopeType": "COMPANY",
"ownershipOnly": false
},
{
"id": "perm_ownership_transfer",
"code": "ownership.transfer",
"scopeType": "COMPANY",
"ownershipOnly": true
},
{
"id": "perm_program_create",
"code": "program.create",
"scopeType": "BRANCH",
"ownershipOnly": false
},
{
"id": "perm_program_read",
"code": "program.read",
"scopeType": "PROGRAM",
"ownershipOnly": false
},
{
"id": "perm_program_update",
"code": "program.update",
"scopeType": "PROGRAM",
"ownershipOnly": false
},
{
"id": "perm_program_status_manage",
"code": "program.status.manage",
"scopeType": "PROGRAM",
"ownershipOnly": false
},
{
"id": "perm_program_transfer",
"code": "program.transfer",
"scopeType": "BRANCH",
"ownershipOnly": false
},
{
"id": "perm_program_archive",
"code": "program.archive",
"scopeType": "PROGRAM",
"ownershipOnly": false
},
{
"id": "perm_subscription_manage",
"code": "subscription.manage",
"scopeType": "PROGRAM",
"ownershipOnly": false
},
{
"id": "perm_subscriber_manage",
"code": "subscriber.manage",
"scopeType": "PROGRAM",
"ownershipOnly": false
},
{
"id": "perm_statement_manage",
"code": "statement.manage",
"scopeType": "PROGRAM",
"ownershipOnly": false
},
{
"id": "perm_payment_manage",
"code": "payment.manage",
"scopeType": "PROGRAM",
"ownershipOnly": false
},
{
"id": "perm_auction_read",
"code": "auction.read",
"scopeType": "PROGRAM",
"ownershipOnly": false
},
{
"id": "perm_auction_configure",
"code": "auction.configure",
"scopeType": "PROGRAM",
"ownershipOnly": false
},
{
"id": "perm_auction_schedule",
"code": "auction.schedule",
"scopeType": "PROGRAM",
"ownershipOnly": false
},
{
"id": "perm_auction_operate",
"code": "auction.operate",
"scopeType": "PROGRAM",
"ownershipOnly": false
},
{
"id": "perm_auction_bid_manage",
"code": "auction.bid.manage",
"scopeType": "PROGRAM",
"ownershipOnly": false
},
{
"id": "perm_auction_prebid_manage",
"code": "auction.prebid.manage",
"scopeType": "PROGRAM",
"ownershipOnly": false
},
{
"id": "perm_auction_winner_manage",
"code": "auction.winner.manage",
"scopeType": "PROGRAM",
"ownershipOnly": false
},
{
"id": "perm_document_manage",
"code": "document.manage",
"scopeType": "PROGRAM",
"ownershipOnly": false
},
{
"id": "perm_signature_manage",
"code": "signature.manage",
"scopeType": "PROGRAM",
"ownershipOnly": false
},
{
"id": "perm_template_manage",
"code": "template.manage",
"scopeType": "COMPANY",
"ownershipOnly": false
},
{
"id": "perm_preset_manage",
"code": "preset.manage",
"scopeType": "COMPANY",
"ownershipOnly": false
},
{
"id": "perm_security_type_manage",
"code": "security-type.manage",
"scopeType": "COMPANY",
"ownershipOnly": false
}
]
Scope compatibility rules when constructing custom roles:
COMPANYroles can includeCOMPANY,BRANCH, andPROGRAMpermissions.BRANCHroles can includeBRANCHandPROGRAMpermissions.PROGRAMroles can includePROGRAMpermissions only.ownershipOnlypermissions (ownership.transfer) can never be added to custom roles.
8.2 Create a custom role¶
POST /v2/companies/roles
Content-Type: application/json
{
"name": "Selected Program Collections",
"description": "Handles payments and statements for assigned programs",
"scopeType": "PROGRAM",
"permissionIds": ["perm_program_read", "perm_payment_manage", "perm_statement_manage"]
}
Validation:
name1–100 characters, unique within the company and scope.descriptionup to 255 characters, optional.scopeTypefixes the role's scope.- At least one valid permission ID required.
- The creator must hold all included permissions.
Success response:
{
"status": "success",
"code": 200,
"message": "Role created.",
"data": {
"id": "rol_cm7a1b2c3d4e5f6g7h8i9j06",
"companyId": "cmp_cm7a1b2c3d4e5f6g7h8i9j00",
"name": "Selected Program Collections",
"description": "Handles payments and statements for assigned programs",
"scopeType": "PROGRAM",
"systemRole": null,
"isSystem": false,
"assignmentCount": 0,
"permissions": [
{
"id": "perm_program_read",
"code": "program.read",
"scopeType": "PROGRAM",
"ownershipOnly": false
}
],
"createdAt": "2026-09-16T10:00:00.000Z",
"updatedAt": "2026-09-16T10:00:00.000Z"
},
"error": null
}
8.3 Update and delete¶
PATCH /:roleId: accepts any ofname,description(nullable — sendnullto clear), andpermissionIds(min 1). At least one field required.- System roles (
isSystem: true) cannot be edited or deleted. DELETE /:roleId: fails with409 Conflict("Remove all role assignments before deleting this role.") while any assignment references the role. The UI should checkassignmentCount > 0and show employees holding it before offering deletion.
Changing a role immediately invalidates cached effective access for every membership holding it — employees' permissions change on their next request.
9. Real-time WebSocket integration¶
9.1 Connection and authentication¶
WebSocket connections (/ws or /v2/ws) authenticate with the access token via header or subprotocol:
- Header:
Authorization: Bearer <token> - Subprotocol:
Sec-WebSocket-Protocol: bearer.<token>
During connection handshake, the server resolves the user's active CompanyMembership. The socket's internal companyId is set to Company.id (not the user's userId).
9.2 Auction room subscription¶
[!WARNING] Generic room join commands (
room.join) for auction rooms (auction:<auctionId>orauction:staff:<auctionId>) are forbidden and return403 Forbidden("Use auction.subscribe to join an authorized auction channel.").
Clients must subscribe using the typed auction.subscribe message:
Backend authorization on auction.subscribe:
- Verifies that the actor holds
auction.readat the program scope for this auction. - Automatically joins the socket to both:
auction:<auctionId>(public participant channel)auction:staff:<auctionId>(private staff broadcast channel)- Replays initial auction state and presence counts.
Unsubscribing or closing the socket automatically cleans up both rooms and presence registrations.
9.3 Staff auction actions and required permissions¶
All live auction mutations (via REST or WebSocket) check permissions at the program scope:
| Staff action | WebSocket / REST command | Required permission |
|---|---|---|
| View auction real-time channel | auction.subscribe | auction.read |
| Place bid on behalf of attendee | auction.bid | auction.bid.manage |
| Schedule or approve auction | POST .../schedule / POST .../approve | auction.schedule |
| Cancel auction | POST .../cancel | auction.operate |
| Prebid window (open/close) | POST .../prebid/open / .../close | auction.prebid.manage |
| Reveal prebid amounts | POST .../prebid/reveal | auction.prebid.manage |
| Delete prebid entry | DELETE .../prebid/:prebidId | auction.prebid.manage |
| Disqualify winner | POST .../disqualify | auction.winner.manage |
| Update auction trigger policy | PUT .../trigger-policy | auction.configure |
10. Granular endpoint permission map¶
Company endpoints enforce granular permissions resolved from the actor's membership assignments. The frontend must verify that the actor holds the required permissions before showing UI sections or enabling action buttons:
10.1 Company profile, MOU, and compliance¶
| Endpoint | Method | Required permission | Scope | Notes |
|---|---|---|---|---|
/v2/companies/me | GET | company.profile.read | COMPANY | Basic company profile |
/v2/companies/me/new | GET | company.profile.read | COMPANY | Detailed company profile |
/v2/companies/me | PATCH | company.profile.update | COMPANY | Update company profile fields |
/v2/companies/me/addresses | PATCH | company.profile.update | COMPANY | Update company registered or billing address |
/v2/companies/me/mou | GET | company.profile.read | COMPANY | Get current company MOU |
/v2/companies/me/mou/accept | POST | company.mou.manage | COMPANY | Accept assigned company MOU |
/v2/companies/me/managers/:managerId | PATCH | company.compliance.manage | COMPANY | Update manager details |
/v2/companies/me/file-uploads/generate | POST | company.compliance.manage | COMPANY | Signed URL for company compliance files |
/v2/companies/me/file-uploads/confirm | POST | company.compliance.manage | COMPANY | Confirm company compliance upload |
[!NOTE] Bank accounts are managed under branches (
/v2/companies/branches/:branchId/bank-accounts). Company corporate addresses (REGISTEREDandBILLING) are updated viaPATCH /v2/companies/me/addresses, while branch physical operational addresses are managed under branches (PUT /v2/companies/branches/:branchId/address).
10.2 Dashboard sub-views and metrics¶
| Endpoint | Required permission(s) | Scope |
|---|---|---|
GET /v2/companies/dashboard | program.read AND subscriber.manage | COMPANY |
GET /v2/companies/dashboard/overview | program.read, subscriber.manage, company.billing.manage, employee.read | COMPANY |
GET /v2/companies/dashboard/revenue | company.billing.manage | COMPANY |
GET /v2/companies/dashboard/billing | company.billing.manage | COMPANY |
GET /v2/companies/dashboard/profile-health | company.profile.read | COMPANY |
If an employee lacks any required permission for a dashboard sub-view, suppress that widget or section client-side based on effectivePermissions.company.
10.3 Billing, policies, templates, presets, and security types¶
| Endpoint | Method(s) | Required permission | Scope |
|---|---|---|---|
/v2/companies/billing/invoices | GET | company.billing.manage | COMPANY |
/v2/companies/billing/transactions | GET | company.billing.manage | COMPANY |
/v2/companies/company-auction-policy | GET, PUT | company.policy.manage | COMPANY |
/v2/companies/company-auction-policy/resolved | GET | company.policy.manage | COMPANY |
/v2/companies/auction-presets | GET, POST | preset.manage | COMPANY |
/v2/companies/auction-presets/:presetId | PATCH, DELETE | preset.manage | COMPANY |
/v2/companies/prebid-templates | GET | template.manage | COMPANY |
/v2/companies/prebid-templates/generate | POST | template.manage | COMPANY |
/v2/companies/prebid-templates/confirm | POST | template.manage | COMPANY |
/v2/companies/prebid-templates/:templateId | DELETE | template.manage | COMPANY |
/v2/companies/security-types | GET, POST | security-type.manage | COMPANY |
/v2/companies/security-types/:securityTypeId | PATCH, DELETE | security-type.manage | COMPANY |
10.4 Documents, signatures, and subscriber requests¶
| Endpoint | Method | Required permission | Scope | Notes |
|---|---|---|---|---|
/v2/companies/programs/file-uploads/generate | POST | document.manage | PROGRAM | Scoped to body.programId |
/v2/companies/programs/file-uploads/confirm | POST | document.manage | PROGRAM | Scoped to file's program |
/v2/companies/documents/signatures/generate | POST | signature.manage | COMPANY | Upload signature asset |
/v2/companies/documents/signatures/confirm | POST | signature.manage | COMPANY | Confirm signature upload |
/v2/companies/documents/signatures | GET | signature.manage | COMPANY | List company signature assets |
/v2/companies/me/programs/:programId/consent | POST | signature.manage | PROGRAM | Accept program consent |
/v2/companies/requests | GET | (scoped filter) | PROGRAM | Filtered to accessible branches/programs |
/v2/companies/requests/:requestId | GET | subscriber.manage | PROGRAM | Scoped to request's program |
/v2/companies/requests/:requestId/status | PATCH | subscriber.manage | PROGRAM | Scoped to request's program |
/v2/companies/requestors | GET | (scoped filter) | PROGRAM | Filtered to accessible branches/programs |
/v2/companies/programs/:programId/requestors | GET | subscriber.manage | PROGRAM | Scoped to target program |
10.5 Program lifecycle, cycles, invoices, and payments¶
| Endpoint | Method | Required permission | Scope | Notes |
|---|---|---|---|---|
/v2/companies/programs/names | GET | (scoped filter) | PROGRAM | Scoped to accessible branches/programs |
/v2/companies/me/programs | GET | (scoped filter) | PROGRAM | Scoped to accessible branches/programs |
/v2/companies/me/programs/:programId | GET | program.read | PROGRAM | Scoped to target program |
/v2/companies/me/programs/:programId | PATCH | program.update | PROGRAM | Scoped to target program |
/v2/companies/me/programs/:programId/subscribers | POST | subscriber.manage | PROGRAM | Add subscribers to program |
/v2/companies/programs/:programId/subscribers | GET | subscriber.manage | PROGRAM | List enrolled subscribers |
/v2/companies/programs/:programId/subscribers/:subscriberId/statements | GET | statement.manage | PROGRAM | List subscriber statements |
/v2/companies/programs/:programId/program-status | PATCH | program.status.manage | PROGRAM | Scoped to target program |
/v2/companies/programs/:programId/settings | GET | auction.read | PROGRAM | Scoped to target program |
/v2/companies/programs/:programId/settings | PATCH | auction.configure | PROGRAM | Scoped to target program |
/v2/companies/programs/:programId/review/details | GET | program.read | PROGRAM | Scoped to target program |
/v2/companies/programs/:programId/review/subscribers | GET | program.read | PROGRAM | Scoped to target program |
/v2/companies/programs/:programId/reviews | GET | program.read | PROGRAM | Approval reviews for program |
/v2/companies/programs/:programId/winners | GET | auction.read | PROGRAM | Program winners list |
/v2/companies/cycles | POST | program.status.manage | PROGRAM | Scoped to body.programId |
/v2/companies/cycles/:cycleId | PATCH | program.status.manage | PROGRAM | Scoped to cycle's program |
/v2/companies/cycles/:cycleId/invoices | POST | payment.manage | PROGRAM | Scoped to cycle's program |
/v2/companies/cycles/:cycleId/invoices | GET | statement.manage | PROGRAM | Scoped to cycle's program |
/v2/companies/cycles/:cycleId/invoices/program-details | GET | statement.manage | PROGRAM | Scoped to cycle's program |
/v2/companies/cycles/:cycleId/winners | POST | auction.winner.manage | PROGRAM | Scoped to cycle's program |
/v2/companies/invoices/:invoiceId/mark-as-paid | POST | payment.manage | PROGRAM | Scoped to invoice's program |
/v2/companies/invoices/:invoiceId/cancel | POST | payment.manage | PROGRAM | Scoped to invoice's program |
/v2/companies/payments | GET | (scoped filter) | PROGRAM | Filtered to accessible branches/programs |
11. Error handling matrix¶
| Status | Error cause | Frontend action |
|---|---|---|
400 | Validation failure, scope/target mismatch, branch not program-ready, or ineligible program transfer | Highlight relevant form fields using error path; show modal for state errors |
401 | Missing/invalid JWT token or revoked employee membership | Clear local auth state; redirect to company login screen |
403 | Missing administrative permission, attempted OWNER assignment, or direct room.join to an auction channel | Show "Access denied"; switch to auction.subscribe for auction rooms |
404 | Resource missing, deleted, or outside the actor's scope (including foreign IDs or lacking scoped permission) | Render standard "Not found" view; never disclose foreign existence |
409 | Duplicate branch code, duplicate pending invitation, identity conflict, deleting assigned role, last-owner invariant, or replay of consumed invitation | Show actionable conflict message (e.g. "Branch code already in use") |
429 | Invitation resend attempted within 60 seconds | Start a 60-second visual countdown; disable resend button until timer expires |
[!IMPORTANT] 403 Forbidden vs 404 Not Found:
403 Forbiddenis returned for company-wide administrative gates (e.g. lackingrole.manageon/v2/companies/roles, lackingbranch.createonPOST /branches, or sending a directroom.jointo an auction channel).404 Not Foundis deliberately returned when attempting to read or mutate a scoped resource (branch, program, cycle, invoice, auction, or request) where the actor lacks the required permission on that specific resource's branch or program. This prevents probing attacks and ensures tenants cannot enumerate resources outside their assigned scope.
Validation errors may carry a field path (for example branchCode). Map it to the form field when possible, but always keep a form-level fallback for state and conflict errors.
12. Frontend implementation checklist¶
- Tenant identity: No request sends a client-provided
companyId. All company-side calls derive the tenant from the activeCompanyMembership. - Branch management: Complete UI for create, list/filter, update, address replacement, deactivate/reactivate, and archive with
409guards. - Branch bank accounts: Complete UI for branch-scoped bank accounts under
/v2/companies/branches/:branchId/bank-accountswith primary account rules. - Program creation step 1: Sends mandatory
branchId; enforcesisProgramReadyclient-side and surfaces branch address setup prompt when unready. - Program transfer: Available for draft/incomplete programs without subscribers/cycles/auctions; targets program-ready branches only.
- Employee invitations: Staff invite screen with scoped assignment builder (
COMPANY,BRANCH,PROGRAM). Enforces 60s cooldown on resend. - Acceptance flow: Public screen accepting
token,otp, andname. Handles invalid token, bad OTP, and conflict states, redirecting to login afterwards. - Employee administration: Displays assigned roles and computed
effectivePermissions. Warns that suspend/revoke logs the user out immediately. - Ownership transfer: Prompts for explicit target confirmation; refetches caller permissions immediately on completion.
- Role management: Builds permission pickers from
GET /permissions; enforces scope validity; blocks delete ifassignmentCount > 0. - WebSocket auction subscription: Connects using active company token; subscribes to auction rooms using typed
auction.subscribe(no genericroom.join). - Dashboard and profile permissions: Hides or disables profile, MOU, and dashboard sections when the employee's
effectivePermissionslack required keys.
13. Related backend references¶
- REST contract: Company tenancy, branches, employees, and roles
- Superadmin provisioning: Superadmin company provisioning
- Program creation & editing: Program creation and editing integration
- Authorization architecture & caching: Company authorization
- Database rollout & rollback contract: Company tenancy migration
- REST documentation index: REST overview