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
INCOMPLETEuntil 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:
| 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:
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
0or1. - Counts must be non-negative integers.
- Establishment year must be between 1800 and the current year.
- Address
cityId,postOfficeId, andsroIdmust 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¶
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:
AVATARPAN_CARDREGISTRATION_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¶
Response data:
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¶
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¶
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¶
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¶
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.
7. Recommended resumable submission state¶
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¶
{
"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¶
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:
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¶
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
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:
ACTIVEwhenrequiresSubAccountisfalse.UNDER_REVIEWwhenrequiresSubAccountistrue.
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
201andcompanyIdwas 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
mouVersionIdand required S3 headers. - S3 PUT included both
Content-TypeandIf-None-Match. - MOU confirm returned
READYwith non-nullsizeBytesandsha256. - 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. -
mouAcceptanceRequiredroutes 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
mouVersionIdand literalaccepted: true. - Initial acceptance routes
ACTIVEcompanies onward and explains the review state toUNDER_REVIEWcompanies.