Most WhatsApp Cloud API integrations assume they own the number: every message in or out passes through your webhook and your send call. Coexistence breaks that assumption. A business keeps using the WhatsApp Business app on its phone and connects the same number to your platform, so messages now arrive from three directions at once: live customer messages on the normal messages webhook, a backfill of up to six months of old chats, and copies of whatever the owner types on their phone. Get the merge wrong and you get duplicate contacts, a chat list reordered by six-month-old messages, automations replying to history, and an inbox that says "you can reply" when WhatsApp won't deliver.
This post covers what coexistence sends you, the decisions it forces, and a tested TypeScript implementation of each webhook, based on what we built and fixed in WAPGROWAI's own coexistence support. All Meta behaviour quoted here is from Meta's coexistence onboarding guide and its webhook reference pages.
The snippets are small, self-contained versions of the patterns, and each was run against the cases shown.
What changes when the app and the API share a number
On a coexistence number, the business still uses the app for 1:1 chats, and your platform sees them. But plenty doesn't carry over to the Cloud API side. Meta's feature table marks these as not supported: group chats, disappearing and view-once messages, live location, broadcast lists (which become read-only), voice and video calls, the app's business tools (catalog, orders, status), its messaging tools (marketing messages, greeting and away messages, quick replies, labels), the business profile, and channels.
Two limits matter for platform code:
- Throughput is fixed. Numbers "in use with both the WhatsApp Business app and Cloud API have a fixed throughput of 20 mps" (messages per second), instead of the usual 80 that can rise to 1,000. Campaign senders need to pace for 20.
- Offboarding happens in the app. The Deregister API can't be used on coexistence numbers. The business disconnects from the app's settings, and you get an
account_updatewebhook withPARTNER_REMOVED.
Four webhooks, three sources
Subscribe to these fields for coexistence numbers:
| Webhook field | What it carries | Live or sync? |
|---|---|---|
messages | Customers' messages, as usual | Live |
history | Up to 180 days of past 1:1 chats, in phases and chunks | Sync |
smb_message_echoes | Messages the business sends from the app or a linked device | Live, but from the business |
smb_app_state_sync | The phone's contacts: added, edited, removed | Sync |
The rule that shaped everything we built: sync payloads are not customer activity. History, echoes and contact changes must never fire automations, send notifications, bump unread counts or move a chat to the top of the list as if the customer had just written. They're records of things that already happened.
Step 1: request the sync, within 24 hours
History and contacts don't arrive by themselves. After onboarding you call POST /<PHONE_NUMBER_ID>/smb_app_data once with sync_type: "smb_app_state_sync" and once with sync_type: "history". Meta's guide is strict about timing: "you have 24 hours to synchronize their contacts and messaging history, otherwise they must be offboarded and complete the flow again." And each request can only be made once.
type Fetch = (url: string, init: { method: string; headers: Record<string, string>; body: string }) => Promise<{ ok: boolean; status: number; json: () => Promise<any> }>; export type SyncType = "smb_app_state_sync" | "history"; export type SyncResult = { type: SyncType; ok: boolean; message?: string }; const WINDOW_MS = 24 * 3_600_000; // After onboarding you have 24 hours to request the contacts and history sync, // and each can be requested once. Ask for both straight away, contacts first. export const requestCoexistenceSync = async ( fetchImpl: Fetch, opts: { phoneNumberId: string; token: string; onboardedAt: Date; graph?: string }, now = new Date(), ): Promise<SyncResult[]> => { if (now.getTime() - opts.onboardedAt.getTime() > WINDOW_MS) { const message = "More than 24 hours since onboarding: the business must disconnect and onboard again."; return [{ type: "smb_app_state_sync", ok: false, message }, { type: "history", ok: false, message }]; } const results: SyncResult[] = []; for (const type of ["smb_app_state_sync", "history"] as const) { const res = await fetchImpl(`${opts.graph ?? "https://graph.facebook.com/v25.0"}/${opts.phoneNumberId}/smb_app_data`, { method: "POST", headers: { Authorization: `Bearer ${opts.token}`, "Content-Type": "application/json" }, body: JSON.stringify({ messaging_product: "whatsapp", sync_type: type }), }); const body = await res.json().catch(() => ({})); // Success only means "requested": whether the business shared history arrives later, by webhook. results.push(res.ok ? { type, ok: true } : { type, ok: false, message: body.error?.message ?? `HTTP ${res.status}` }); } return results; }; Things to get right:
- Fire both requests as part of onboarding, not from a background job that might run tomorrow.
- A 200 means "requested", nothing more. The response is just a
request_id. Whether the business agreed to share history only shows up later, in thehistorywebhook. - Store the outcome per number. If a request fails, you have the rest of the 24 hours to retry; after that, the only fix is offboarding and onboarding again. A visible "sync contacts and chats" action is worth having for exactly that window.
A per-account queue
// Meta sends history chunks, contact batches and echoes at the same time. Two // payloads creating the same new contact in parallel make duplicates, so all // sync work for one account runs strictly in order. const tails = new Map<string, Promise<void>>(); export const perAccount = <T>(account: string, task: () => Promise<T>): Promise<T> => { const previous = tails.get(account) ?? Promise.resolve(); const run = previous.then(task); const tail = run.then( () => undefined, () => undefined, // one failed payload mustn't block the next ); tails.set(account, tail); void tail.then(() => { if (tails.get(account) === tail) tails.delete(account); }); return run; }; Meta sends history chunks, contact batches and echoes concurrently. Our first version processed each webhook independently, and two payloads that both mentioned a brand-new customer raced through find-or-create: one created the contact, the other hit the unique index and aborted its whole chunk, or worse, created a second conversation and split the thread in two. Import order doesn't matter (every message keeps its own timestamp), so the fix is simple: process all sync payloads for one account strictly in sequence.
This in-memory version only serialises within one process. With several API instances, use a per-account lock or a queue partitioned by account, and keep the unique indexes as the last line of defence.
The store
// A tiny in-memory stand-in for the database, keyed the way the real tables are. export type Direction = "in" | "out"; export type Message = { wamid: string; account: string; customer: string; // the customer's wa_id, digits only direction: Direction; type: string; text?: string; status?: string; at: Date; source: "live" | "history" | "echo" | "api"; editedText?: string; revoked?: boolean; }; export type Contact = { account: string; waId: string; name: string | null }; export const createStore = () => { const messages = new Map<string, Message>(); // unique on wamid, like a DB index const contacts = new Map<string, Contact>(); // unique on account + wa_id const lastInbound = new Map<string, Date>(); // per account + customer: the 24-hour window anchor let contactInserts = 0; return { messages, contacts, lastInbound, insertMessage: (m: Message) => { if (messages.has(m.wamid)) return false; messages.set(m.wamid, m); return true; }, upsertContact: async (account: string, waId: string, name: string | null) => { const key = `${account}:${waId}`; const existing = contacts.get(key); // A real find-or-create awaits a query between the find and the create. await new Promise((r) => setTimeout(r, 1)); if (existing) { if (name && name !== existing.name) existing.name = name; return existing; } if (contacts.has(key)) throw new Error("ER_DUP_ENTRY: contact already exists"); const created = { account, waId, name }; contacts.set(key, created); contactInserts++; return created; }, contactInserts: () => contactInserts, }; }; export type Store = ReturnType<typeof createStore>; A stand-in for the database with the two constraints that matter: messages are unique on wamid, contacts on account plus wa_id. The deliberate pause in upsertContact reproduces the gap between "find" and "create" that a real query has, which is what makes the race visible in tests.
The handlers
import type { Store } from "./store.ts"; import { perAccount } from "./queue.ts"; const digits = (s: string | undefined) => (s ?? "").replace(/\D/g, ""); const toDate = (ts: string) => new Date(Number(ts) * 1000); const DAY = 86_400_000; type HistoryMessage = { id: string; from: string; type: string; timestamp: string; text?: { body: string }; history_context?: { status?: string } }; type HistoryEntry = { metadata?: { phase?: number; chunk_order?: number; progress?: number }; threads?: { id: string; messages: HistoryMessage[] }[]; errors?: { code: number; title: string }[]; }; export type SyncProgress = { phase: number; progress: number; sharingOff: boolean }; // history: up to 180 days of 1:1 chats, in phases, in chunks that can arrive in any order. export const handleHistory = (store: Store, account: string, businessNumber: string, entries: HistoryEntry[], now = new Date()) => perAccount(account, async () => { const report: SyncProgress = { phase: 0, progress: 0, sharingOff: false }; for (const entry of entries) { // The business turned history sharing off in the app (error 2593109). if (entry.errors?.length) { report.sharingOff = true; continue; } report.phase = Math.max(report.phase, entry.metadata?.phase ?? 0); report.progress = Math.max(report.progress, entry.metadata?.progress ?? 0); for (const thread of entry.threads ?? []) { const customer = digits(thread.id); await store.upsertContact(account, customer, null); for (const m of thread.messages) { // Media comes later in its own webhook, under the same message ID. if (m.type === "media_placeholder") continue; // History messages carry `from` but not `to`: the sender decides direction. const outbound = digits(m.from) === digits(businessNumber); const at = toDate(m.timestamp); const inserted = store.insertMessage({ wamid: m.id, account, customer, type: m.type, text: m.text?.body, at, source: "history", direction: outbound ? "out" : "in", status: outbound ? (m.history_context?.status ?? "SENT").toLowerCase() : undefined, }); // An old inbound message must not reopen the 24-hour window, or agents // would see a chat as replyable when WhatsApp won't deliver free text. const key = `${account}:${customer}`; if (inserted && !outbound && now.getTime() - at.getTime() < DAY) { const prev = store.lastInbound.get(key); if (!prev || prev < at) store.lastInbound.set(key, at); } } } } return report; }); type Echo = { id: string; from: string; to?: string; timestamp: string; type: string; text?: { body: string }; original_message_id?: string }; // smb_message_echoes: what the business sends from the app or a linked device. export const handleEchoes = (store: Store, account: string, echoes: Echo[]) => perAccount(account, async () => { let added = 0; for (const e of echoes) { if (e.type === "edit" || e.type === "revoke") { const original = e.original_message_id ? store.messages.get(e.original_message_id) : undefined; if (original && e.type === "edit") original.editedText = e.text?.body; if (original && e.type === "revoke") original.revoked = true; continue; } if (!e.to) continue; const customer = digits(e.to); await store.upsertContact(account, customer, null); // A message your API sent can come back as an echo too; the wamid makes it one row. if (store.insertMessage({ wamid: e.id, account, customer, direction: "out", type: e.type, text: e.text?.body, status: "sent", at: toDate(e.timestamp), source: "echo" })) added++; } return added; }); type StateSync Source:https://dev.to/wapgrowai/whatsapp-coexistence-for-developers-history-sync-message-echoes-and-contacts-without-breaking-1111