Genuka WA docs

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, under lib/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 APICoexistence
Throughput80 → 1 000 mps20 mps, fixed
Catalog / orders via APIyesno — they stay in the app
Voice / video callingyesno
History, contact sync, echoesnoyes

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-180 before 0-1 is 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" → false

Echo 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
  1. Suspend sends — refuse locally, before the request is built, with a message that names the real cause. config class means "stop, alert an operator": no retry brings the number back, only the merchant reconnecting does.
  2. Notify — offboarding is a business event. Somebody has to ask the merchant to reconnect. It surfaces as coexistence.offboarded.
  3. 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

ConcernTableKey
Import phasesCoexistenceImport(connectionId, phase)
History + echo messagesMessageproviderMessageId (unique)
Address bookContact(companyId, waId), deletedAt for soft delete
LifecycleWhatsappConnectionoffboardedAt, 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.

WhereAssumption
history envelopevalue.history[] of { metadata, threads[] }; a top-level threads is accepted as one anonymous chunk
history phase markeran index (0, "1", "phase_2") or a day range ("0-1", "day_90_to_180"); digits are extracted and matched against both
history phase completionmetadata.progress === 100, or is_final / is_last / status: "completed"
history thread identityid / wa_id / chat_id / contact_id, name under contact.full_name / profile.name
history directionfrom_me, else an explicit direction, else from === threadId
timestampsunix seconds; a value beyond 1e12 is read as milliseconds
smb_message_echoesvalue.message_echoes[] of standard message objects with from and to; messages[], recipient_id, chat_id and a nested message object accepted
smb_app_state_syncvalue.state_sync[] of { type: "contact", action, contact: { phone_number | wa_id, full_name } }; flat contacts[] accepted
SMB App Data APIPOST /{PHONE_NUMBER_ID}/smb_app_data with { messaging_product, sync_type }
account_offboardedno 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.

On this page