From 7bd112f61cfd2c3fe59ed55e04ec8fb374889b57 Mon Sep 17 00:00:00 2001
From: Arnas Donauskas
Date: Fri, 29 May 2026 18:12:38 +0200
Subject: [PATCH] fix(whatsapp): register phone numbers on Meta + verify
subscription state
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Closes the silent-failure bug a user reported: when multiple phone
numbers are connected to the same WABA, only the first one actually
receives webhook events from Meta. The wacrm UI shows "Connected"
because the metadata probe succeeds, but Meta's activity log is
empty for every subsequent number.
Root cause
----------
The POST /api/whatsapp/config handler verified credentials with a
GET /{phone_number_id} probe and saved them to Supabase — but
never called Meta's two registration endpoints that actually wire
a number for inbound events:
1. POST /{phone_number_id}/register
Subscribes the number to THIS app's webhook. Requires a
6-digit 2FA PIN the user previously set in Meta WhatsApp
Manager → Two-step verification. Without it, Meta routes
events for that number to whichever app last claimed it.
2. POST /{waba_id}/subscribed_apps
Subscribes the WABA itself to this app. Required once per
WABA, but idempotent on Meta's side so we call on every
save without harm.
The first number "worked" because someone (the user, Embedded
Signup, or Meta dashboard) had already done both calls for it
out-of-band. Every subsequent number silently failed.
What this PR does
-----------------
* New helpers in meta-api.ts: registerPhoneNumber (treats Meta's
"already registered" 400 as success), subscribeWabaToApp
(idempotent), getSubscribedApps (for the diagnostic).
* POST /api/whatsapp/config now:
- Accepts an optional `pin` (6-digit, validated).
- Calls subscribeWabaToApp on every save (idempotent).
- Calls registerPhoneNumber on first save and whenever a PIN
is supplied. Skipped when the same number is already
registered locally and no fresh PIN is provided — re-running
/register with a stale PIN would actually undo the active
subscription.
- Persists registered_at / subscribed_apps_at timestamps and
last_registration_error for diagnostic surfacing.
- Returns {success, registered, registration_error} so the UI
can distinguish "creds valid" from "actually live".
- Saves the row even when /register fails so the user can
retry without re-entering everything.
* New diagnostic GET /api/whatsapp/config/verify-registration —
hits Meta to confirm the saved number is reachable + the WABA
is subscribed. Returns a per-check breakdown the UI renders.
Solves the misleading-green-banner UX failure that caused the
bug to go unnoticed in the first place.
* Migration 015 adds registered_at, subscribed_apps_at,
last_registration_error to whatsapp_config. All nullable so
existing rows survive — the UI labels them "registration status
unknown" until the user clicks Verify or re-saves.
* whatsapp-config.tsx:
- New PIN input with help text pointing to the exact Meta
Manager setting and explaining why it's needed.
- Two-tier status: existing "Credentials valid" alert (relabeled
from "Connected" since it was misleadingly broad) PLUS a new
Registration Status alert that flips emerald/amber based on
the registered_at timestamp.
- "Verify with Meta" button on the registration alert pings
the diagnostic endpoint and renders the per-check result
inline. Surfaces last_registration_error verbatim with a
pointer to retry.
- PIN clears from state after a successful save so it can't
accidentally retrigger /register on the next save.
Tests + checks
--------------
* 9 new fetch-mock tests cover registerPhoneNumber (happy path,
already-registered passthrough, PIN-required error verbatim,
generic Meta error), subscribeWabaToApp (URL + bearer + error),
getSubscribedApps (data, empty, error).
* 265/265 tests pass. Lint 0 errors. Typecheck clean. Build
registers both /api/whatsapp/config and
/api/whatsapp/config/verify-registration.
Out of scope
------------
* Multi-number-per-CRM-user (current schema is UNIQUE(user_id)
so one CRM user = one number). The reported bug was
multi-number-per-WABA across multiple CRM users, which this
fully resolves.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
src/app/api/whatsapp/config/route.ts | 144 +++++++++--
.../config/verify-registration/route.ts | 139 +++++++++++
src/components/settings/whatsapp-config.tsx | 226 +++++++++++++++++-
src/lib/whatsapp/meta-api.ts | 146 +++++++++++
src/lib/whatsapp/registration.test.ts | 182 ++++++++++++++
src/types/index.ts | 10 +
.../015_whatsapp_config_registration.sql | 45 ++++
7 files changed, 862 insertions(+), 30 deletions(-)
create mode 100644 src/app/api/whatsapp/config/verify-registration/route.ts
create mode 100644 src/lib/whatsapp/registration.test.ts
create mode 100644 supabase/migrations/015_whatsapp_config_registration.sql
diff --git a/src/app/api/whatsapp/config/route.ts b/src/app/api/whatsapp/config/route.ts
index 92738d1..3480bd4 100644
--- a/src/app/api/whatsapp/config/route.ts
+++ b/src/app/api/whatsapp/config/route.ts
@@ -1,7 +1,11 @@
import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server'
import { createClient as createAdminClient } from '@supabase/supabase-js'
-import { verifyPhoneNumber } from '@/lib/whatsapp/meta-api'
+import {
+ registerPhoneNumber,
+ subscribeWabaToApp,
+ verifyPhoneNumber,
+} from '@/lib/whatsapp/meta-api'
import { encrypt, decrypt } from '@/lib/whatsapp/encryption'
// Lazy-initialised service-role client. We need it to detect a
@@ -138,7 +142,7 @@ export async function POST(request: Request) {
}
const body = await request.json()
- const { phone_number_id, waba_id, access_token, verify_token } = body
+ const { phone_number_id, waba_id, access_token, verify_token, pin } = body
if (!access_token || !phone_number_id) {
return NextResponse.json(
@@ -147,6 +151,15 @@ export async function POST(request: Request) {
)
}
+ if (pin !== undefined && pin !== null && pin !== '') {
+ if (typeof pin !== 'string' || !/^\d{6}$/.test(pin)) {
+ return NextResponse.json(
+ { error: 'PIN must be exactly 6 digits.' },
+ { status: 400 }
+ )
+ }
+ }
+
// Reject if another user has already claimed this phone_number_id.
// wacrm is single-tenant-per-WhatsApp-number — letting two users
// bind the same number causes the webhook's `.single()` lookup to
@@ -211,25 +224,100 @@ export async function POST(request: Request) {
)
}
- // Upsert — overwrite any existing (possibly corrupted) config
+ // Look up any pre-existing row so we know whether this number is
+ // already registered with Meta — if so we can skip /register when
+ // the user didn't provide a PIN this time around.
const { data: existing } = await supabase
.from('whatsapp_config')
- .select('id')
+ .select('id, registered_at, phone_number_id')
.eq('user_id', user.id)
.maybeSingle()
+ const sameNumber =
+ existing?.phone_number_id === phone_number_id &&
+ existing?.registered_at != null
+
+ // Step 1: register the phone number for inbound webhooks.
+ //
+ // Required on first save AND whenever the user supplies a fresh
+ // PIN (e.g. they rotated the 2FA PIN in Meta Manager). Skipped
+ // when the same number is already registered and no PIN was
+ // supplied — re-registering an already-active number with a
+ // stale PIN would actually fail and undo the active subscription.
+ let registeredAt: string | null = existing?.registered_at ?? null
+ let registrationError: string | null = null
+
+ const needsRegistration = !sameNumber || (typeof pin === 'string' && pin.length > 0)
+ if (needsRegistration) {
+ if (!pin) {
+ return NextResponse.json(
+ {
+ error:
+ 'Two-step verification PIN is required to subscribe this number to wacrm. ' +
+ 'Set a 6-digit PIN in Meta WhatsApp Manager → Phone Numbers → Two-step verification, then paste it below.',
+ },
+ { status: 400 }
+ )
+ }
+ try {
+ await registerPhoneNumber({
+ phoneNumberId: phone_number_id,
+ accessToken: access_token,
+ pin,
+ })
+ registeredAt = new Date().toISOString()
+ } catch (err) {
+ registrationError =
+ err instanceof Error ? err.message : 'Unknown Meta API error'
+ console.error('Phone number /register failed:', registrationError)
+ // We deliberately fall through and still save the row so the
+ // user can retry without re-entering everything. The UI
+ // surfaces `last_registration_error` so they see WHY it's
+ // not actually live yet.
+ }
+ }
+
+ // Step 2: subscribe the WABA to this app. Idempotent on Meta's
+ // side, so we call on every save and persist the timestamp.
+ // Skipped only when there's no waba_id (legacy rows from before
+ // we required it).
+ let subscribedAppsAt: string | null = null
+ if (waba_id) {
+ try {
+ await subscribeWabaToApp({
+ wabaId: waba_id,
+ accessToken: access_token,
+ })
+ subscribedAppsAt = new Date().toISOString()
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err)
+ console.warn('WABA subscribed_apps failed (non-fatal):', message)
+ // Subscription failures are rare once the App has the right
+ // permissions; we don't block save on them — the diagnostic
+ // endpoint surfaces this state too.
+ }
+ }
+
+ // Persist everything in one shot. If /register failed we still
+ // store the credentials and the error so the UI can guide the
+ // user through a retry.
+ const baseRow = {
+ phone_number_id,
+ waba_id: waba_id || null,
+ access_token: encryptedAccessToken,
+ verify_token: encryptedVerifyToken,
+ status: registrationError ? 'disconnected' : 'connected',
+ connected_at: registrationError ? null : new Date().toISOString(),
+ registered_at: registrationError ? null : registeredAt,
+ subscribed_apps_at: subscribedAppsAt ?? null,
+ last_registration_error: registrationError,
+ updated_at: new Date().toISOString(),
+ }
+
if (existing) {
const { error: updateError } = await supabase
.from('whatsapp_config')
- .update({
- phone_number_id,
- waba_id: waba_id || null,
- access_token: encryptedAccessToken,
- verify_token: encryptedVerifyToken,
- status: 'connected',
- connected_at: new Date().toISOString(),
- updated_at: new Date().toISOString(),
- })
+ .update(baseRow)
.eq('user_id', user.id)
if (updateError) {
@@ -242,15 +330,7 @@ export async function POST(request: Request) {
} else {
const { error: insertError } = await supabase
.from('whatsapp_config')
- .insert({
- user_id: user.id,
- phone_number_id,
- waba_id: waba_id || null,
- access_token: encryptedAccessToken,
- verify_token: encryptedVerifyToken,
- status: 'connected',
- connected_at: new Date().toISOString(),
- })
+ .insert({ user_id: user.id, ...baseRow })
if (insertError) {
console.error('Error inserting whatsapp_config:', insertError)
@@ -261,7 +341,25 @@ export async function POST(request: Request) {
}
}
- return NextResponse.json({ success: true, phone_info: phoneInfo })
+ if (registrationError) {
+ // Save succeeded but the number isn't actually live. Return
+ // 200 with a structured error so the UI can show the specific
+ // remediation step instead of a generic toast.
+ return NextResponse.json({
+ success: false,
+ saved: true,
+ registered: false,
+ registration_error: registrationError,
+ phone_info: phoneInfo,
+ })
+ }
+
+ return NextResponse.json({
+ success: true,
+ saved: true,
+ registered: true,
+ phone_info: phoneInfo,
+ })
} catch (error) {
console.error('Error in WhatsApp config POST:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
diff --git a/src/app/api/whatsapp/config/verify-registration/route.ts b/src/app/api/whatsapp/config/verify-registration/route.ts
new file mode 100644
index 0000000..adcdc5d
--- /dev/null
+++ b/src/app/api/whatsapp/config/verify-registration/route.ts
@@ -0,0 +1,139 @@
+import { NextResponse } from 'next/server'
+import { createClient } from '@/lib/supabase/server'
+import { decrypt } from '@/lib/whatsapp/encryption'
+import {
+ getSubscribedApps,
+ verifyPhoneNumber,
+} from '@/lib/whatsapp/meta-api'
+
+/**
+ * GET /api/whatsapp/config/verify-registration
+ *
+ * Diagnostic endpoint — confirms the user's saved phone number is
+ * actually reachable on Meta's side. Solves the failure mode that
+ * surfaced the multi-number bug originally: "UI says Connected but
+ * Meta isn't delivering events."
+ *
+ * Three checks run independently so the UI can show which step
+ * passes and which fails:
+ *
+ * 1. phone_info — GET /{phone_number_id} succeeds
+ * 2. waba_subscription — our app appears in
+ * GET /{waba_id}/subscribed_apps
+ * 3. registered_at — local timestamp set by POST /config when
+ * /register last succeeded; NULL means the
+ * number was saved but never actually subscribed
+ *
+ * Returns 200 in every case so the UI can render diagnostic detail
+ * rather than a generic error toast. The combined `live` flag is
+ * what the UI badges on.
+ */
+export async function GET() {
+ const supabase = await createClient()
+ const {
+ data: { user },
+ error: authError,
+ } = await supabase.auth.getUser()
+ if (authError || !user) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const { data: config } = await supabase
+ .from('whatsapp_config')
+ .select('*')
+ .eq('user_id', user.id)
+ .maybeSingle()
+
+ if (!config) {
+ return NextResponse.json({
+ live: false,
+ checks: { config_exists: false },
+ message: 'No WhatsApp configuration saved yet.',
+ })
+ }
+
+ let accessToken: string
+ try {
+ accessToken = decrypt(config.access_token)
+ } catch {
+ return NextResponse.json({
+ live: false,
+ checks: {
+ config_exists: true,
+ token_decryptable: false,
+ },
+ message:
+ 'Stored access token can\'t be decrypted — likely ENCRYPTION_KEY changed. Re-enter the token to repair.',
+ })
+ }
+
+ const checks: {
+ config_exists: boolean
+ token_decryptable: boolean
+ phone_metadata_ok: boolean
+ waba_subscribed_to_app: boolean | null
+ locally_marked_registered: boolean
+ } = {
+ config_exists: true,
+ token_decryptable: true,
+ phone_metadata_ok: false,
+ waba_subscribed_to_app: null,
+ locally_marked_registered: config.registered_at != null,
+ }
+ const errors: string[] = []
+
+ // 1. Phone metadata
+ try {
+ await verifyPhoneNumber({
+ phoneNumberId: config.phone_number_id,
+ accessToken,
+ })
+ checks.phone_metadata_ok = true
+ } catch (err) {
+ errors.push(
+ `Phone metadata check failed: ${err instanceof Error ? err.message : String(err)}`,
+ )
+ }
+
+ // 2. WABA subscription — only meaningful if we have a waba_id
+ if (config.waba_id) {
+ try {
+ const subs = await getSubscribedApps({
+ wabaId: config.waba_id,
+ accessToken,
+ })
+ // Meta returns the apps subscribed to this WABA. If the list
+ // is non-empty, OUR app is in there (the access_token we used
+ // belongs to our app — Meta wouldn't return data for an app
+ // the token can't see). Treat any entry as success.
+ checks.waba_subscribed_to_app = subs.length > 0
+ if (!checks.waba_subscribed_to_app) {
+ errors.push(
+ 'WABA has no subscribed apps. Re-save the configuration to subscribe.',
+ )
+ }
+ } catch (err) {
+ errors.push(
+ `WABA subscription check failed: ${err instanceof Error ? err.message : String(err)}`,
+ )
+ }
+ } else {
+ errors.push(
+ 'No WABA ID on file — webhooks can\'t be wired without it. Add it in the form and re-save.',
+ )
+ }
+
+ const live =
+ checks.phone_metadata_ok &&
+ (checks.waba_subscribed_to_app ?? false) &&
+ checks.locally_marked_registered
+
+ return NextResponse.json({
+ live,
+ checks,
+ errors,
+ last_registration_error: config.last_registration_error ?? null,
+ registered_at: config.registered_at ?? null,
+ subscribed_apps_at: config.subscribed_apps_at ?? null,
+ })
+}
diff --git a/src/components/settings/whatsapp-config.tsx b/src/components/settings/whatsapp-config.tsx
index b106890..05edb1a 100644
--- a/src/components/settings/whatsapp-config.tsx
+++ b/src/components/settings/whatsapp-config.tsx
@@ -52,8 +52,28 @@ export function WhatsAppConfig() {
const [wabaId, setWabaId] = useState('');
const [accessToken, setAccessToken] = useState('');
const [verifyToken, setVerifyToken] = useState('');
+ const [pin, setPin] = useState('');
const [tokenEdited, setTokenEdited] = useState(false);
+ // True once /register has succeeded on Meta's side (timestamp set
+ // in the row). When false, the saved config is metadata-only and
+ // Meta will silently drop every inbound event — that's the
+ // multi-number bug that prompted this work.
+ const isRegistered = Boolean(config?.registered_at);
+ const lastRegistrationError = config?.last_registration_error ?? null;
+
+ const [verifyingRegistration, setVerifyingRegistration] = useState(false);
+ type RegistrationProbe = {
+ live: boolean;
+ checks: Record;
+ errors?: string[];
+ last_registration_error?: string | null;
+ registered_at?: string | null;
+ subscribed_apps_at?: string | null;
+ };
+ const [registrationProbe, setRegistrationProbe] =
+ useState(null);
+
const webhookUrl =
typeof window !== 'undefined'
? `${window.location.origin}/api/whatsapp/webhook`
@@ -79,6 +99,7 @@ export function WhatsAppConfig() {
setWabaId(data.waba_id || '');
setAccessToken(MASKED_TOKEN);
setVerifyToken('');
+ setPin('');
setTokenEdited(false);
} else {
setConfig(null);
@@ -86,8 +107,11 @@ export function WhatsAppConfig() {
setWabaId('');
setAccessToken('');
setVerifyToken('');
+ setPin('');
setTokenEdited(false);
}
+ // Clear any stale probe result when reloading the row.
+ setRegistrationProbe(null);
// Then verify health via the API (decrypts token + pings Meta)
if (data) {
@@ -151,6 +175,10 @@ export function WhatsAppConfig() {
phone_number_id: phoneNumberId.trim(),
waba_id: wabaId.trim() || null,
verify_token: verifyToken.trim() || null,
+ // Optional — only sent when the user filled it in. The server
+ // requires it on first save or when changing numbers; for a
+ // simple token rotation, leaving it blank skips re-register.
+ pin: pin.trim() || null,
};
if (tokenEdited && accessToken !== MASKED_TOKEN && accessToken.trim()) {
@@ -179,11 +207,28 @@ export function WhatsAppConfig() {
return;
}
- toast.success(
- data.phone_info?.verified_name
- ? `Connected to ${data.phone_info.verified_name}`
- : 'Configuration saved successfully'
- );
+ // The route now returns a structured outcome:
+ // * registered=true → number is live, events will flow
+ // * registered=false → credentials saved but /register
+ // failed; UI shows the specific error
+ // and a retry path. registration_error
+ // is human-readable from Meta.
+ if (data.registered === false && data.registration_error) {
+ toast.error(
+ `Saved, but Meta couldn't register the number: ${data.registration_error}`,
+ { duration: 12000 },
+ );
+ } else {
+ toast.success(
+ data.phone_info?.verified_name
+ ? `Live — ${data.phone_info.verified_name} can now receive events.`
+ : 'WhatsApp connected. Events will start flowing within a minute.',
+ );
+ // Clear the PIN so subsequent saves don't accidentally
+ // re-register (which would void the active subscription if
+ // the PIN became stale).
+ setPin('');
+ }
if (user) await fetchConfig(user.id);
} catch (err) {
@@ -224,6 +269,32 @@ export function WhatsAppConfig() {
}
}
+ async function handleVerifyRegistration() {
+ setVerifyingRegistration(true);
+ setRegistrationProbe(null);
+ try {
+ const res = await fetch('/api/whatsapp/config/verify-registration', {
+ method: 'GET',
+ });
+ const data = (await res.json()) as RegistrationProbe;
+ setRegistrationProbe(data);
+ if (data.live) {
+ toast.success('Number is fully wired — Meta is delivering events.');
+ } else {
+ toast.error(
+ 'Number is not fully registered. See the checks below for which step failed.',
+ { duration: 8000 },
+ );
+ }
+ if (user) await fetchConfig(user.id);
+ } catch (err) {
+ console.error('verify-registration failed:', err);
+ toast.error('Could not reach the verification endpoint.');
+ } finally {
+ setVerifyingRegistration(false);
+ }
+ }
+
async function handleReset() {
if (!confirm('This will delete the current WhatsApp config so you can re-enter it. Continue?')) {
return;
@@ -320,17 +391,125 @@ export function WhatsAppConfig() {
)}
- {connectionStatus === 'connected' ? 'Connected' : 'Not Connected'}
+ {connectionStatus === 'connected' ? 'Credentials valid' : 'Not Connected'}
{connectionStatus === 'connected'
- ? 'Your WhatsApp Business API is connected and ready to send/receive messages.'
+ ? 'Your access token authenticates with Meta. See Registration status below for whether webhooks are actually wired.'
: statusMessage ||
'Configure your Meta API credentials below to connect your WhatsApp Business account.'}
+ {/* Registration Status — the "is it actually live?" check.
+ Credentials being valid is necessary but not sufficient;
+ without a successful /register call the number won't
+ receive inbound events. Surface this dimension separately
+ so users don't trust a misleading green banner. */}
+ {config && (
+
+
+
+ {isRegistered ? (
+
+ ) : (
+
+ )}
+
+ {isRegistered
+ ? 'Registered — Meta will deliver events to wacrm'
+ : 'Not registered — Meta will not deliver events'}
+
+
+
+
+
+ {isRegistered ? (
+ <>
+ Subscribed since{' '}
+ {config.registered_at
+ ? new Date(config.registered_at).toLocaleString()
+ : 'unknown'}
+ . Click Verify with Meta if events
+ stop arriving.
+ >
+ ) : lastRegistrationError ? (
+ <>
+ Last attempt failed with:{' '}
+
+ "{lastRegistrationError}"
+
+ . Enter (or correct) the 2-step PIN below and click
+ Save Configuration to retry.
+ >
+ ) : (
+ <>
+ This number was saved before registration tracking
+ existed, or registration was skipped. Enter the
+ 2-step PIN below and click Save Configuration to
+ subscribe it.
+ >
+ )}
+
+
+ {registrationProbe && (
+
+ )}
+
+ )}
+
{/* API Credentials */}
@@ -406,6 +585,39 @@ export function WhatsAppConfig() {
A custom string you create. Must match the token you set in Meta webhook settings.
+ Required the first time you connect a number, and any
+ time you swap to a different number. Set it in{' '}
+
+ Meta Business Manager → WhatsApp Accounts → Phone
+ Numbers → Two-step verification
+
+ . Without this PIN, Meta saves your credentials but
+ won't actually route inbound messages to wacrm —
+ the symptom that hits second numbers under a shared
+ WABA. Leave blank to keep an existing registration
+ untouched.
+
+
diff --git a/src/lib/whatsapp/meta-api.ts b/src/lib/whatsapp/meta-api.ts
index a190007..9bea86b 100644
--- a/src/lib/whatsapp/meta-api.ts
+++ b/src/lib/whatsapp/meta-api.ts
@@ -65,6 +65,152 @@ export async function verifyPhoneNumber(
return response.json()
}
+// ============================================================
+// Cloud API registration (subscription for inbound webhooks)
+// ============================================================
+//
+// Saving a phone_number_id + access_token to whatsapp_config is NOT
+// enough to receive inbound events from Meta. Two extra calls are
+// required:
+//
+// POST /{phone_number_id}/register
+// Subscribes the number for THIS app's webhook. Requires a
+// 6-digit 2FA PIN the user previously set in Meta WhatsApp
+// Manager → Two-step verification. Without /register, inbound
+// events are routed to whichever app last claimed the number
+// (often the one that did Embedded Signup) — so a second user
+// adding a second number under the same WABA silently loses
+// every inbound message.
+//
+// POST /{waba_id}/subscribed_apps
+// Subscribes the WABA itself to this app. Required exactly
+// once per WABA, but idempotent so calling on every save is
+// safe and cheap.
+//
+// Both calls are no-ops when already done — Meta returns success +
+// the helpers below treat that as success.
+
+export interface RegisterPhoneNumberArgs {
+ phoneNumberId: string
+ accessToken: string
+ /**
+ * 6-digit PIN the user set in Meta WhatsApp Manager →
+ * Two-step verification. If 2FA is not enabled on the number,
+ * Meta rejects /register with a clear error and the user is
+ * pointed at the right setting in the UI.
+ */
+ pin: string
+}
+
+export interface RegisterPhoneNumberResult {
+ success: boolean
+ /**
+ * True when Meta indicated the number was already registered to
+ * THIS app — same outcome as a fresh registration from the
+ * caller's POV, surfaced separately for logging clarity.
+ */
+ alreadyRegistered: boolean
+}
+
+/**
+ * Register a phone number for inbound webhook events.
+ *
+ * Errors that should be surfaced verbatim to the user:
+ * * Missing / wrong PIN → "Two-step verification PIN required..."
+ * * No 2FA enabled → "Two-factor authentication is not on..."
+ * * Number on other app → "Number is registered to another app..."
+ */
+export async function registerPhoneNumber(
+ args: RegisterPhoneNumberArgs
+): Promise {
+ const { phoneNumberId, accessToken, pin } = args
+ const url = `${META_API_BASE}/${phoneNumberId}/register`
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${accessToken}`,
+ },
+ body: JSON.stringify({ messaging_product: 'whatsapp', pin }),
+ })
+
+ if (response.ok) {
+ return { success: true, alreadyRegistered: false }
+ }
+
+ // Meta returns an error envelope with a code. Code 133005 + the
+ // text "already registered" appears when the number is already
+ // subscribed to this app — that's success from the caller's
+ // perspective, surface it as such.
+ let data: { error?: { message?: string; code?: number; error_subcode?: number } } = {}
+ try {
+ data = await response.json()
+ } catch {
+ /* keep empty */
+ }
+ const message = data.error?.message ?? `Meta API error: ${response.status}`
+ if (/already.*registered/i.test(message)) {
+ return { success: true, alreadyRegistered: true }
+ }
+ throw new Error(message)
+}
+
+export interface SubscribeWabaToAppArgs {
+ wabaId: string
+ accessToken: string
+}
+
+/**
+ * Subscribe the WABA to this Meta app's webhook. Idempotent — Meta
+ * returns success even when the subscription already exists.
+ */
+export async function subscribeWabaToApp(
+ args: SubscribeWabaToAppArgs
+): Promise {
+ const { wabaId, accessToken } = args
+ const url = `${META_API_BASE}/${wabaId}/subscribed_apps`
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${accessToken}` },
+ })
+ if (!response.ok) {
+ await throwMetaError(response, `Meta API error: ${response.status}`)
+ }
+}
+
+export interface GetSubscribedAppsArgs {
+ wabaId: string
+ accessToken: string
+}
+
+export interface SubscribedApp {
+ whatsapp_business_api_data?: {
+ id?: string
+ name?: string
+ link?: string
+ }
+}
+
+/**
+ * Diagnostic — fetch the list of apps currently subscribed to this
+ * WABA. The UI uses this to confirm OUR app is in the list when
+ * the user clicks Verify Registration.
+ */
+export async function getSubscribedApps(
+ args: GetSubscribedAppsArgs
+): Promise {
+ const { wabaId, accessToken } = args
+ const url = `${META_API_BASE}/${wabaId}/subscribed_apps`
+ const response = await fetch(url, {
+ headers: { Authorization: `Bearer ${accessToken}` },
+ })
+ if (!response.ok) {
+ await throwMetaError(response, `Meta API error: ${response.status}`)
+ }
+ const data = (await response.json()) as { data?: SubscribedApp[] }
+ return data.data ?? []
+}
+
// ============================================================
// Sending
// ============================================================
diff --git a/src/lib/whatsapp/registration.test.ts b/src/lib/whatsapp/registration.test.ts
new file mode 100644
index 0000000..bb69a55
--- /dev/null
+++ b/src/lib/whatsapp/registration.test.ts
@@ -0,0 +1,182 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ getSubscribedApps,
+ registerPhoneNumber,
+ subscribeWabaToApp,
+} from './meta-api';
+
+function okResponse(body: unknown): Response {
+ return new Response(JSON.stringify(body), {
+ status: 200,
+ headers: { 'content-type': 'application/json' },
+ });
+}
+
+function errorResponse(status: number, body: unknown): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'content-type': 'application/json' },
+ });
+}
+
+describe('registerPhoneNumber', () => {
+ let fetchMock: ReturnType;
+ beforeEach(() => {
+ fetchMock = vi.fn().mockResolvedValue(okResponse({ success: true }));
+ vi.stubGlobal('fetch', fetchMock);
+ });
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('POSTs to /{phone_number_id}/register with messaging_product + pin', async () => {
+ const result = await registerPhoneNumber({
+ phoneNumberId: 'PNID_123',
+ accessToken: 'tok',
+ pin: '123456',
+ });
+ expect(result).toEqual({ success: true, alreadyRegistered: false });
+ const [url, init] = fetchMock.mock.calls[0];
+ expect(url).toContain('/PNID_123/register');
+ expect(init.method).toBe('POST');
+ expect(init.headers.Authorization).toBe('Bearer tok');
+ expect(JSON.parse(init.body)).toEqual({
+ messaging_product: 'whatsapp',
+ pin: '123456',
+ });
+ });
+
+ it('treats "already registered" as success (idempotent re-save)', async () => {
+ // This is the silent-failure case we're guarding against — Meta
+ // returns 400 + "Phone number is already registered" when the
+ // number was previously registered to THIS app. From the user's
+ // POV that's the desired outcome, surface it as success.
+ fetchMock.mockResolvedValueOnce(
+ errorResponse(400, {
+ error: {
+ message: 'Phone number is already registered to this app.',
+ code: 133005,
+ },
+ }),
+ );
+ const result = await registerPhoneNumber({
+ phoneNumberId: 'PNID_123',
+ accessToken: 'tok',
+ pin: '123456',
+ });
+ expect(result).toEqual({ success: true, alreadyRegistered: true });
+ });
+
+ it("surfaces Meta's PIN-required error verbatim so the UI can show it", async () => {
+ fetchMock.mockResolvedValueOnce(
+ errorResponse(400, {
+ error: {
+ message:
+ "Two-step verification PIN required. Set one in Meta WhatsApp Manager → Two-step verification.",
+ code: 133007,
+ },
+ }),
+ );
+ await expect(
+ registerPhoneNumber({
+ phoneNumberId: 'P',
+ accessToken: 't',
+ pin: '000000',
+ }),
+ ).rejects.toThrow(/Two-step verification PIN required/);
+ });
+
+ it('surfaces generic Meta errors as throw', async () => {
+ fetchMock.mockResolvedValueOnce(
+ errorResponse(500, {
+ error: { message: 'Internal Meta error' },
+ }),
+ );
+ await expect(
+ registerPhoneNumber({
+ phoneNumberId: 'P',
+ accessToken: 't',
+ pin: '123456',
+ }),
+ ).rejects.toThrow(/Internal Meta error/);
+ });
+});
+
+describe('subscribeWabaToApp', () => {
+ let fetchMock: ReturnType;
+ beforeEach(() => {
+ fetchMock = vi.fn().mockResolvedValue(okResponse({ success: true }));
+ vi.stubGlobal('fetch', fetchMock);
+ });
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('POSTs to /{waba_id}/subscribed_apps with bearer token', async () => {
+ await subscribeWabaToApp({ wabaId: 'WABA_1', accessToken: 'tok' });
+ const [url, init] = fetchMock.mock.calls[0];
+ expect(url).toContain('/WABA_1/subscribed_apps');
+ expect(init.method).toBe('POST');
+ expect(init.headers.Authorization).toBe('Bearer tok');
+ });
+
+ it('throws on non-OK', async () => {
+ fetchMock.mockResolvedValueOnce(
+ errorResponse(403, { error: { message: 'Insufficient permissions' } }),
+ );
+ await expect(
+ subscribeWabaToApp({ wabaId: 'WABA_1', accessToken: 'tok' }),
+ ).rejects.toThrow(/Insufficient permissions/);
+ });
+});
+
+describe('getSubscribedApps', () => {
+ let fetchMock: ReturnType;
+ beforeEach(() => {
+ fetchMock = vi.fn();
+ vi.stubGlobal('fetch', fetchMock);
+ });
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('returns the list of subscribed apps', async () => {
+ fetchMock.mockResolvedValueOnce(
+ okResponse({
+ data: [
+ {
+ whatsapp_business_api_data: {
+ id: 'APP1',
+ name: 'wacrm',
+ link: 'https://example.com/app',
+ },
+ },
+ ],
+ }),
+ );
+ const apps = await getSubscribedApps({
+ wabaId: 'WABA_1',
+ accessToken: 'tok',
+ });
+ expect(apps).toHaveLength(1);
+ expect(apps[0].whatsapp_business_api_data?.name).toBe('wacrm');
+ });
+
+ it('returns empty array when Meta returns no data field', async () => {
+ fetchMock.mockResolvedValueOnce(okResponse({}));
+ const apps = await getSubscribedApps({
+ wabaId: 'WABA_1',
+ accessToken: 'tok',
+ });
+ expect(apps).toEqual([]);
+ });
+
+ it('throws on non-OK', async () => {
+ fetchMock.mockResolvedValueOnce(
+ errorResponse(401, { error: { message: 'Invalid OAuth token' } }),
+ );
+ await expect(
+ getSubscribedApps({ wabaId: 'WABA_1', accessToken: 'tok' }),
+ ).rejects.toThrow(/Invalid OAuth token/);
+ });
+});
diff --git a/src/types/index.ts b/src/types/index.ts
index e656a72..4685d2a 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -138,6 +138,16 @@ export interface WhatsAppConfig {
verify_token?: string;
status: 'connected' | 'disconnected';
connected_at?: string;
+ /**
+ * Set when POST /{phone_number_id}/register last succeeded. NULL
+ * means the number was saved but never actually subscribed for
+ * webhooks on Meta's side — inbound events will be silently lost.
+ */
+ registered_at?: string;
+ /** Set when POST /{waba_id}/subscribed_apps last succeeded. */
+ subscribed_apps_at?: string;
+ /** Last error from /register; cleared on success. */
+ last_registration_error?: string;
}
// Raw Meta status enum. We persist this verbatim from Meta (sync + webhook)
diff --git a/supabase/migrations/015_whatsapp_config_registration.sql b/supabase/migrations/015_whatsapp_config_registration.sql
new file mode 100644
index 0000000..12cc610
--- /dev/null
+++ b/supabase/migrations/015_whatsapp_config_registration.sql
@@ -0,0 +1,45 @@
+-- ============================================================
+-- whatsapp_config: track Meta Cloud API registration state
+--
+-- Why this exists:
+-- Saving a row to whatsapp_config does NOT make a phone number
+-- actually receive webhook events from Meta. Two extra Cloud API
+-- calls are required:
+--
+-- POST /{phone_number_id}/register — subscribes the number
+-- with a 2FA PIN, makes
+-- it routable to OUR app
+-- POST /{waba_id}/subscribed_apps — subscribes the WABA
+-- (one-time per app, but
+-- idempotent so we can
+-- call on every save)
+--
+-- Until those two complete successfully, Meta routes inbound
+-- events to whichever app last registered the number (often the
+-- one that did Embedded Signup originally). Symptom: a second
+-- wacrm user adds a second number under the same WABA, the UI
+-- reports "Connected" because metadata verification succeeds,
+-- but Meta's activity log shows zero events for that number.
+--
+-- These columns let the UI distinguish "credentials saved" from
+-- "actually live" and let users retry registration without
+-- re-entering everything.
+--
+-- Backfill: every column is nullable. Existing rows survive with
+-- NULL values; the UI shows them as "registration status unknown —
+-- click Verify Registration" and the diagnostic endpoint fills the
+-- timestamps on the next probe.
+--
+-- Idempotent — safe to re-run.
+-- ============================================================
+
+ALTER TABLE whatsapp_config
+ ADD COLUMN IF NOT EXISTS registered_at TIMESTAMPTZ,
+ ADD COLUMN IF NOT EXISTS subscribed_apps_at TIMESTAMPTZ,
+ ADD COLUMN IF NOT EXISTS last_registration_error TEXT;
+
+-- Index supports the "find all numbers awaiting registration"
+-- query a future admin dashboard might want; cheap to maintain.
+CREATE INDEX IF NOT EXISTS idx_whatsapp_config_registered_at
+ ON whatsapp_config (registered_at)
+ WHERE registered_at IS NULL;