+ )}
+
+ )}
+
{/* 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;