Skip to content

Superadmin company provisioning — frontend integration

This guide describes the complete v2 frontend flow for creating a company from one superadmin form, uploading its optional documents and mandatory MOU, and handing the account over to the company through the existing mobile OTP login.

The form has one user-facing Submit action, but the frontend must orchestrate multiple backend and S3 requests. The workflow is intentionally resumable: after company creation succeeds, retain companyId and retry only the failed upload or assignment step. Do not recreate the company.

1. End-to-end behavior

flowchart TD
    A[Superadmin submits one form] --> B[Create company aggregate]
    B --> C[Save companyId immediately]
    C --> D[Upload and confirm optional files]
    D --> E[Generate MOU version]
    E --> F[PUT PDF directly to S3]
    F --> G[Confirm MOU upload]
    G --> H[Assign exact MOU version]
    H --> I[Show provisioning success]
    I --> J[Company requests OTP on normal login]
    J --> K[Company verifies OTP]
    K --> L[Download and review assigned PDF]
    L --> M[Accept exact MOU version]
    M --> N{requiresSubAccount?}
    N -- No --> O[ACTIVE]
    N -- Yes --> P[UNDER_REVIEW]

Important behavior:

  • Provisioning sends no welcome SMS, email, push notification, or automatic OTP. The company initiates OTP delivery from the normal login screen.
  • The company MOBILE auth provider starts unverified and is verified by the existing OTP flow.
  • A newly provisioned company remains INCOMPLETE until it accepts the assigned MOU.
  • An assigned, unaccepted MOU is an onboarding prompt managed by the frontend; it does not prevent the company from calling other authenticated APIs.
  • Assigning a later MOU version requires acceptance of that version, but does not change an already-onboarded company's profile status.

2. API summary

All superadmin API calls require:

Authorization: Bearer <superadmin-access-token>
Content-Type: application/json
Step Method and path Success
Create aggregate POST /v2/admin/companies 201
Generate optional-file upload POST /v2/admin/companies/:companyId/file-uploads/generate 200
Confirm optional-file upload POST /v2/admin/companies/:companyId/file-uploads/confirm 200
Generate MOU upload POST /v2/admin/companies/:companyId/mou-versions/generate 200
Confirm MOU upload POST /v2/admin/companies/:companyId/mou-versions/:mouVersionId/confirm 200
Assign MOU version POST /v2/admin/companies/:companyId/mou-versions/:mouVersionId/assign 200
Request company login OTP POST /v2/auth/request-otp 200
Verify company login OTP POST /v2/auth/verify-otp 200
Read current assigned MOU GET /v2/companies/me/mou 200
Accept current assigned MOU POST /v2/companies/me/mou/accept 200

Backend responses use the common envelope:

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

Do not branch on human-readable message values. Use the HTTP status, envelope code, and typed data.

3. Form model and validation

Use E.164 mobile numbers in the UI, for example +919876543210. The backend normalizes mobile input, but the company must later log in with the same number.

interface AddressInput {
  line1: string;
  line2?: string;
  pincode: string;
  postOffice: string;
  city: string;
  district: string;
  state: string;
  cityId: number;
  postOfficeId: number;
}

interface CompanyProvisioningInput {
  name: string;
  mobileNumber: string;
  countryCode: string;
  email: string;
  cin: string;
  pan: string;
  registrationNumber: string;
  sroId: number;
  branch: string;
  hasGstin: boolean;
  gstin?: string;
  billingAddress: AddressInput;
  registeredSameAsBillingAddress: boolean;
  registeredAddress?: AddressInput;
  yearOfEstablishment: number;
  noOfOngoingPrograms: number;
  noOfCompletedPrograms: number;
  manager: {
    name: string;
    mobileNumber: string;
    email: string;
    countryCode: string;
    pan: string;
    aadhaarNumber: string;
  };
  requiresSubAccount: boolean;
  bankAccount?: {
    ifsc: string;
    ifscValid: true;
    accountHolder: string;
    accountNumber: string;
    bankName: string;
    branchName: string;
  };
}

Conditional fields

Condition Frontend rule
registeredSameAsBillingAddress === true Omit registeredAddress
registeredSameAsBillingAddress === false Require the complete registeredAddress object
hasGstin === true Require gstin
hasGstin === false Omit gstin
requiresSubAccount === true Require bankAccount; ifscValid must be literal true
requiresSubAccount === false Omit bankAccount; sending it is rejected

Relevant format rules:

  • PAN: five letters, four digits, one letter, for example ABCDE1234F.
  • CIN: standard 21-character CIN, for example U12345KL2020PTC123456.
  • Aadhaar: 12 digits and cannot begin with 0 or 1.
  • Counts must be non-negative integers.
  • Establishment year must be between 1800 and the current year.
  • Address cityId, postOfficeId, and sroId must be positive integers from the corresponding backend-backed selectors.

Do not store raw PAN, Aadhaar, or bank account numbers in logs, analytics, local storage, or error monitoring breadcrumbs. The backend encrypts these values before persistence.

4. Create the company aggregate

POST /v2/admin/companies

Example body for a company that does not require a payment sub-account:

{
  "name": "Example Chits Private Limited",
  "mobileNumber": "+919876543210",
  "countryCode": "IN",
  "email": "accounts@example.com",
  "cin": "U12345KL2020PTC123456",
  "pan": "ABCDE1234F",
  "registrationNumber": "REG-2026-001",
  "sroId": 42,
  "branch": "Kochi Main",
  "hasGstin": true,
  "gstin": "22ABCDE1234F1Z5",
  "billingAddress": {
    "line1": "Building 10, MG Road",
    "pincode": "682016",
    "postOffice": "Ernakulam",
    "city": "Kochi",
    "district": "Ernakulam",
    "state": "Kerala",
    "cityId": 101,
    "postOfficeId": 202
  },
  "registeredSameAsBillingAddress": true,
  "yearOfEstablishment": 2020,
  "noOfOngoingPrograms": 3,
  "noOfCompletedPrograms": 12,
  "manager": {
    "name": "Anita Nair",
    "mobileNumber": "+919876543211",
    "email": "anita@example.com",
    "countryCode": "IN",
    "pan": "FGHIJ5678K",
    "aadhaarNumber": "234567890123"
  },
  "requiresSubAccount": false
}

When requiresSubAccount is true, add:

{
  "bankAccount": {
    "ifsc": "HDFC0001234",
    "ifscValid": true,
    "accountHolder": "Example Chits Private Limited",
    "accountNumber": "123456789012",
    "bankName": "HDFC Bank",
    "branchName": "Kochi"
  }
}

The 201 response is:

{
  "status": "success",
  "code": 201,
  "message": "Company created successfully.",
  "data": {
    "companyId": "cm9company0000000000000001",
    "mobileNumber": "+919876543210",
    "profileStatus": "INCOMPLETE",
    "nextStep": "SIGN_AGREEMENT",
    "requiresSubAccount": false
  },
  "error": null
}

Save data.companyId immediately, before starting uploads. A repeated create request with the same login mobile returns 409; it is not the retry mechanism.

5. Optional company files

Supported company file categories are:

  • AVATAR
  • PAN_CARD
  • REGISTRATION_CERTIFICATE

For each selected file, run generate → S3 PUT → confirm. Files are independent, so they may be processed sequentially or with bounded parallelism.

5.1 Generate an upload URL

POST /v2/admin/companies/:companyId/file-uploads/generate
{
  "fileType": "PAN_CARD",
  "fileName": "company-pan.pdf",
  "contentType": "application/pdf"
}

Response data:

{
  "fileId": "cm9file000000000000000001",
  "signedUrl": "https://s3.example/signed-put"
}

5.2 Upload directly to S3

async function putOptionalFile(signedUrl: string, file: File): Promise<void> {
  const response = await fetch(signedUrl, {
    method: 'PUT',
    headers: { 'Content-Type': file.type },
    body: file
  });

  if (!response.ok) {
    throw new Error(`S3 upload failed with ${response.status}`);
  }
}

Use the exact contentType sent to the generate endpoint. Do not attach the application bearer token to S3 requests.

5.3 Confirm the upload

POST /v2/admin/companies/:companyId/file-uploads/confirm
{ "fileId": "cm9file000000000000000001" }

Confirmation verifies that the object exists, is no larger than 10 MB, and matches its declared type by inspecting its bytes. A fileId generated for a different company is returned as not found.

Only treat an optional file as complete after confirm succeeds. Optional file failure does not prevent MOU upload or invalidate the created company.

6. Mandatory company MOU

The MOU must be a PDF with contentType: "application/pdf" and a .pdf filename. The maximum object size is 10 MB.

6.1 Generate a versioned upload

POST /v2/admin/companies/:companyId/mou-versions/generate
{
  "fileName": "example-company-mou.pdf",
  "contentType": "application/pdf"
}

Response data:

{
  "mouVersionId": "cm9mou0000000000000000001",
  "version": 1,
  "signedUrl": "https://s3.example/signed-put",
  "requiredHeaders": {
    "Content-Type": "application/pdf",
    "If-None-Match": "*"
  }
}

Persist both mouVersionId and version. Each generate call creates a new monotonic version. Reuse the returned signed URL while it is valid. If it expires before the upload completes, generate a new version and continue with the new mouVersionId; the company aggregate is still reused.

6.2 Upload with all required signed headers

The MOU upload is non-overwriting. Send every header returned in requiredHeaders, including If-None-Match:

async function putMou(
  signedUrl: string,
  requiredHeaders: Record<string, string>,
  file: File
): Promise<void> {
  const response = await fetch(signedUrl, {
    method: 'PUT',
    headers: requiredHeaders,
    body: file
  });

  if (!response.ok) {
    throw new Error(`MOU upload failed with ${response.status}`);
  }
}

If the browser reports a CORS error, the S3 bucket CORS policy must allow the PUT, Content-Type, and If-None-Match request headers. Do not remove the conditional header to work around CORS; it is part of the signed request and prevents replacement of an existing version object.

6.3 Confirm the uploaded bytes

POST /v2/admin/companies/:companyId/mou-versions/:mouVersionId/confirm

No body is required. Confirmation downloads and validates the object, records its actual size, and computes SHA-256 on the server.

Example response data:

{
  "mouVersionId": "cm9mou0000000000000000001",
  "version": 1,
  "status": "READY",
  "originalFileName": "example-company-mou.pdf",
  "contentType": "application/pdf",
  "sizeBytes": 248310,
  "sha256": "65e2b9d9c26b8c17c3b87b916ca032fc70d314f4d34c7fe7e39d97f15b9a4d7c",
  "uploadedAt": "2026-09-14T08:30:00.000Z",
  "assignedAt": null,
  "acceptedAt": null
}

Confirm is safe to retry. A successful prior confirmation returns the stored version metadata instead of reprocessing it.

6.4 Assign the exact confirmed version

POST /v2/admin/companies/:companyId/mou-versions/:mouVersionId/assign

No body is required. Only a READY version can be assigned. Assignment makes the document metadata immutable and changes its status to ASSIGNED.

Do not show final provisioning success until this request succeeds. The exact assigned version is what the company will download and accept.

Assignment is safe to retry for the currently assigned version. Assigning a newer version preserves every older version and its acceptance audit.

Keep server identifiers separate from form fields:

interface ProvisioningProgress {
  companyId?: string;
  optionalFiles: Partial<
    Record<
      'AVATAR' | 'PAN_CARD' | 'REGISTRATION_CERTIFICATE',
      { fileId: string; confirmed: boolean }
    >
  >;
  mou?: {
    mouVersionId: string;
    version: number;
    uploaded: boolean;
    confirmed: boolean;
    assigned: boolean;
  };
}

At minimum, preserve this state for the lifetime of the submission screen. If the admin UI supports resuming after reload, persist only nonsensitive server IDs and flags; never persist the form's PAN, Aadhaar, or bank account values.

Recommended retry boundaries:

Failure point Retry action
Create request failed without 201 Fix the error and retry create
Create returned 201, later step failed Reuse companyId; never call create again
Optional generate failed Retry that file's generate request
Optional S3 PUT failed Retry the same signed URL while valid, otherwise generate a new file record
Optional confirm failed Retry confirm with the same fileId after correcting/re-uploading the object
MOU S3 PUT failed before any response Try confirm first because the PUT may have succeeded; if the object is missing, retry the same signed URL while valid
MOU signed URL expired Generate a new MOU version and continue with its new mouVersionId; keep the same companyId
MOU confirm failed Retry confirm for the same mouVersionId
MOU assign failed Retry assign for the same confirmed mouVersionId

Example orchestration skeleton:

async function provisionCompany(
  input: CompanyProvisioningInput,
  files: {
    avatar?: File;
    panCard?: File;
    registrationCertificate?: File;
    mou: File;
  },
  progress: ProvisioningProgress
): Promise<ProvisioningProgress> {
  if (!progress.companyId) {
    const created = await api.post<
      ApiSuccess<{
        companyId: string;
        mobileNumber: string;
        profileStatus: 'INCOMPLETE';
        nextStep: 'SIGN_AGREEMENT';
        requiresSubAccount: boolean;
      }>
    >('/v2/admin/companies', input);
    progress.companyId = created.data.companyId;
    saveProgress(progress);
  }

  await uploadSelectedOptionalFiles(progress.companyId, files, progress);

  if (!progress.mou) {
    const generated = await api.post<
      ApiSuccess<{
        mouVersionId: string;
        version: number;
        signedUrl: string;
        requiredHeaders: Record<string, string>;
      }>
    >(`/v2/admin/companies/${progress.companyId}/mou-versions/generate`, {
      fileName: files.mou.name,
      contentType: 'application/pdf'
    });

    progress.mou = {
      mouVersionId: generated.data.mouVersionId,
      version: generated.data.version,
      uploaded: false,
      confirmed: false,
      assigned: false
    };
    saveProgress(progress);
    await putMou(generated.data.signedUrl, generated.data.requiredHeaders, files.mou);
    progress.mou.uploaded = true;
    saveProgress(progress);
  }

  if (!progress.mou.confirmed) {
    await api.post(
      `/v2/admin/companies/${progress.companyId}/mou-versions/${progress.mou.mouVersionId}/confirm`
    );
    progress.mou.confirmed = true;
    saveProgress(progress);
  }

  if (!progress.mou.assigned) {
    await api.post(
      `/v2/admin/companies/${progress.companyId}/mou-versions/${progress.mou.mouVersionId}/assign`
    );
    progress.mou.assigned = true;
    saveProgress(progress);
  }

  return progress;
}

api.post, saveProgress, and uploadSelectedOptionalFiles are application abstractions in this example, not exported backend SDK functions.

8. Company login and MOU acceptance

The company uses the existing login endpoints. Provisioning itself does not send the OTP.

8.1 Request OTP

POST /v2/auth/request-otp
{
  "mobileNumber": "+919876543210",
  "countryCode": "IN",
  "acceptedTermsAndConditions": true,
  "deviceId": "stable-device-id",
  "deviceName": "Chrome on macOS",
  "platform": "WEB",
  "formFactor": "DESKTOP",
  "otpChannel": "SMS"
}

Keep deviceId stable between request and verify. Verification from another device is rejected.

8.2 Verify OTP

POST /v2/auth/verify-otp
{
  "mobileNumber": "+919876543210",
  "otp": "123456",
  "deviceId": "stable-device-id"
}

For a company with a pending assigned MOU, response data includes:

{
  "userId": "cm9company0000000000000001",
  "role": "COMPANY",
  "isNewSubscriber": false,
  "accessToken": "<access-token>",
  "nextStep": "SIGN_AGREEMENT",
  "onboardingCompleted": false,
  "mouAcceptanceRequired": true,
  "currentMouVersionId": "cm9mou0000000000000000001"
}

The response also sets the existing HTTP-only refresh-token cookie. For browser requests, keep the API client's credential/cookie configuration unchanged.

Routing rule:

if (data.role === 'COMPANY' && data.mouAcceptanceRequired) {
  navigate('/company/mou');
}

currentMouVersionId is present when a current company-specific version exists. Treat mouAcceptanceRequired as the authoritative frontend routing flag. It is not an API authorization restriction.

8.3 Apply the frontend-only MOU gate

When mouAcceptanceRequired is true, make only these experiences available in the company application:

  • View the currently assigned MOU.
  • Accept the currently assigned MOU.
  • Log out.
  • Refresh the authentication session in the background.

Hide the normal company navigation and use a router guard so typing or opening another frontend URL redirects to the MOU screen. Hiding links alone is not sufficient for a consistent user experience.

const MOU_ALLOWED_FRONTEND_ROUTES = new Set(['/company/mou', '/logout']);

function resolveCompanyRoute(
  requestedPath: string,
  mouAcceptanceRequired: boolean
): string {
  if (!mouAcceptanceRequired) return requestedPath;

  return MOU_ALLOWED_FRONTEND_ROUTES.has(requestedPath) ? requestedPath : '/company/mou';
}

Also disable dashboard queries, preloaders, polling, and background mutations while the gate is active. If the application uses a central API client, it may reject unintended company requests before they are sent:

const MOU_ALLOWED_API_REQUESTS = new Set([
  'GET /v2/companies/me/mou',
  'POST /v2/companies/me/mou/accept',
  'POST /v2/auth/logout',
  'POST /v2/auth/refresh'
]);

function canIssueCompanyRequest(
  method: string,
  pathname: string,
  role: string,
  mouAcceptanceRequired: boolean
): boolean {
  if (role !== 'COMPANY' || !mouAcceptanceRequired) return true;
  return MOU_ALLOWED_API_REQUESTS.has(`${method.toUpperCase()} ${pathname}`);
}

Keep this allowlist aligned with the API client's actual refresh-token path. The refresh request must remain available; otherwise an expired access token can prevent the company from viewing or accepting the MOU.

This is deliberately a frontend-only restriction. The backend authenticates and authorizes company requests normally and does not reject other APIs because an MOU is awaiting acceptance. A modified client can bypass the frontend gate; that is acceptable for this workflow because the signed MOU is collected offline and the gate is a guided onboarding experience, not a security or legal enforcement boundary.

On browser reload, restore the persisted login state and call GET /v2/companies/me/mou before rendering company navigation. Use acceptanceRequired and mouVersionId from that response to rebuild the gate. A 404 means no company-specific MOU is assigned, so this frontend gate should be disabled. Do not persist the short-lived downloadUrl.

To discover a newer MOU assigned during an existing session, refetch the current MOU when the application starts and when it regains focus. This avoids a database check on every unrelated company API while keeping the frontend state reasonably current.

8.4 Fetch and display the exact MOU

GET /v2/companies/me/mou
Authorization: Bearer <company-access-token>

Response data includes the immutable metadata, acceptanceRequired, and a short-lived downloadUrl:

{
  "mouVersionId": "cm9mou0000000000000000001",
  "version": 1,
  "status": "ASSIGNED",
  "originalFileName": "example-company-mou.pdf",
  "contentType": "application/pdf",
  "sizeBytes": 248310,
  "sha256": "65e2b9d9c26b8c17c3b87b916ca032fc70d314f4d34c7fe7e39d97f15b9a4d7c",
  "uploadedAt": "2026-09-14T08:30:00.000Z",
  "assignedAt": "2026-09-14T08:31:00.000Z",
  "acceptedAt": null,
  "acceptanceRequired": true,
  "downloadUrl": "https://s3.example/signed-get"
}

Use downloadUrl directly in a PDF viewer or a new browser tab. It expires; fetch the MOU endpoint again instead of caching the signed URL long-term. Show the version and filename next to the acceptance control so the company can see which document it is accepting.

8.5 Accept the current version

Enable the action only after the user checks an explicit acceptance checkbox.

POST /v2/companies/me/mou/accept
Authorization: Bearer <company-access-token>
Content-Type: application/json
{
  "mouVersionId": "cm9mou0000000000000000001",
  "accepted": true
}

The ID must equal the currently assigned version returned by GET/login. The backend records the accepting user, timestamp, request IP, user agent, and the document SHA-256. Repeating acceptance for the same current version is idempotent. A stale or foreign ID is rejected.

On initial onboarding, the response's profileStatus is:

  • ACTIVE when requiresSubAccount is false.
  • UNDER_REVIEW when requiresSubAccount is true.

For acceptance of a later version, profileStatus may be absent because the existing profile status is deliberately preserved. After success, refresh the current-user/profile state and route according to that authoritative state. Clear the frontend MOU gate only after the acceptance request succeeds:

await api.post('/v2/companies/me/mou/accept', {
  mouVersionId: authStore.currentMouVersionId,
  accepted: true
});

authStore.setMouGate({ required: false, versionId: undefined });
navigate('/company/dashboard', { replace: true });

9. Error handling

HTTP status Typical meaning Frontend action
400 Cross-field validation failed, wrong MOU state, stale MOU ID, or unsupported company file category Show field/state error; do not restart provisioning
401 Missing or expired access token Run the existing refresh/login behavior
403 The authenticated user does not have the role required by the endpoint Deny access and route to an appropriate authorized screen
404 Target company/file/MOU or uploaded S3 object not found Verify retained IDs; re-upload the object when confirm reports it missing
409 The login mobile already exists Do not recreate the company; locate and resume the prior provisioning state
413 Uploaded object exceeds 10 MB Require a smaller file and upload again
415 Extension, declared MIME, and detected bytes do not agree Require a genuine supported file; renaming an extension is insufficient

Validation errors may include a field path. Map that path back to the form when possible, but retain a form-level fallback for transaction or state errors.

Do not automatically retry 400, 401, 403, 409, 413, or 415. Network failures and transient 5xx failures may use bounded retries with backoff, but the orchestration state must prevent replaying already-completed steps.

The direct conditional MOU PUT can return S3 412 Precondition Failed when an object already exists at the signed key. Call the backend confirm endpoint for that mouVersionId: if confirm succeeds, continue to assignment; if it reports the object missing and the URL cannot be reused, generate a new MOU version.

10. UI completion checklist

Before showing Company provisioned successfully, verify all of the following:

  • Company create returned 201 and companyId was retained.
  • Every selected optional file completed generate, S3 PUT, and confirm.
  • The mandatory MOU is a PDF no larger than 10 MB.
  • MOU generate returned mouVersionId and required S3 headers.
  • S3 PUT included both Content-Type and If-None-Match.
  • MOU confirm returned READY with non-null sizeBytes and sha256.
  • Assign succeeded for that same mouVersionId.
  • The UI did not claim that an OTP or welcome message was sent.
  • Sensitive form fields were cleared after the terminal success state.

For the company login UI:

  • Request and verify OTP use the provisioned company mobile and the same deviceId.
  • mouAcceptanceRequired routes the company to the MOU screen.
  • Normal company navigation, queries, and mutations are disabled while the frontend MOU gate is active.
  • MOU viewing, MOU acceptance, logout, and authentication refresh remain available while the gate is active.
  • Application startup and window focus refetch the current MOU to detect a newly assigned version.
  • The PDF comes from the current MOU response's short-lived downloadUrl.
  • Accept submits the exact displayed mouVersionId and literal accepted: true.
  • Initial acceptance routes ACTIVE companies onward and explains the review state to UNDER_REVIEW companies.