Coexistence
Numbers shared with the WhatsApp Business app: history backfill, message echoes, contact sync and lifecycle.
@genuka/whatsapp/coexistence. Everything specific to a number shared between the merchant's WhatsApp Business app and the Cloud API. Pure functions only: normalization, detection, state transitions. Persistence lives in the app, underlib/coexistence/.
Coexistence is the onboarding mode for small merchants: they keep WhatsApp Business on their phone and plug the API into the same number. It is the mode most Genuka clients will use, and it behaves differently enough from Full API that treating it as "Cloud API with a flag" produces bugs nobody can reproduce.
1. Detecting it, and what follows — US-7.1
platform_type === "SMB_APP". That single value has consequences, and they are returned as data
rather than re-derived at every call site:
import { coexistenceProfile, estimateCampaignDuration } from "@genuka/whatsapp/coexistence";
const profile = coexistenceProfile(connection.platformType, connection.throughputLevel);
// { coexistence: true, messagesPerSecond: 20, catalog: false, calling: false,
// historyImport: true, contactSync: true, echoes: true, appOnlyCapabilities: [...] }| Full API | Coexistence | |
|---|---|---|
| Throughput | 80 → 1 000 mps | 20 mps, fixed |
| Catalog / orders via API | yes | no — they stay in the app |
| Voice / video calling | yes | no |
| History, contact sync, echoes | no | yes |
An unknown platform_type is treated as Cloud API. Assuming coexistence would silently
throttle a healthy number to a quarter of its speed; the opposite mistake is corrected by Meta
itself with a 130429, which the retry policy already handles.
Announce the duration before launch
At 20 mps, 100 000 messages take 83 minutes. A merchant who learns that at minute 40 opens a support ticket.
const estimate = estimateCampaignDuration(recipients.length, connection.platformType);
// { messagesPerSecond: 20, seconds: 5000, minutes: 83, breakdown: { hours: 1, minutes: 23, seconds: 20 } }It is a floor, not a forecast: Meta-side throttling, retries and per-user marketing caps only make it longer. No string is returned — formatting is the UI's job, and this library is locale-free.
Wiring: call it in the campaign launch path (launchCampaign in lib/billing/send.ts, behind
POST /api/v1/campaigns/{id}/launch) before the first send, and return it alongside the
existing { sent, failed, skipped } so the caller can show it, or confirm it, up front.
2. The 180-day history import — US-7.2
The delicate one. Not because of the volume, because of the delivery model.
Meta cuts the merchant's past into three phases by message age — 0-1, 1-90, 90-180 days
— and streams each as a series of chunks. Nothing is guaranteed:
- phases arrive out of order (
90-180before0-1is routine); - chunks within a phase interleave;
- any chunk can be redelivered after a webhook timeout;
- the last chunk of a phase is not reliably flagged.
Three design consequences, and they are the whole story:
One row per phase, keyed (connectionId, phase). A phase is a unit of progress that can be
running, completed or failed independently of the other two. A partial import — two phases out of
three — is a real, useful outcome, not a failure.
Idempotency on providerMessageId. Message.providerMessageId is @unique, and that is the
only dedupe mechanism in play. A message with no wamid is dropped by normalizeHistory rather
than stored, because a row that cannot be deduplicated will be duplicated.
Progress is derived, never accumulated.
const progress = historyImportProgress(rows);
// { percent: 50, status: "running", settled: false, threadsSeen: 6, messagesSeen: 410, phases: [...] }Each phase weighs a third: completed and failed count 1 (settled), running counts 0.5 — a
bar that sits at 0% for four minutes is indistinguishable from a broken one — pending counts 0.
Reprocess the entire backfill twice and the number does not move. status: "partial" means all
phases settled and at least one failed: the merchant has some of their history, which is a
different conversation from "the import failed".
Because Meta does not flag the last chunk, a running phase that has received nothing for
COEXISTENCE.historyPhaseIdleMinutes is closed by isPhaseStale. Closing early is safe: a late
chunk still writes its messages, it just does not reopen the phase.
const chunks = normalizeHistory(event.payload);
// each: { phase, chunkOrder?, progress?, final, threads, messages[], contacts[] }Direction is resolved from the thread: a thread is keyed by the customer's wa_id, so
from === threadId means inbound. When it is undecidable we say outbound — calling an
outbound message inbound would wrongly re-open the 24h service window and authorize a free-form
send Meta then rejects with 131047.
3. Message echoes — US-7.3
smb_message_echoes are messages the merchant sent from their phone (or a companion device).
They are stored as direction: "outbound", source: "app", and they exist so the Genuka inbox is
not a half-truth.
No delivery status will ever follow an echo.
There is no sent → delivered → read sequence coming, because we never sent it — the phone did.
Anything that waits on a wamid — a campaign counter, a "stuck in sending" alert, a retry sweeper —
must exclude echoes, or it will report a permanent failure for a message the customer read an hour
ago. That is why every normalized echo carries expectsDeliveryStatus: false as a literal type
rather than as a comment, and why the shared predicate exists:
import { expectsDeliveryStatus } from "@genuka/whatsapp/coexistence";
expectsDeliveryStatus(message.source); // "api" → true, "app" | "history" → falseEcho rows are written with status: "sent" and stay there for life. Not because delivery is in
doubt, but because nothing will ever tell us otherwise.
4. Contact sync — US-7.4
smb_app_state_sync mirrors the merchant's phone address book: add, update, delete.
normalizeContactSync(event.payload); // [{ action: "add" | "update" | "delete", waId, displayName? }]An unrecognized action degrades to update (an upsert), never to delete: guessing "delete" from
an unknown verb hides a contact the merchant never removed. Non-contact state entries (labels,
chat settings) are skipped rather than misread into contact rows.
Deletes are soft. The merchant deleting a contact from their phone is saying "stop showing me
this person", not "erase what we said to each other" — and the conversation hangs off the contact.
deletedAt is set; a re-add clears it and the history is simply there again.
The initial sync has to be asked for
Only changes after onboarding arrive on their own. The address book itself is requested through the SMB App Data API:
await requestInitialContactSync(transport, phoneNumberId);Call it once, immediately after onboarding completes. Skip it and the merchant sees an empty
contact list and a live sync that appears to do nothing — and the Embedded Signup window is
24 h, after which the whole onboarding restarts. The contacts do not come back in the
response; they arrive as smb_app_state_sync webhooks over the following minutes.
requestHistorySync is the same endpoint for the 180-day backfill.
5. Offboarding and reconnection — US-7.5
A merchant can disconnect the API from their phone at any moment, without telling anyone.
account_offboarded is the only warning, and from that instant every send fails with a
config-class Graph error that reads like a token problem — which is how an operator spends an
afternoon rotating credentials that were fine.
import { assertNumberSendable, offboardTransition } from "@genuka/whatsapp/coexistence";
assertNumberSendable(connection); // throws WhatsAppError { errorClass: "config" } when offboarded- Suspend sends — refuse locally, before the request is built, with a message that names the
real cause.
configclass means "stop, alert an operator": no retry brings the number back, only the merchant reconnecting does. - Notify — offboarding is a business event. Somebody has to ask the merchant to reconnect.
It surfaces as
coexistence.offboarded. - Keep everything — no cascade, no cleanup. Reconnection is one field going back to
null.
The original offboardedAt is preserved on redelivery: it is the timestamp a billing job reads,
and moving it forward would silently extend a period nobody paid for. Reconnection returns the
number to connected rather than to whatever it was before — a stale flagged we cannot verify
would suspend a number Meta considers healthy, and Meta re-asserts real restrictions within
minutes through its own webhooks.
Wiring: assertNumberSendable(connection) belongs in sendDirectMessage and launchCampaign
(lib/billing/send.ts), right next to the existing assertCompanyActive(connection.company) —
the one door every send already goes through.
6. Where the state lives
| Concern | Table | Key |
|---|---|---|
| Import phases | CoexistenceImport | (connectionId, phase) |
| History + echo messages | Message | providerMessageId (unique) |
| Address book | Contact | (companyId, waId), deletedAt for soft delete |
| Lifecycle | WhatsappConnection | offboardedAt, status |
lib/coexistence/ingest.ts is the seam webhook ingestion calls. It is idempotent,
order-independent, and it never throws: Meta already received its 200 by the time it runs, so
a throw would only lose the event. Failures are logged and recorded on the phase row.
7. Assumptions about Meta's payload shapes
Coexistence is Meta's least documented surface. Every guess below is marked ASSUMPTION in the
source, the raw message object is kept verbatim on Message.content, and the raw phase marker is
kept on the import row — so a wrong guess costs a re-read of stored rows, never a re-import.
| Where | Assumption |
|---|---|
history envelope | value.history[] of { metadata, threads[] }; a top-level threads is accepted as one anonymous chunk |
history phase marker | an index (0, "1", "phase_2") or a day range ("0-1", "day_90_to_180"); digits are extracted and matched against both |
history phase completion | metadata.progress === 100, or is_final / is_last / status: "completed" |
history thread identity | id / wa_id / chat_id / contact_id, name under contact.full_name / profile.name |
history direction | from_me, else an explicit direction, else from === threadId |
| timestamps | unix seconds; a value beyond 1e12 is read as milliseconds |
smb_message_echoes | value.message_echoes[] of standard message objects with from and to; messages[], recipient_id, chat_id and a nested message object accepted |
smb_app_state_sync | value.state_sync[] of { type: "contact", action, contact: { phone_number | wa_id, full_name } }; flat contacts[] accepted |
| SMB App Data API | POST /{PHONE_NUMBER_ID}/smb_app_data with { messaging_product, sync_type } |
account_offboarded | no documented timestamp; timestamp / event_time / offboarded_at used when present, arrival time otherwise |
When one of these is confirmed or contradicted by a real payload, the fix is a single function — that is the point of keeping them isolated.