# WhatsApp Deep-Link Verification Implementation Plan

**Date**: 2026-06-17  
**Status**: 
- **Phase 1** (2026-06-17): Core backend (intents table/model/factory, `POST /api/v1/auth/whatsapp/init`, webhook verification branch + optional secret hardening via `FONNTE_WEBHOOK_SECRET`), frontend rewrite of `/verify-whatsapp` (deep-link button + waiting panel + reuse of `/me` auto-advance), legacy OTP routes kept temporarily (marked deprecated).
- **Phase 2** (2026-06-17): Feature tests in `backend/tests/Feature/WhatsappDeepLinkVerificationTest.php` (initial 8 tests / 36 assertions).
- **Phase 3 – Seamless registration → WhatsApp handoff** (2026-06-17, completed in this continuation):
  - Backend (`RegisterController`): On successful registration we create **both** the legacy OTP row (transition compatibility) **and** a fresh `WhatsappVerificationIntent`. Response now includes `whatsapp_verification` payload (`wa_link`, `phone_masked`, `message_template`, `expires_at`) next to legacy `otp_expires_at`.
  - Frontend `register/page.tsx`: Always sets `pending_whatsapp`. If the new payload is present, also stores it as `pending_whatsapp_verification` in sessionStorage.
  - Frontend `verify-whatsapp/page.tsx` (zero-flash handoff):
    - **Synchronous initial state** (IIFE before any render) reads the sessionStorage transients and initializes `verificationStarted`, `verificationTemplate`, `verificationMasked`, and `phone` in the first `useState` calls. The page paints directly in the "waiting for WhatsApp" UI with the correct data.
    - Dedicated registration effect only performs side-effects (consume transients, open wa.me once, fallback INIT if needed).
    - Verified auto-redirect (from the `/me` check) resets deep-link state and cleans transients.
    - Result: After register the user gets an immediate WhatsApp popup; the verify page is already showing the exact message to send and the "Cek Status" button. After they send (and/or webhook confirms), they are auto-advanced. **No phone form is ever shown** in the happy path.
  - Test added: `test_registration_creates_both_legacy_otp_and_deep_link_intent_and_returns_verification_payload` (now 9 tests / 53 assertions, all green).
- **Current status (as of this continuation)**: The full proposed flow is implemented end-to-end:
  1. User registers → instant deep-link WhatsApp message with unique nonce.
  2. User sends the exact message.
  3. Webhook verifies, marks `whatsapp_verified_at`, replies with return link.
  4. User returns (or the page is open) → authoritative `/me` check → auto-advance to correct destination (invitation / onboarding / dashboard).
  - Already-verified users (including stale localStorage or admin-verified) are instantly redirected on visiting `/verify-whatsapp`.
  - Legacy OTP paths are preserved only for the transition window (documented removal plan in `must-do-in-production.md`).
- **Next (post-transition)**: Full removal of `WhatsappOtp` / legacy routes / table (see production doc for exact steps), additional manual/E2E validation on real devices, final polish of any remaining legacy comments.

See `must-do-in-production.md` for required deploy steps (migrate + new `FONNTE_*` env vars + test command).

See `must-do-in-production.md` for required deploy steps (migrate + new env vars).  
**Owner**: Kilo (following evaluation)  
**Related**:  
- Previous OTP-based flow (WhatsappVerificationController, whatsapp_otps table, Fonnte send)  
- Current webhook (WhatsAppWebhookController)  
- Frontend verify page + recent `/me` auto-redirect fix (frontend/app/verify-whatsapp/page.tsx)  
- Architectural evaluation (security, UX, state, feasibility) provided 2026-06-17  

## 1. Objectives (from user request + evaluation)

- Replace proactive OTP sending with **user-initiated verification**:
  - User enters/confirm phone (optional but recommended for UX), clicks button.
  - Button triggers `https://wa.me/{BUSINESS_NUMBER}?text={prefilled template containing unique nonce}`.
  - User sends the message from their WhatsApp.
  - System (via inbound webhook) receives the message, verifies the interaction (nonce + sender binding to authenticated web user), marks the user as verified (`whatsapp_verified_at` + `whatsapp_number`).
  - System replies via WhatsApp with a URL (or instruction) directing the user back to the application (next step: dashboard / onboarding / invitation).
- **Mandatory**: If a (now-verified) user navigates or returns to `/verify-whatsapp` after completion, the system must **recognize the verified state** (via fresh `/api/v1/me`) and **automatically advance** them to the correct next destination (same logic as successful verification today: invitation → onboarding → dashboard). No form should be shown for already-verified users.

This aligns with the technical evaluation: proof-of-possession via WhatsApp message instead of OTP code delivery.

## 2. High-Level Flow (New)

1. Authenticated user lands on `/verify-whatsapp` (or is redirected by middleware/client error handling).
2. Frontend (new behavior):
   - Immediately calls `/api/v1/me` (already not behind whatsapp_verified middleware).
   - If `whatsapp_verified_at` or `is_admin` → compute destination (invitation/onboarding/dashboard) and `router.push` away. Show minimal "checking" placeholder while doing so.
   - Else: show phone input (pre-filled from profile or previous `pending_whatsapp`), big "Verify via WhatsApp" primary button.
3. On click:
   - POST to new `POST /api/v1/auth/whatsapp/init` (authenticated) with `{ phone: buildFullNumber(...) }`.
   - Backend: create `WhatsappVerificationIntent` (user_id, phone, nonce, expires_at).
   - Return `{ wa_link: 'https://wa.me/628xxx?text=MANABOU-VERIF-ABC123', phone_masked, expires_at, instructions }`.
4. Frontend opens the wa.me link (new tab/window or `window.location` on mobile), transitions UI to "Waiting for WhatsApp confirmation" state:
   - Shows the exact pre-filled text user should send.
   - "I have sent the message" (optional manual poll of `/me` or just rely on user returning).
   - Clear error guidance.
5. User sends the exact message from the correct WhatsApp account to the business number.
6. Webhook receives `{sender, message}`:
   - Parse for verification pattern (e.g. starts with "MANABOU-VERIF-" + nonce).
   - Lookup intent by nonce (unconsumed, not expired).
   - Verify sender (normalized) matches the phone on the intent.
   - On success: update the User (set `whatsapp_number`, `whatsapp_verified_at = now()`), consume intent, log, reply via Fonnte: "✅ WhatsApp verified for Manabou! Return to the app: https://.../verify-whatsapp (or just close this and go back)".
   - On failure (bad nonce, mismatch, expired): reply helpful message, do not verify.
7. User returns to browser (or the link in the reply):
   - Page loads → `/me` check → sees verified flag → auto `router.push` to correct destination.
   - Same post-verification destination logic reused exactly.

Old OTP flow (send/verify/resend) can be kept temporarily behind feature flag or deprecated after migration.

## 3. Security Implications (from evaluation – must be enforced in code)

- **Webhook is currently unauthenticated/public**. This change makes it a verification surface → **harden immediately**.
  - Add optional shared secret: if `config('services.fonnte.webhook_secret')` is set, require `?token=...` or `X-Webhook-Token` header on the webhook route and validate it in the controller (or middleware).
  - Log and reject mismatches. Fallback to IP allow-listing if Fonnte publishes ranges later.
  - Never trust `sender` alone; always require valid nonce + binding.
- Intent model:
  - Nonce: short, high-entropy, single-use, time-bound (15-30 min).
  - Created only for the **currently authenticated** `user_id` (Sanctum).
  - On webhook success: atomic consume + user update.
  - Rate limits: 1 active intent per user, max N per hour (reuse existing patterns from battle invites / OTP TODOs).
- No secrets in the wa.me text (nonce is opaque).
- Reply URL must be to our controlled domain only (no open redirect).
- Preserve existing protections: `EnforceWhatsappVerification` middleware, admin bypass, etc.
- Audit log the verification event (via existing channels or simple Log).

## 4. UX Impact & Requirements

- Keep the beautiful existing page shell (teal branding, grid bg, hero, info badge).
- Replace the "phone step form + Send OTP" with:
  - Same PhoneInput (for confirmation / prefill).
  - Primary CTA: "Open WhatsApp & Send Verification Message" (or similar Japanese/Indonesian bilingual).
  - After click: loading state, then "Waiting..." panel with:
    - Exact text to send (copyable).
    - The business number (for manual search if deep link fails).
    - "Having trouble? Make sure you are sending from the number you entered."
    - "Return to this tab after sending – we will detect it automatically."
- "Ganti nomor" / change number button still works (goes back to phone step).
- Logout button remains.
- On success (via auto-advance or the reply link) reuse existing success banner + redirect timeout if needed, but prefer instant via /me.
- Clear error states: "Message not recognized – check the exact text and that it came from the right number", "Link expired – request a new one", network errors, etc.
- Mobile-first: deep link should feel native.
- The "checking" placeholder (already implemented) is critical so verified users never see the form.

## 5. State Management Requirements

- **New table**: `whatsapp_verification_intents` (or repurpose `whatsapp_otps` conceptually – recommend new table to avoid breaking review/OTP history).
  - `id`, `user_id` (FK cascade delete), `phone` (normalized), `nonce` (string, unique index), `expires_at`, `consumed_at` (nullable), `timestamps`.
  - Indexes: user_id, nonce (unique).
- Model: `WhatsappVerificationIntent` with relations to User, scopes for active/valid, helper `consume()`, `isValid()`.
- User model: no new columns (reuse `whatsapp_number` + `whatsapp_verified_at` as the single source of truth).
- Frontend: rely on existing `localStorage` 'user' + `storage` event + the server-first `/me` check on the verify page (already coded). No new long-lived client tokens needed.
- Intent cleanup: expire on lookup + optional artisan command/cron.
- Webhook must continue to support all existing commands (REVIEW, STOP, HINT, HELP, answer processing) – verification is an additional early parse branch.

## 6. Backend Changes (detailed)

### 6.1 Database
- New migration: `2026_06_17_xxxxxx_create_whatsapp_verification_intents_table.php`
  - Schema matching model above.
- (Optional later) Backfill or drop old `whatsapp_otps` if we fully deprecate OTP path.

### 6.2 Model
- `app/Models/WhatsappVerificationIntent.php`
  - Fillable, casts for dates, `belongsTo(User)`.
  - Methods: `scopeActive()`, `isExpired()`, `consume()` (sets consumed_at and saves).
  - Factory for tests.

### 6.3 New Endpoint (in WhatsappVerificationController or new controller)
- Route (protected by `auth:sanctum`): `POST /api/v1/auth/whatsapp/init`
  - Validate phone (same normalization as before).
  - Rate limit per user.
  - Delete any prior active intents for this user (or mark expired).
  - Generate nonce: e.g. `Str::upper(Str::random(8))` or 6-8 alphanum.
  - Create intent: user_id, phone=normalized, nonce, expires_at=now()+15min.
  - Return JSON:
    ```json
    {
      "wa_link": "https://wa.me/6281234567890?text=MANABOU-VERIF-ABC12345",
      "phone_masked": "0812****345",
      "expires_at": "...",
      "message_template": "MANABOU-VERIF-ABC12345"
    }
    ```
  - Business WA number comes from config (new `services.fonnte.business_number` or dedicated env).

### 6.4 Webhook Updates (WhatsAppWebhookController)
- Early in `handle()` (after basic validation, before user lookup or command parsing):
  - Try to parse verification: if message matches `/^MANABOU-VERIF-([A-Z0-9]{6,12})$/i` → extract nonce.
  - Normalize sender.
  - Find fresh intent by nonce (not consumed, not expired).
  - If intent found:
    - If intent->phone && normalized(sender) !== intent->phone → reply error "Number mismatch...".
    - Else: load the User via intent->user_id (more reliable than phone lookup).
    - Set `$user->whatsapp_number = $sender; $user->whatsapp_verified_at = now(); $user->save();`
    - `$intent->consume();`
    - Log success.
    - Reply: "✅ Verified! Go back to Manabou and refresh the page (or tap: {frontend_url}/verify-whatsapp )."
    - Return 200.
  - If no match / invalid → fall through to existing "find user by whatsapp_number" + command handling (so existing REVIEW etc. unaffected).
- Always normalize phone the same way (reuse the private method from the old OTP controller or centralize).
- Keep using injected `FonnteService` for the reply.

### 6.5 Security Hardening (webhook)
- In `routes/v1/webhooks.php` or controller:
  - Support `?token={secret}` or header.
  - In controller `handle()`:
    ```php
    $secret = config('services.fonnte.webhook_secret');
    if ($secret && $request->query('token') !== $secret) {
        Log::warning('Webhook secret mismatch');
        return response()->json(['status' => 'error'], 403);
    }
    ```
- Add the key to `config/services.php`:
  ```php
  'fonnte' => [
      'token' => env('FONNTE_TOKEN'),
      'webhook_secret' => env('FONNTE_WEBHOOK_SECRET'),
      'business_number' => env('FONNTE_BUSINESS_NUMBER'), // e.g. 6281234567890 (no +)
  ],
  ```
- Update `.env.example` / docs (but do not commit real secrets).
- Document that the webhook URL should be `https://api.../api/v1/webhook/whatsapp?token=...` when secret is configured.

### 6.6 Routes
- Add the new init route in `routes/api.php` (inside the `auth:sanctum` group, alongside the existing whatsapp send/verify/resend for now).
- Keep old routes for backward compat during transition (or guard them).

### 6.7 Controller / Service Cleanup
- `WhatsappVerificationController`: keep old methods for now (mark deprecated in comments). The new `init` can live here or be extracted.
- Centralize phone normalization if possible (already duplicated in a few places).
- Register model in `WhatsappVerificationIntent` and update any factories/tests that touch users.

### 6.8 Other
- Update any places that assumed OTP (e.g. admin UI if any, but probably not).
- Preserve all existing webhook commands and review state.

## 7. Frontend Changes

### 7.1 Constants
- `frontend/constants/api.ts`:
  - Add under `AUTH.WHATSAPP`:
    ```ts
    INIT: '/api/v1/auth/whatsapp/init',
    ```
  - Old SEND/VERIFY/RESEND stay for compat (will be removed in a follow-up).

### 7.2 Verify Page (frontend/app/verify-whatsapp/page.tsx)
- **Keep the recent server-first checking + auto-advance logic** (the big useEffect with `/api/v1/me`, `checking` state, placeholder, early router.push if verified). This already satisfies the "return after complete → auto advance" requirement.
- Replace the phone-step form content:
  - Keep `<PhoneInput>` for entering/confirming the number (pre-fill from profile or sessionStorage pending).
  - Replace the submit button and its handler with:
    - Button: "Buka WhatsApp untuk Verifikasi" (or bilingual "Verify via WhatsApp 確認").
    - On click: call `api.post(API_CONSTANTS.ENDPOINTS.AUTH.WHATSAPP.INIT, { phone: buildFullNumber(...) })`.
    - On success: construct or use the returned `wa_link`, `window.open(wa_link, '_blank')` or appropriate mobile handling.
    - Set a new local state e.g. `verificationStarted: true`, show the waiting panel.
  - Waiting panel (inside the card, when `verificationStarted && !success`):
    - "Pesan telah dikirim ke WhatsApp Anda. Balas dengan teks persis ini: `MANABOU-VERIF-XXXX`"
    - Copyable code block.
    - "Setelah mengirim, kembali ke tab ini. Kami akan mendeteksi secara otomatis."
    - "Belum terverifikasi? Pastikan Anda mengirim dari nomor yang sama dan teks persis."
    - Optional: "Cek status" button that re-calls /me and redirects if now verified.
  - "Ganti nomor" button resets to phone input + clears started state.
- Remove or comment out OTP-specific UI (6-digit grid, resend, countdown, handleOtp* functions) – they can be deleted once old flow is fully removed.
- The OTP step form and all its handlers can be removed in this pass since we are switching the entire initiation model.
- Success state + redirect logic (the part after `handleVerifyOtp` success) can be simplified or kept for the auto-advance path (the /me path already does destination calculation).
- Error display reuse the existing red banner.
- Loading states on the new button.
- Keep navbar hide, logout, grid bg, hero, info badge (update badge text for the new flow: "Klik tombol di bawah untuk membuka WhatsApp dengan pesan verifikasi siap pakai.").

### 7.3 Minor
- No change needed to layout, providers, or other pages (they already respect the verified gate via middleware or the client 403 redirect to VERIFY_WHATSAPP).
- The existing login/google/github callbacks that check `!whatsapp_verified_at` will continue to work and route to this page.

## 8. Implementation Order (this phase)

1. Write this plan + index it (0-init.md).
2. Create migration + model + factory.
3. Add `business_number` + `webhook_secret` to `config/services.php` + document.
4. Implement `POST /.../init` in backend + route.
5. Harden webhook + extend `handle()` for verification parsing + reply.
6. Update API constants (frontend).
7. Revise the verify-whatsapp page.tsx (deep link button + waiting UI, remove OTP grid for this flow).
8. Add any missing normalization helpers if needed.
9. Update old controller comments / deprecation notices.
10. Run `php artisan migrate`, lint (eslint + php-cs or whatever the project uses), tsc.
11. (Future) Remove old OTP code, update tests, admin if any, docs.

## 9. Rollout & Compatibility Notes

- During transition: both flows can coexist (old OTP endpoints still present; new init + webhook branch added). Frontend on this page switches to new UI.
- After verification, everything downstream (middleware, client guards, battle invites, etc.) is unchanged because they only look at `whatsapp_verified_at`.
- The `/me` endpoint already returns the full user (including the flag) – no change required.
- Test matrix (manual + future automated):
  - New user, enter phone, click, send exact text from that WA → verified + auto-redirect.
  - Return to /verify-whatsapp after verified → instant redirect (no form flash).
  - Wrong nonce, wrong number, expired nonce → clear errors + helpful WA reply.
  - Admin user → bypass.
  - Already verified user (stale localStorage) → /me fixes it.
  - Webhook secret enabled/disabled.
- Update any user-facing help text or onboarding copy later.
- **Profile number change re-verification** (completed 2026-06-17):
  - `ProfileController@update` now normalizes the phone (same logic as registration) and, when the WhatsApp number actually changes:
    - Clears `whatsapp_verified_at` (forces re-verification).
    - Deletes any active `WhatsappVerificationIntent` rows for the user (they belong to the old number).
  - Frontend `profile/page.tsx` (Settings tab) detects the change, sets the same `pending_whatsapp` + `pending_whatsapp_verification` transients used by registration, then redirects to `/verify-whatsapp`. The verify page re-uses its existing zero-flash handoff logic → instant wa.me open + waiting panel.
  - Added a helpful hint under the phone input in the profile form.
  - Added two new tests in `WhatsappDeepLinkVerificationTest`:
    - Changing the number clears verification + old intents.
    - Changing other profile fields leaves verification + intents untouched.
  - Legacy `whatsapp_otps` table is deliberately left untouched by this flow (only the deep-link intent matters now).

## 10. Open Questions / Follow-ups (post this phase)

- Exact business number configuration (env vs per-tenant later).
- Whether to keep old OTP send for "I don't want to switch apps" fallback.
- Analytics / success rate tracking for the new channel.
- Rate limiting middleware (if not using Laravel's built-in).
- Full removal of OTP table/code after migration period.
- Unit/feature tests for the new intent + webhook branch.

This plan directly implements the proposed architectural change while addressing every point raised in the technical evaluation (feasibility, security, UX, state management). All code changes must preserve the existing "verified users never get stuck" guarantee and reuse the destination logic.

---

**Next actions after this doc**: Create the migration, model, then the init endpoint + webhook updates, then the frontend page revision. Update todos and 0-init.md immediately.
