Skip to content

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.

  1. Frontend (app/src/pages/auth/SignupPage.tsx) validates phone with /^[6-9]\d{9}$/ (Indian mobile)
  2. Frontend sends POST /auth/public/signup with {name, email, phone, password, orgUID}
  3. Backend (signup.controller.ts → signup.service.ts) validates phone server-side and creates User with phoneVerified: false
  4. 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's plan.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 — see app/src/lib/bundles.ts) then fine-tune individual features within the same module groups the customer sees in the sidebar. Dependencies auto-resolve via toggleFeatureWithDependencies, and the page blocks Save on a validateModuleSelection violation, surfacing the "X requires Y" warning via describeModuleViolations. 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 same FeatureModuleGrid, but with bundles/dependency-validation OFF since KYC restriction is an independent per-feature flag). See KYC.md for the grace-period flow.
  • Numeric knobs: signupCredits, recurringMonthlyCredits, maxConcurrentCalls, maxTeamMembers, and an optional defaultSignupNumber.

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 ​

  1. User logs in via POST /auth/login
  2. Backend returns full user object including phoneVerified, skipPhoneVerification, and phone
  3. Frontend stores user in AuthContext

Step 3: Dashboard Blocks Unverified Users ​

CustomerLayout.tsx determines if verification is needed:

typescript
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) ​

  1. User clicks "Send OTP Call" in the modal
  2. Frontend calls POST /api/verification/send-voice-otp
  3. 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() with outbound_ivr_config for 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:

  1. LiveKit creates a room and dispatches the agent
  2. Agent server detects outbound_ivr_config.enabled = true
  3. Agent mutes audio input (session.input.set_audio_enabled(False)) user cannot interact with AI
  4. Agent suppresses inactivity detection (ctx._ivr_executing = True) no "are you still there?"
  5. Agent pauses background audio
  6. IVR engine speaks the OTP as first_message via session.say() (TTS-only, no LLM involved)
  7. IVR engine then speaks rootNode.prompt (the repeat prompt) and waits for DTMF
  8. If user presses DTMF "1" → IVR repeats the OTP (action: repeat)
  9. If timeout (15s × 2) → call ends
  10. 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 ​

  1. User enters 4-digit code in the modal
  2. Frontend calls POST /api/verification/verify-otp with {otp}
  3. 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
  4. Frontend calls refreshUser() → modal condition re-evaluates → modal unmounts

Step 7: Change Phone Number (Optional) ​

If user entered wrong phone during signup:

  1. User clicks "Change Phone Number" in the modal (available in idle and otp-input states)
  2. Enters new 10-digit number → frontend validates format
  3. Frontend calls PUT /api/verification/update-phone with {phone}
  4. Backend validates format, checks uniqueness, updates User document
  5. Only allowed when phoneVerified === false (once verified, cannot change via this endpoint)
  6. Modal returns to idle state with the new phone number

Bypass Mechanisms ​

Who Skips Phone Verification ​

ConditionHow It's SetUse Case
@insybit.com emailFrontend check onlyInternal team members
skipPhoneVerification: trueSet by org admin when creating customerOrg-managed customers created via dashboard
phoneVerified: trueSet after OTP verificationAlready 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:

json
{
  "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 ​

FileRoleKey Functions
Backend
models/User.tsMongoose schemaphoneVerified, skipPhoneVerification fields
types/verification.types.tsConstants & interfacesVERIFICATION_OTP_PREFIX, PHONE_REGEX, RateLimitCheckResult
services/verification.service.tsCore business logicgenerateSecureOtp(), storeOtp(), getStoredOtp(), deleteOtp(), checkPhoneUniqueness(), buildOtpSpeechText(), dispatchVoiceOtpCall()
services/verification-rate-limiter.service.tsRedis sliding window rate limitcheckOtpRateLimit(), recordOtpAttempt()
controllers/verification.controller.tsHTTP handlerssendVoiceOtp, verifyPhoneOtp, updatePhone
controllers/admin.controller.tsOrg admin handlerscreateCustomer (accepts skipPhoneVerification)
routes/verification.routes.tsRoute definitionsPOST /send-voice-otp, POST /verify-otp, PUT /update-phone
services/signup.service.tsSignup phone validationPhone regex validation in validateInput()
Frontend
pages/auth/SignupPage.tsxSignup formPhone input + validation
components/verification/PhoneVerificationModal.tsxOTP modal UIStates: idle, calling, otp-input, verifying, error, rate-limited, edit-phone
layouts/CustomerLayout.tsxDashboard wrapperModal display logic + bypass checks
types/auth.tsFrontend typesphoneVerified, skipPhoneVerification on User

Data Model ​

User (modified fields) ​

typescript
{
  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 PatternTypeTTLPurpose
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 with retryAfterSeconds), 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:

json
{
  "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:

  1. CallHandler.start_call() detects outbound_ivr_config.enabled = true
  2. Sets ctx._ivr_executing = True → suppresses "are you still there?"
  3. Calls session.input.set_audio_enabled(False) → mutes all audio input (user cannot talk to AI)
  4. Pauses background audio
  5. Resolved first message (the OTP greeting) is spoken via session.say() (TTS only)
  6. IvrMenuEngine executes the root node: speaks prompt, listens for DTMF
  7. DTMF "1" → repeats (action type: repeat)
  8. 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 ​

  1. Signup: Create account with phone 9876543210
  2. Login: Verify response contains phoneVerified: false
  3. Dashboard: Confirm modal appears (non-dismissible)
  4. Send OTP: Click "Send OTP Call" → verify:
    • Redis key verification_otp:9876543210 exists
    • Phone rings, IVR speaks OTP digits clearly (no "dot", no AI conversation)
    • User cannot interact with agent (audio muted)
    • DTMF "1" repeats the code
  5. Change Phone: Click "Change Phone Number" → enter new number → verify user doc updates
  6. Rate limit: 4th attempt returns 429
  7. Verify OTP: Enter correct code → modal closes, dashboard accessible
  8. Bypass tests:
    • Login with @insybit.com → no modal
    • Create customer via admin with skipPhoneVerification: true → no modal on login

API Testing (curl) ​

bash
# 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 ​

SymptomCauseFix
Modal doesn't show after loginUser has skipPhoneVerification: true or phoneVerified: trueCheck user document in DB
TTS says "dot" between digitsUsing . as separator instead of ,Verify buildOtpSpeechText() uses commas
User can talk to AI during OTP calloutbound_ivr_config not set or enabled: falseVerify dispatch includes IVR config
"Are you still there?" during OTPctx._ivr_executing not setIVR engine must set this flag
"No outbound phone number assigned"Org has no phone number assignmentAssign outbound number in admin
OTP call never ringsSIP trunk misconfiguredCheck trunk status, verify LiveKit trunk ID
429 on first attemptStale rate limit entriesDEL verification_rate:{userUID} in Redis
OTP expiredTook >15 minutesOTP TTL is 900s; request new code
409 on verifyPhone used by another same-role userDifferent accounts same role can't share
Modal shows for org-created customerskipPhoneVerification not set during creationAdmin must pass skipPhoneVerification: true

Last updated: