DEV Community

Cover image for WhatsApp Coexistence for Developers: History Sync, Message Echoes and Contacts Without Breaking Your Inbox
WAPGROWAI for WAPGROWAI

Posted on Edited on

WhatsApp Coexistence for Developers: History Sync, Message Echoes and Contacts Without Breaking Your Inbox

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_update webhook with PARTNER_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;
};
Enter fullscreen mode Exit fullscreen mode

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 the history webhook.
  • 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;
};
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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 = { type: string; action: "add" | "remove"; contact: { phone_number: string; full_name?: string; first_name?: string } };

// smb_app_state_sync: the phone's address book. "add" also means "edited".
export const handleContacts = (store: Store, account: string, items: StateSync[]) =>
  perAccount(account, async () => {
    let synced = 0;
    for (const item of items) {
      // Removing a phone contact must not delete the platform's history, tags or opt-in.
      if (item.type !== "contact" || item.action === "remove") continue;
      const name = item.contact.full_name?.trim() || item.contact.first_name?.trim() || null;
      await store.upsertContact(account, digits(item.contact.phone_number), name);
      synced++;
    }
    return synced;
  });
Enter fullscreen mode Exit fullscreen mode

History: six months, in phases, in any order

The history webhook describes "all messages sent or received within 180 days" of onboarding, in three phases: day 0–1, day 1–90 and day 90–180. Each phase is split into chunks with metadata.chunk_order, and metadata.progress runs to 100 when the sync is complete. Chunks can arrive out of order, and a phase with no messages sends nothing at all.

  • Idempotent on wamid. Re-delivered and out-of-order chunks are harmless when the message ID is the key.
  • Direction from from. History messages always carry from; Meta's reference says to "is only included if the message object represents an SMB message echo." If from is the business's number, it's outbound.
  • Keep the delivery status. Outbound history messages carry history_context.status: SENT, DELIVERED, READ, PLAYED, PENDING or ERROR.
  • Skip media_placeholder. History media doesn't include media IDs. A separate webhook follows with the media under the same message ID, and only for recent media: within two weeks. Meta's own examples show that follow-up in a different shape from the history payload, so log the raw payloads you receive before relying on either. Above all, make sure it isn't processed as a new message from the customer.
  • Don't reopen the window with old messages. Importing a customer message from March must not mark the chat as replyable today. Only an inbound message from the last 24 hours should move the window anchor, and even then silently.
  • Show progress. phase and progress are enough for a "Importing chats: 60%" indicator, which saves a lot of "where are my old chats?" support tickets.
  • Report "sharing off" as its own state. If the business declined to share history, Meta sends a history webhook with error code 2593109 instead of threads. An empty inbox and a declined sync look the same unless you record which one happened.

Echoes: the owner is still typing on their phone

smb_message_echoes notifies you of messages sent from the WhatsApp Business app or a linked device. Each carries from, to, id, timestamp and type.

  • Thread by to. The customer is the recipient.
  • Deduplicate with your own sends. Messages sent through your API can come back as echoes too; when your send stored the wamid, the echo is a no-op.
  • Apply edits and deletes. Echoes include edit and revoke types with an original_message_id. If the owner corrects a price on their phone, your inbox should show the corrected text, not the wrong one. Our own implementation skips these today, so an edited or deleted message stays as it was first sent in the inbox; it's the next fix on our list.
  • Push them live. Unlike history, an echo is happening now. Show it in open inboxes immediately, attributed to the app, so an agent doesn't reply to a customer the owner has just answered.

Contacts: an address book, not a source of truth

smb_app_state_sync sends the phone's contacts. There are two actions: add, which "indicates the WhatsApp Business app user added or edited a contact", and remove.

  • add is an upsert. There's no separate edit action, so update the name on an existing contact.
  • We ignore remove. Deleting a contact from the phone shouldn't delete a platform contact that holds chat history, campaign opt-in, tags and attributes. If you'd rather mirror removals, mark the contact instead of deleting it.

What the tests check

  • History direction and status: a customer message is inbound; the business's reply is outbound with status read; a media_placeholder is skipped.
  • History idempotency: chunk 2 before chunk 1, then both again, stores two messages.
  • The 24-hour window: a 3-day-old inbound history message leaves the window closed; one from 3 hours ago opens it.
  • Declined history: an error 2593109 payload sets sharingOff and stores nothing.
  • Echoes: a phone-sent message is stored as outbound; an echo of a message the API already stored adds nothing.
  • Edits and revokes: an edit echo updates the original text; a revoke marks it deleted; no extra rows.
  • Contacts: add sets the name, a second add updates it, and remove keeps the contact.
  • Concurrency: contacts, history and echoes for the same new customer arriving together create one contact and two messages.
  • Without the queue: two parallel creates of the same contact fail on the unique index.
  • Sync requests: both types are requested two hours after onboarding; at 25 hours nothing is sent and both report the offboard-and-retry message.

Checklist

  • Subscribe to history, smb_message_echoes and smb_app_state_sync as well as messages and account_update.
  • Request contacts and history sync once, during onboarding, within 24 hours.
  • Treat sync payloads as records: no automations, notifications or unread counts.
  • Serialise sync work per account; keep unique indexes on wamid and contact.
  • Key everything on wamid; derive history direction from from.
  • Never reopen the 24-hour window from old history.
  • Record "history sharing turned off" (2593109) separately from "no history".
  • Apply echo edits and revokes to the original message.
  • Upsert contacts on add; don't delete platform data on remove.
  • Pace sends for 20 messages per second on coexistence numbers.

WAPGROWAI is built by DI Solutions, a custom software and AI development company. This series covers what we've learned building WAPGROWAI on the WhatsApp Cloud API. Related: routing WhatsApp webhooks in a multi-tenant SaaS and WhatsApp message statuses arrive out of order.

Top comments (0)