Customer Onboarding
End-to-end flow for customer signup, phone verification via Voice OTP (IVR), and dashboard access control.
Architecture Overview
How It Works
Step 1: Signup with Phone Number
User fills out the signup form which now includes a mandatory phone number field.
- Frontend (
app/src/pages/auth/SignupPage.tsx) validates phone with/^[6-9]\d{9}$/(Indian mobile) - Frontend sends
POST /auth/public/signupwith{name, email, phone, password, orgUID} - Backend (
signup.controller.ts→signup.service.ts) validates phone server-side and creates User withphoneVerified: false - User receives email verification link (existing flow unchanged)
Step 1b: What a new customer is granted (signup config + bundles)
Separate from phone verification, a new customer account's feature entitlements are seeded from the org's signupConfig. Configured by the org admin in Admin → Signup Configuration (app/src/pages/admin/SignupConfigPage.tsx):
signupConfig.defaultFeatures(string[], max 50) — the feature keys assigned to every new signup user, drawn from the org'splan.features. The admin picks these in a grouped, bundle-first picker (FeatureModuleGrid,showBundles): apply a quick-start bundle (Call Only / CRM / Commerce / WhatsApp-Marketing / Full Suite — seeapp/src/lib/bundles.ts) then fine-tune individual features within the same module groups the customer sees in the sidebar. Dependencies auto-resolve viatoggleFeatureWithDependencies, and the page blocks Save on avalidateModuleSelectionviolation, surfacing the "X requires Y" warning viadescribeModuleViolations. An applied bundle is highlighted and clicking it again removes its features (isBundleActive/removeBundleFromSelection).signupConfig.kycRestrictedFeatures(string[]) — features that stay locked until the customer's KYC is verified (grouped with the sameFeatureModuleGrid, but with bundles/dependency-validation OFF since KYC restriction is an independent per-feature flag). SeeKYC.mdfor the grace-period flow.- Numeric knobs:
signupCredits,recurringMonthlyCredits,maxConcurrentCalls,maxTeamMembers, and an optionaldefaultSignupNumber.
Role presets (Org Admin / Manager / Agent / Viewer) are seeded per account owner on creation — see TEAM-RBAC.md. Feature entitlement and RBAC compose: the owner's features gate whether a module exists; a team member's role gates what they can do inside it. The full entitlement model is documented in FEATURE-ENTITLEMENTS-AND-BUNDLES.md.
Step 2: Login Triggers Verification Check
- User logs in via
POST /auth/login - Backend returns full user object including
phoneVerified,skipPhoneVerification, andphone - Frontend stores user in AuthContext
Step 3: Dashboard Blocks Unverified Users
CustomerLayout.tsx determines if verification is needed:
const needsPhoneVerification =
user?.phoneVerified === false && !isInsybitEmail && !user?.skipPhoneVerification;Modal is skipped when ANY of these are true:
phoneVerified === true(already verified)- Email ends with
@insybit.com(internal team) skipPhoneVerification === true(org-created customer, bypassed by admin)
If verification is required → renders PhoneVerificationModal (non-dismissible, z-99 overlay).
Step 4: Voice OTP Call Dispatch (IVR Mode)
- User clicks "Send OTP Call" in the modal
- Frontend calls
POST /api/verification/send-voice-otp - Backend flow:
- Rate limit: Redis sorted set sliding window (max 3 attempts per 10 minutes)
- Generate OTP:
crypto.randomInt(1000, 10000)→ 4-digit string - Store OTP: Redis key
verification_otp:{phone}with 900s TTL - Record attempt: Add timestamp to
verification_rate:{userUID}sorted set - Dispatch call:
createDispatch()withoutbound_ivr_configfor IVR behavior
Step 5: Agent Speaks OTP (IVR Engine No LLM)
The OTP call uses the outbound IVR config system. This is a proper IVR flow:
- LiveKit creates a room and dispatches the agent
- Agent server detects
outbound_ivr_config.enabled = true - Agent mutes audio input (
session.input.set_audio_enabled(False)) user cannot interact with AI - Agent suppresses inactivity detection (
ctx._ivr_executing = True) no "are you still there?" - Agent pauses background audio
- IVR engine speaks the OTP as
first_messageviasession.say()(TTS-only, no LLM involved) - IVR engine then speaks
rootNode.prompt(the repeat prompt) and waits for DTMF - If user presses DTMF "1" → IVR repeats the OTP (action:
repeat) - If timeout (15s × 2) → call ends
- Auto-hangup at 90s regardless
Key difference from regular calls: No LLM is involved. The agent doesn't listen to speech, doesn't process conversation, and doesn't respond. It's pure TTS + DTMF.
Step 6: OTP Verification
- User enters 4-digit code in the modal
- Frontend calls
POST /api/verification/verify-otpwith{otp} - Backend flow:
- Phone uniqueness: Check no other same-role user has this phone verified
- Get stored OTP: Redis lookup (returns null if expired)
- Compare: Exact string match
- Update user:
phoneVerified = true - Cleanup: Delete OTP key from Redis
- Frontend calls
refreshUser()→ modal condition re-evaluates → modal unmounts
Step 7: Change Phone Number (Optional)
If user entered wrong phone during signup:
- User clicks "Change Phone Number" in the modal (available in idle and otp-input states)
- Enters new 10-digit number → frontend validates format
- Frontend calls
PUT /api/verification/update-phonewith{phone} - Backend validates format, checks uniqueness, updates User document
- Only allowed when
phoneVerified === false(once verified, cannot change via this endpoint) - Modal returns to idle state with the new phone number
Bypass Mechanisms
Who Skips Phone Verification
| Condition | How It's Set | Use Case |
|---|---|---|
@insybit.com email | Frontend check only | Internal team members |
skipPhoneVerification: true | Set by org admin when creating customer | Org-managed customers created via dashboard |
phoneVerified: true | Set after OTP verification | Already verified users |
Org Admin Creating Customer (Skip Path)
When an org admin creates a customer from their dashboard (POST /api/admin/customers), they can pass skipPhoneVerification: true:
{
"name": "Customer Name",
"email": "[email protected]",
"phone": "9876543210",
"features": ["voice_campaign"],
"skipPhoneVerification": true
}This sets skipPhoneVerification: true on the User document. When that customer logs in, the verification modal is never shown. They can add/verify phone later from account settings.
File Reference
| File | Role | Key Functions |
|---|---|---|
| Backend | ||
models/User.ts | Mongoose schema | phoneVerified, skipPhoneVerification fields |
types/verification.types.ts | Constants & interfaces | VERIFICATION_OTP_PREFIX, PHONE_REGEX, RateLimitCheckResult |
services/verification.service.ts | Core business logic | generateSecureOtp(), storeOtp(), getStoredOtp(), deleteOtp(), checkPhoneUniqueness(), buildOtpSpeechText(), dispatchVoiceOtpCall() |
services/verification-rate-limiter.service.ts | Redis sliding window rate limit | checkOtpRateLimit(), recordOtpAttempt() |
controllers/verification.controller.ts | HTTP handlers | sendVoiceOtp, verifyPhoneOtp, updatePhone |
controllers/admin.controller.ts | Org admin handlers | createCustomer (accepts skipPhoneVerification) |
routes/verification.routes.ts | Route definitions | POST /send-voice-otp, POST /verify-otp, PUT /update-phone |
services/signup.service.ts | Signup phone validation | Phone regex validation in validateInput() |
| Frontend | ||
pages/auth/SignupPage.tsx | Signup form | Phone input + validation |
components/verification/PhoneVerificationModal.tsx | OTP modal UI | States: idle, calling, otp-input, verifying, error, rate-limited, edit-phone |
layouts/CustomerLayout.tsx | Dashboard wrapper | Modal display logic + bypass checks |
types/auth.ts | Frontend types | phoneVerified, skipPhoneVerification on User |
Data Model
User (modified fields)
{
phone: { type: String, default: null }, // 10-digit Indian mobile
phoneVerified: { type: Boolean, default: false }, // Set true after OTP verification
skipPhoneVerification: { type: Boolean, default: false }, // Set true by org admin to bypass
}Redis Keys
| Key Pattern | Type | TTL | Purpose |
|---|---|---|---|
verification_otp:{phone} | String (4 digits) | 900s (15 min) | Stored OTP value |
verification_rate:{userUID} | Sorted Set (timestamps) | 660s (11 min) | Rate limit tracking |
API Endpoints
Send Voice OTP
- Method:
POST - Path:
/api/verification/send-voice-otp - Auth:
verifyJwt - Body: None (user identified via JWT)
- Success:
200{ success: true, message: "OTP call initiated" } - Errors:
400(no phone),429(rate limited withretryAfterSeconds),500
Verify OTP
- Method:
POST - Path:
/api/verification/verify-otp - Auth:
verifyJwt - Body:
{ "otp": "1234" } - Success:
200{ success: true, message: "Phone number verified successfully" } - Errors:
400(invalid/wrong/expired OTP),409(phone taken by same-role user),500
Update Phone Number
- Method:
PUT - Path:
/api/verification/update-phone - Auth:
verifyJwt - Body:
{ "phone": "9876543210" } - Success:
200{ success: true, message: "Phone number updated successfully", phone: "9876543210" } - Errors:
400(invalid format / already verified),409(phone taken),500
Create Customer (with bypass)
- Method:
POST - Path:
/api/admin/customers - Auth:
verifyJwt+ org admin role - Body includes:
"skipPhoneVerification": true(optional, defaults to false)
External Integrations
LiveKit + SIP (Voice OTP Delivery via IVR)
What's dispatched:
{
"mode": "call",
"agent_id": "otp-verification-agent",
"agent_config": {
"Prompt": "You are an OTP verification system. Do not engage in conversation.",
"first_message": "",
"allow_interruptions": false,
"background_audio": false,
"auto_hangup_time": 90
},
"outbound_ivr_config": {
"enabled": true,
"mode": "interactive",
"routingMode": "dtmf",
"greetingSource": "custom_message",
"rootNode": {
"nodeId": "otp-root",
"prompt": "Your O T P is 1, 5, 0, 7. Press 1 to hear the code again.",
"bindings": { "1": { "type": "repeat" } },
"timeoutSeconds": 15,
"timeoutAction": "connect_agent"
}
}
}What happens on the Agent server:
CallHandler.start_call()detectsoutbound_ivr_config.enabled = true- Sets
ctx._ivr_executing = True→ suppresses "are you still there?" - Calls
session.input.set_audio_enabled(False)→ mutes all audio input (user cannot talk to AI) - Pauses background audio
- Resolved first message (the OTP greeting) is spoken via
session.say()(TTS only) IvrMenuEngineexecutes the root node: speaks prompt, listens for DTMF- DTMF "1" → repeats (action type:
repeat) - Timeout × 2 → proceeds to AI (which auto-hangs up at 90s)
On failure:
- SIP call not answered → agent auto-disconnects
- LiveKit dispatch fails → controller returns 500, user can retry
- Rate limit prevents abuse
Redis (OTP Storage + Rate Limiting)
- Uses existing ioredis singleton (
getRedis()) - No additional infrastructure needed
- Keys auto-expire via TTL
How to Test
Prerequisites
- Backend running with Redis connected
- LiveKit Cloud configured with valid API key/secret
- Organization has at least one outbound SIP trunk + phone number assigned
End-to-End Test Steps
- Signup: Create account with phone
9876543210 - Login: Verify response contains
phoneVerified: false - Dashboard: Confirm modal appears (non-dismissible)
- Send OTP: Click "Send OTP Call" → verify:
- Redis key
verification_otp:9876543210exists - Phone rings, IVR speaks OTP digits clearly (no "dot", no AI conversation)
- User cannot interact with agent (audio muted)
- DTMF "1" repeats the code
- Redis key
- Change Phone: Click "Change Phone Number" → enter new number → verify user doc updates
- Rate limit: 4th attempt returns 429
- Verify OTP: Enter correct code → modal closes, dashboard accessible
- Bypass tests:
- Login with
@insybit.com→ no modal - Create customer via admin with
skipPhoneVerification: true→ no modal on login
- Login with
API Testing (curl)
# Send OTP
curl -X POST http://localhost:3000/api/verification/send-voice-otp \
-H "Authorization: Bearer <token>"
# Verify OTP
curl -X POST http://localhost:3000/api/verification/verify-otp \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"otp":"1234"}'
# Change phone number
curl -X PUT http://localhost:3000/api/verification/update-phone \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"phone":"8765432109"}'
# Create customer with bypass (org admin)
curl -X POST http://localhost:3000/api/admin/customers \
-H "Authorization: Bearer <admin_token>" \
-H "Content-Type: application/json" \
-d '{"name":"Test","email":"[email protected]","skipPhoneVerification":true}'Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| Modal doesn't show after login | User has skipPhoneVerification: true or phoneVerified: true | Check user document in DB |
| TTS says "dot" between digits | Using . as separator instead of , | Verify buildOtpSpeechText() uses commas |
| User can talk to AI during OTP call | outbound_ivr_config not set or enabled: false | Verify dispatch includes IVR config |
| "Are you still there?" during OTP | ctx._ivr_executing not set | IVR engine must set this flag |
| "No outbound phone number assigned" | Org has no phone number assignment | Assign outbound number in admin |
| OTP call never rings | SIP trunk misconfigured | Check trunk status, verify LiveKit trunk ID |
| 429 on first attempt | Stale rate limit entries | DEL verification_rate:{userUID} in Redis |
| OTP expired | Took >15 minutes | OTP TTL is 900s; request new code |
| 409 on verify | Phone used by another same-role user | Different accounts same role can't share |
| Modal shows for org-created customer | skipPhoneVerification not set during creation | Admin must pass skipPhoneVerification: true |