Merge pull request #155 from ArnasDon/fix/whatsapp-multi-number-registration

fix(whatsapp): register phone numbers on Meta + verify subscription state
This commit is contained in:
Arnas Donauskas 2026-05-29 18:20:08 +02:00 committed by GitHub
commit 6c0c70486c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 862 additions and 30 deletions

View File

@ -1,7 +1,11 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { createClient as createAdminClient } from '@supabase/supabase-js' 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' import { encrypt, decrypt } from '@/lib/whatsapp/encryption'
// Lazy-initialised service-role client. We need it to detect a // 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 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) { if (!access_token || !phone_number_id) {
return NextResponse.json( 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. // Reject if another user has already claimed this phone_number_id.
// wacrm is single-tenant-per-WhatsApp-number — letting two users // wacrm is single-tenant-per-WhatsApp-number — letting two users
// bind the same number causes the webhook's `.single()` lookup to // 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 const { data: existing } = await supabase
.from('whatsapp_config') .from('whatsapp_config')
.select('id') .select('id, registered_at, phone_number_id')
.eq('user_id', user.id) .eq('user_id', user.id)
.maybeSingle() .maybeSingle()
if (existing) { const sameNumber =
const { error: updateError } = await supabase existing?.phone_number_id === phone_number_id &&
.from('whatsapp_config') existing?.registered_at != null
.update({
// 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, phone_number_id,
waba_id: waba_id || null, waba_id: waba_id || null,
access_token: encryptedAccessToken, access_token: encryptedAccessToken,
verify_token: encryptedVerifyToken, verify_token: encryptedVerifyToken,
status: 'connected', status: registrationError ? 'disconnected' : 'connected',
connected_at: new Date().toISOString(), 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(), updated_at: new Date().toISOString(),
}) }
if (existing) {
const { error: updateError } = await supabase
.from('whatsapp_config')
.update(baseRow)
.eq('user_id', user.id) .eq('user_id', user.id)
if (updateError) { if (updateError) {
@ -242,15 +330,7 @@ export async function POST(request: Request) {
} else { } else {
const { error: insertError } = await supabase const { error: insertError } = await supabase
.from('whatsapp_config') .from('whatsapp_config')
.insert({ .insert({ user_id: user.id, ...baseRow })
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(),
})
if (insertError) { if (insertError) {
console.error('Error inserting whatsapp_config:', 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) { } catch (error) {
console.error('Error in WhatsApp config POST:', error) console.error('Error in WhatsApp config POST:', error)
return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) return NextResponse.json({ error: 'Internal server error' }, { status: 500 })

View File

@ -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,
})
}

View File

@ -52,8 +52,28 @@ export function WhatsAppConfig() {
const [wabaId, setWabaId] = useState(''); const [wabaId, setWabaId] = useState('');
const [accessToken, setAccessToken] = useState(''); const [accessToken, setAccessToken] = useState('');
const [verifyToken, setVerifyToken] = useState(''); const [verifyToken, setVerifyToken] = useState('');
const [pin, setPin] = useState('');
const [tokenEdited, setTokenEdited] = useState(false); 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<string, boolean | null>;
errors?: string[];
last_registration_error?: string | null;
registered_at?: string | null;
subscribed_apps_at?: string | null;
};
const [registrationProbe, setRegistrationProbe] =
useState<RegistrationProbe | null>(null);
const webhookUrl = const webhookUrl =
typeof window !== 'undefined' typeof window !== 'undefined'
? `${window.location.origin}/api/whatsapp/webhook` ? `${window.location.origin}/api/whatsapp/webhook`
@ -79,6 +99,7 @@ export function WhatsAppConfig() {
setWabaId(data.waba_id || ''); setWabaId(data.waba_id || '');
setAccessToken(MASKED_TOKEN); setAccessToken(MASKED_TOKEN);
setVerifyToken(''); setVerifyToken('');
setPin('');
setTokenEdited(false); setTokenEdited(false);
} else { } else {
setConfig(null); setConfig(null);
@ -86,8 +107,11 @@ export function WhatsAppConfig() {
setWabaId(''); setWabaId('');
setAccessToken(''); setAccessToken('');
setVerifyToken(''); setVerifyToken('');
setPin('');
setTokenEdited(false); setTokenEdited(false);
} }
// Clear any stale probe result when reloading the row.
setRegistrationProbe(null);
// Then verify health via the API (decrypts token + pings Meta) // Then verify health via the API (decrypts token + pings Meta)
if (data) { if (data) {
@ -151,6 +175,10 @@ export function WhatsAppConfig() {
phone_number_id: phoneNumberId.trim(), phone_number_id: phoneNumberId.trim(),
waba_id: wabaId.trim() || null, waba_id: wabaId.trim() || null,
verify_token: verifyToken.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()) { if (tokenEdited && accessToken !== MASKED_TOKEN && accessToken.trim()) {
@ -179,11 +207,28 @@ export function WhatsAppConfig() {
return; return;
} }
// 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( toast.success(
data.phone_info?.verified_name data.phone_info?.verified_name
? `Connected to ${data.phone_info.verified_name}` ? `Live — ${data.phone_info.verified_name} can now receive events.`
: 'Configuration saved successfully' : '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); if (user) await fetchConfig(user.id);
} catch (err) { } 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() { async function handleReset() {
if (!confirm('This will delete the current WhatsApp config so you can re-enter it. Continue?')) { if (!confirm('This will delete the current WhatsApp config so you can re-enter it. Continue?')) {
return; return;
@ -320,17 +391,125 @@ export function WhatsAppConfig() {
<XCircle className="size-4 text-red-500" /> <XCircle className="size-4 text-red-500" />
)} )}
<AlertTitle className="text-white mb-0"> <AlertTitle className="text-white mb-0">
{connectionStatus === 'connected' ? 'Connected' : 'Not Connected'} {connectionStatus === 'connected' ? 'Credentials valid' : 'Not Connected'}
</AlertTitle> </AlertTitle>
</div> </div>
<AlertDescription className="text-slate-400"> <AlertDescription className="text-slate-400">
{connectionStatus === '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 || : statusMessage ||
'Configure your Meta API credentials below to connect your WhatsApp Business account.'} 'Configure your Meta API credentials below to connect your WhatsApp Business account.'}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
{/* 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 && (
<Alert
className={
isRegistered
? 'bg-emerald-950/30 border-emerald-700/50'
: 'bg-amber-950/30 border-amber-700/50'
}
>
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="flex items-center gap-2">
{isRegistered ? (
<CheckCircle2 className="size-4 text-emerald-400" />
) : (
<AlertTriangle className="size-4 text-amber-400" />
)}
<AlertTitle
className={
'mb-0 ' + (isRegistered ? 'text-emerald-200' : 'text-amber-200')
}
>
{isRegistered
? 'Registered — Meta will deliver events to wacrm'
: 'Not registered — Meta will not deliver events'}
</AlertTitle>
</div>
<Button
variant="outline"
size="sm"
onClick={handleVerifyRegistration}
disabled={verifyingRegistration}
className="border-slate-700 bg-transparent text-slate-200 hover:bg-slate-800 h-7"
>
{verifyingRegistration ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Zap className="size-3.5" />
)}
Verify with Meta
</Button>
</div>
<AlertDescription className="text-slate-400 mt-2 text-xs leading-relaxed">
{isRegistered ? (
<>
Subscribed since{' '}
{config.registered_at
? new Date(config.registered_at).toLocaleString()
: 'unknown'}
. Click <strong>Verify with Meta</strong> if events
stop arriving.
</>
) : lastRegistrationError ? (
<>
Last attempt failed with:{' '}
<span className="text-red-300">
&quot;{lastRegistrationError}&quot;
</span>
. 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.
</>
)}
</AlertDescription>
{registrationProbe && (
<div className="mt-3 rounded border border-slate-700 bg-slate-900/60 px-3 py-2 space-y-1.5 text-[11px]">
<p className="font-medium text-slate-200">
Diagnostic — last run: {' '}
<span className={registrationProbe.live ? 'text-emerald-400' : 'text-amber-400'}>
{registrationProbe.live ? 'live' : 'not live'}
</span>
</p>
<ul className="space-y-0.5 text-slate-400">
{Object.entries(registrationProbe.checks).map(([k, v]) => (
<li key={k} className="flex items-center gap-1.5">
{v === true ? (
<CheckCircle2 className="size-3 text-emerald-400 shrink-0" />
) : v === false ? (
<XCircle className="size-3 text-red-400 shrink-0" />
) : (
<span className="size-3 rounded-full border border-slate-600 shrink-0" />
)}
<code className="text-slate-300">{k}</code>
</li>
))}
</ul>
{(registrationProbe.errors ?? []).length > 0 && (
<ul className="pt-1 space-y-0.5 text-red-300">
{registrationProbe.errors?.map((e, i) => (
<li key={i}>• {e}</li>
))}
</ul>
)}
</div>
)}
</Alert>
)}
{/* API Credentials */} {/* API Credentials */}
<Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent"> <Card className="bg-slate-900 border-slate-700 ring-0 ring-transparent">
<CardHeader> <CardHeader>
@ -406,6 +585,39 @@ export function WhatsAppConfig() {
A custom string you create. Must match the token you set in Meta webhook settings. A custom string you create. Must match the token you set in Meta webhook settings.
</p> </p>
</div> </div>
<div className="space-y-2">
<Label className="text-slate-300">
Two-step verification PIN
{!isRegistered && (
<span className="ml-1 text-red-400">*</span>
)}
</Label>
<Input
type="text"
inputMode="numeric"
maxLength={6}
placeholder="6-digit PIN from Meta WhatsApp Manager"
value={pin}
onChange={(e) =>
setPin(e.target.value.replace(/\D/g, '').slice(0, 6))
}
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500 tracking-widest"
/>
<p className="text-xs text-slate-500 leading-relaxed">
Required the first time you connect a number, and any
time you swap to a different number. Set it in{' '}
<strong className="text-slate-300">
Meta Business Manager → WhatsApp Accounts → Phone
Numbers → Two-step verification
</strong>
. Without this PIN, Meta saves your credentials but
won&apos;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.
</p>
</div>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -65,6 +65,152 @@ export async function verifyPhoneNumber(
return response.json() 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<RegisterPhoneNumberResult> {
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<void> {
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<SubscribedApp[]> {
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 // Sending
// ============================================================ // ============================================================

View File

@ -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<typeof vi.fn>;
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<typeof vi.fn>;
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<typeof vi.fn>;
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/);
});
});

View File

@ -138,6 +138,16 @@ export interface WhatsAppConfig {
verify_token?: string; verify_token?: string;
status: 'connected' | 'disconnected'; status: 'connected' | 'disconnected';
connected_at?: string; 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) // Raw Meta status enum. We persist this verbatim from Meta (sync + webhook)

View File

@ -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;