Skip to content

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 companyId in any company-side path or body.
  • Success responses use the common envelope { status, code, message, data, error }. Successful creations return HTTP 201 with the envelope code: 201; everything else returns 200. Do not branch on human-readable message.
  • Paginated list endpoints accept pageNumber (integer ≥ 1, default 1) and pageSize (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
}
  • status is ACTIVE, INACTIVE, or ARCHIVED.
  • address is null until an operational address is set.
  • isProgramReady is computed, never stored. It is true only 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

POST /v2/companies/branches
{
  "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:

  • name is required, trimmed, 1–150 characters.
  • branchCode, email, mobileNumber, countryCode, and address are optional. countryCode defaults to IN. pincode must be exactly 6 digits when present.
  • Returns 201 with the branch object. All mutations and the audit record are committed in one transaction.

3.3 List branches

GET /v2/companies/branches?pageNumber=1&pageSize=10&search=kochi&status=ACTIVE
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

PATCH /v2/companies/branches/:branchId

All fields are optional, but at least one must be present:

{ "name": "Kochi Central Office", "branchCode": "KL-01" }

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

PUT /v2/companies/branches/:branchId/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/deactivate sets status: INACTIVE. Authorization caches are invalidated immediately, so branch-scoped access that depends on the branch's active state stops working right away.
  • POST /:branchId/reactivate sets status: ACTIVE. Reactivating an archived branch is rejected with 409.
  • DELETE /:branchId archives the branch (status: ARCHIVED, deletedAt set). The row is never hard-deleted. Rejections with 409:
  • 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-01 is stored as KL-01;
  • an empty string is stored as null (the UI can send "" or null to 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 Conflict with a branchCode field 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 data contains pagination fields plus a bankAccounts array; 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 companyId plus branchId);
  • 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.transfer on both the source and destination branches.
  • The destination branch must be program-ready (isProgramReady === true).
  • Only INCOMPLETE or DRAFTED programs 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

POST /v2/companies/employees/invitations
Content-Type: application/json
{
  "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:

  • name 1–150 characters; email required; mobileNumber required; countryCode defaults to IN.
  • assignments requires at least one entry. Each entry is { roleId, scopeType, branchId?, programId? } and must match its scope: COMPANY takes no resource ID, BRANCH exactly one branchId, PROGRAM exactly one programId.
  • 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. OWNER roles 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

GET /v2/companies/employees/invitations?pageNumber=1&pageSize=10&status=PENDING

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 Requests otherwise, 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 }. Only PENDING invitations can be resent; anything else returns 404 Not Found.
  • POST /:invitationId/revoke — revokes a PENDING invitation. Returns { invitationId }. Revoking a non-pending invitation returns 404 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"
}
  • token is exactly 64 characters, otp is exactly 6 digits, name is 1–150 characters.
  • Checks, in order: token exists, invitation is PENDING and 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 created ACTIVE, 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:

  1. Read the token from the invitation link's query parameter (?token=...).
  2. Collect the OTP and the display name only; never re-collect email/mobile.
  3. Show distinct errors for invalid/expired token, wrong OTP, and identity conflict; offer the resend path via the inviter for expiry cases.
  4. Handle 409 as 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

GET /v2/companies/employees?pageNumber=1&pageSize=10&status=ACTIVE

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.memberships contains the membership records.
  • Each item provides both assignments (explains which roles were assigned) and effectivePermissions (pre-computed map across company, branches, and programs).
  • Use effectivePermissions to toggle UI actions client-side.

7.2 Membership status changes

Endpoints:

  • POST /v2/companies/employees/:membershipId/suspend
  • POST /v2/companies/employees/:membershipId/reactivate
  • POST /v2/companies/employees/:membershipId/revoke

No body required. Suspending or revoking an employee:

  1. Immediately invalidates the employee's cached permissions.
  2. Terminates all active user sessions (userSessionsRepository.revokeAll), causing the next request from any of their devices to fail authentication (401 Unauthorized).
  3. Revoking also automatically revokes any pending invitations associated with the user's email/mobile number.
  4. Returns the updated ManagedMembership object.

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:

POST /v2/companies/employees/:membershipId/assignments
Content-Type: application/json

Valid body shapes (target must strictly match scope):

{ "roleId": "<company-scope-role-id>", "scopeType": "COMPANY" }
{ "roleId": "<branch-scope-role-id>", "scopeType": "BRANCH", "branchId": "<branch-id>" }
{
  "roleId": "<program-scope-role-id>",
  "scopeType": "PROGRAM",
  "programId": "<program-id>"
}

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:

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

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.
  • OWNER cannot be assigned via this route (403 Forbidden).

7.4 Transfer ownership

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

No body required. Requirements:

  • The caller must hold ownership.transfer (i.e. be an owner).
  • The target membershipId must belong to an active employee of the same company.
  • Atomic mutation: the target employee becomes OWNER and the caller is demoted to COMPANY_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:

  • COMPANY roles can include COMPANY, BRANCH, and PROGRAM permissions.
  • BRANCH roles can include BRANCH and PROGRAM permissions.
  • PROGRAM roles can include PROGRAM permissions only.
  • ownershipOnly permissions (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:

  • name 1–100 characters, unique within the company and scope.
  • description up to 255 characters, optional.
  • scopeType fixes 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 of name, description (nullable — send null to clear), and permissionIds (min 1). At least one field required.
  • System roles (isSystem: true) cannot be edited or deleted.
  • DELETE /:roleId: fails with 409 Conflict ("Remove all role assignments before deleting this role.") while any assignment references the role. The UI should check assignmentCount > 0 and 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> or auction:staff:<auctionId>) are forbidden and return 403 Forbidden ("Use auction.subscribe to join an authorized auction channel.").

Clients must subscribe using the typed auction.subscribe message:

{
  "type": "auction.subscribe",
  "data": {
    "auctionId": "auc_cm7a1b2c3d4e5f6g7h8i9j88"
  }
}

Backend authorization on auction.subscribe:

  1. Verifies that the actor holds auction.read at the program scope for this auction.
  2. Automatically joins the socket to both:
  3. auction:<auctionId> (public participant channel)
  4. auction:staff:<auctionId> (private staff broadcast channel)
  5. 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 (REGISTERED and BILLING) are updated via PATCH /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 Forbidden is returned for company-wide administrative gates (e.g. lacking role.manage on /v2/companies/roles, lacking branch.create on POST /branches, or sending a direct room.join to an auction channel).
  • 404 Not Found is 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 active CompanyMembership.
  • Branch management: Complete UI for create, list/filter, update, address replacement, deactivate/reactivate, and archive with 409 guards.
  • Branch bank accounts: Complete UI for branch-scoped bank accounts under /v2/companies/branches/:branchId/bank-accounts with primary account rules.
  • Program creation step 1: Sends mandatory branchId; enforces isProgramReady client-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, and name. 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 if assignmentCount > 0.
  • WebSocket auction subscription: Connects using active company token; subscribes to auction rooms using typed auction.subscribe (no generic room.join).
  • Dashboard and profile permissions: Hides or disables profile, MOU, and dashboard sections when the employee's effectivePermissions lack required keys.