Most small businesses that move to the WhatsApp Business Platform already run their number in the WhatsApp Business app — with years of chats and a phone that staff answer every day. Asking them to give that up is often a deal-breaker. Coexistence removes the choice: the same number keeps working in the app and becomes available to your software through the Cloud API.
For a developer it is a different integration from a normal Cloud API number. You onboard it differently, you import contacts and history, you receive messages the business sends from the phone, and some API features stop working. This post covers each of those, with the patterns we use in WAPGROWAI, which supports coexistence onboarding. The TypeScript snippets are small, and each was run against the cases it describes.
The shape of it
WhatsApp Business app (phone) Meta Your platform
───────────────────────────── ──── ─────────────
confirms the connection ──────▶ Embedded Signup ──────────────▶ code + waba_id + phone_number_id
exchange code, subscribe app
◀────────────── POST /{phone}/smb_app_data (contacts)
◀────────────── POST /{phone}/smb_app_data (history)
webhook: smb_app_state_sync ─▶ contacts from the phone
webhook: history (chunks) ─▶ up to ~6 months of chats
staff reply from the phone ──────▶ webhook: smb_message_echoes ▶ those replies, as outbound
customers write ──────▶ webhook: messages ─▶ the usual inbound flow
Everything after onboarding is webhooks — three fields you won't have seen on a normal number.
1. Onboarding: a different Embedded Signup flow
Coexistence uses the same Embedded Signup popup as a normal connection, with one extras value that switches it to the Business app path:
FB.login(handleLoginResponse, {
config_id: EMBEDDED_SIGNUP_CONFIG_ID,
response_type: "code",
override_default_response_type: true,
extras: {
setup: {},
featureType: "whatsapp_business_app_onboarding",
sessionInfoVersion: "3",
},
});
Per Meta's guide to onboarding WhatsApp Business app users, this needs a recent Business app (2.24.17 or later) and you must be a Solution Partner or Tech Provider. The business confirms the connection from the app on their phone during the flow — worth telling them before they start, because it's the step people aren't ready for.
The login callback gives you an OAuth code. The WABA and phone number IDs arrive separately, as a message event posted to your window:
// signup-event.ts
type SignupResult = { wabaId: string; phoneNumberId: string } | { cancelledAt: string } | null;
// Embedded Signup posts its result to the opener window as a JSON string.
export const parseSignupMessage = (origin: string, data: unknown): SignupResult => {
if (!origin.endsWith("facebook.com") || typeof data !== "string") return null;
let event: any;
try {
event = JSON.parse(data);
} catch {
return null;
}
if (event?.type !== "WA_EMBEDDED_SIGNUP") return null;
if (String(event.event).startsWith("FINISH")) {
return { wabaId: event.data.waba_id, phoneNumberId: event.data.phone_number_id };
}
if (event.event === "CANCEL") return { cancelledAt: event.data?.current_step ?? "unknown" };
return null;
};
Then the usual server-side steps: exchange the code for a token and subscribe your app to the WABA. Store that this account is a coexistence number — WAPGROWAI keeps it as an is_coexistence flag set from the flow the business chose — because several behaviours below depend on it.
2. Ask for contacts and history straight away
Nothing arrives until you ask. Two requests to the same endpoint — contacts first, then history:
// trigger-sync.ts
const GRAPH = `https://graph.facebook.com/${process.env.GRAPH_API_VERSION ?? "v24.0"}`;
// Contacts first, then chat history. Meta expects these soon after onboarding.
export const startCoexistenceSync = async (phoneNumberId: string, token: string) => {
const results: Record<string, string> = {};
for (const syncType of ["smb_app_state_sync", "history"]) {
const res = await fetch(`${GRAPH}/${phoneNumberId}/smb_app_data`, {
method: "POST",
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
body: JSON.stringify({ messaging_product: "whatsapp", sync_type: syncType }),
});
const body = await res.json();
const code = body.error?.code;
// 135000: a sync was already requested for this number. Treat as done, not as a failure.
results[syncType] = res.ok ? "requested" : code === 135000 ? "already-requested" : `failed:${code}`;
}
return results;
};
Send them right after onboarding: Meta only accepts the history request for a limited time after the business connects (24 hours at the time of writing — check the current docs). Error 135000 means a sync was already requested for that number; treat it as done rather than surfacing a failure. In WAPGROWAI the requests fire automatically after connecting, and a "Sync contacts & chats" button lets an admin retry if the first attempt was rate-limited.
3. Importing chat history
History arrives on the history webhook field, in chunks — covering up to about six months, in phases with chunk_order and a cumulative progress percentage. If the business declined to share history in the app, the entry carries errors instead of threads.
Three things make the import harder than it looks:
-
There's no
tofield. A history message has afrom, and the threadidis the customer's number. Iffromis the business, the message was outbound. -
Chunks repeat and overlap. Re-delivered chunks, and messages that also arrive through other webhooks, mean every insert must be idempotent on the message ID (
wamid) — a unique index, not a "check then insert". -
Media can arrive as a placeholder. A
media_placeholdermessage is re-delivered later with the real media under the same ID, so skip the placeholder.
// history.ts
type HistoryMessage = { id: string; from: string; timestamp: string; type: string; text?: { body: string } };
type HistoryEntry = {
metadata?: { phase: number; chunk_order: number; progress: number };
threads?: { id: string; messages?: HistoryMessage[] }[];
errors?: { code: number; title: string }[];
};
export type StoredMessage = { wamid: string; customer: string; direction: "in" | "out"; at: Date; body?: string };
export interface Store {
// Must be idempotent on wamid (a unique index): chunks and echoes repeat.
insertIfNew(message: StoredMessage): boolean;
recordInbound(customer: string, at: Date): void; // forward-only, no notifications
}
export const importHistory = (entry: HistoryEntry, businessNumber: string, store: Store) => {
if (entry.errors?.length) return { skipped: "sharing-declined" as const };
let imported = 0;
for (const thread of entry.threads ?? []) {
for (const m of thread.messages ?? []) {
if (m.type === "media_placeholder" || m.type === "reaction") continue; // media is re-sent later with the same id
// History messages have no `to`; the thread id is the customer.
const direction = m.from === businessNumber || m.from !== thread.id ? "out" : "in";
const at = new Date(Number(m.timestamp) * 1000);
if (!store.insertIfNew({ wamid: m.id, customer: thread.id, direction, at, body: m.text?.body })) continue;
imported++;
if (direction === "in") store.recordInbound(thread.id, at);
}
}
return { imported, phase: entry.metadata?.phase, progress: entry.metadata?.progress };
};
Imported customer messages should update your "last time the customer wrote" anchor — but quietly (no notifications, no unread badges, no automations firing on six-month-old messages) and forward-only, so an old message can never shorten a window that a live message already opened. If you've read the previous post in this series on webhooks, it's the same discipline: store idempotently, never let an older event overwrite a newer state.
Chunks arrive concurrently
Meta can deliver several history chunks at the same moment. If each handler independently "finds or creates" the contact and conversation, two chunks for the same customer can both create them. Process each account's sync events in order:
// per-account-queue.ts
// Meta delivers sync chunks concurrently. Process one account's events in order,
// so two chunks can't both create the same contact or conversation.
const tails = new Map<string, Promise<unknown>>();
export const enqueueForAccount = <T>(accountId: string, job: () => Promise<T>): Promise<T> => {
const run = (tails.get(accountId) ?? Promise.resolve()).then(job, job);
tails.set(accountId, run.catch(() => undefined));
return run;
};
An in-process queue only serialises within one server process. If you run several instances, back it with database constraints (unique contact per account and phone, unique message per wamid) so a race fails safely instead of duplicating.
4. Contacts from the phone
The smb_app_state_sync field delivers the address-book contacts saved in the Business app, as add (create or edit, with full_name or first_name and phone_number) or remove. Decide deliberately what remove means for you. WAPGROWAI keeps the platform contact when it's deleted from the phone, because the conversation history and campaign consent live on the platform record.
5. Messages sent from the phone: echoes
After onboarding, staff can still reply from the app. Those replies don't pass through your API, so Meta sends them to you on the smb_message_echoes field — like a normal message, but with a to (the customer) because the business is the sender.
// echoes.ts
import type { Store } from "./history.ts";
type Echo = { id: string; from: string; to: string; timestamp: string; type: string; text?: { body: string } };
// A message someone sent from the WhatsApp Business app on the phone.
// It's outbound, so it must never open the 24-hour window.
export const importEcho = (echo: Echo, store: Store) =>
store.insertIfNew({
wamid: echo.id,
customer: echo.to,
direction: "out",
at: new Date(Number(echo.timestamp) * 1000),
body: echo.text?.body,
});
The important rule: an echo is outbound. It belongs in the conversation so your inbox shows the full thread, but it must not open the customer service window — only the customer can do that. Echoes of message edits and deletions are worth handling explicitly; until you do, at least make sure they don't create duplicate messages.
6. What changes for the number
According to Meta's documentation, once a number is connected:
- Throughput is fixed at 20 messages per second, instead of the 80 that normal Cloud API numbers start with. If your sender is paced for 80, slow it down for these numbers.
- Group chats aren't synced, broadcast lists in the app become read-only, and disappearing and view-once messages are turned off in one-to-one chats.
- Messages sent from the app stay free; messages sent through the API are charged as usual.
- The phone has to stay in use. If the primary device is inactive for about 14 days, the connection is dropped.
Two more limits we hit in practice:
-
The business profile belongs to the app. In our testing, reading the profile through the API works, but writing it returns
403with error#200. WAPGROWAI shows the profile as read-only for coexistence numbers, mirrored from WhatsApp, with a note to edit it in the Business app. - WhatsApp's Groups API isn't available for coexistence numbers, so any group features should be hidden for them rather than failing with Meta's error.
Summary
- Onboard with
featureType: "whatsapp_business_app_onboarding", and flag the account as coexistence. - Request contact and history sync immediately after onboarding; treat
135000as success. - Import history idempotently on
wamid, infer direction fromfrom, skip media placeholders, and update the customer-activity anchor quietly and forward-only. - Serialise each account's sync events, and back that with unique constraints.
- Store echoes as outbound messages that never open the window.
- Pace coexistence numbers at 20 messages per second, and treat the profile as read-only.
Coexistence is what lets a business adopt a proper inbox without abandoning the phone it already uses, which makes it worth getting these details right.
I work on WAPGROWAI, a WhatsApp Business Platform that supports both normal and coexistence onboarding; its help guide to connecting a number shows the flow from the business's side. For the business-level view of coexistence, see WhatsApp Business App vs. WhatsApp Business Platform.
Top comments (0)