Inbox reaches WhatsApp through two distinct channels, and they are separate networks in
MESSAGING_NETWORKS, not variants of one. They have different connect flows, different credentials,
different payload shapes and different sending rules, so treating them as one thing produces code
that is wrong for both.
whatsapp |
whatsapp_session |
|
|---|---|---|
| What it is | Business / Cloud API number | QR-linked personal session (WhatsApp Web) |
| Adapter | whatsapp |
whatsapp_session |
| Providers | gupshup, zernio, infobip |
wasender, openwa (default wasender) |
| Connect | No self-serve — import credentials or request it | qr / pairingCode, then a human links a handset |
| Cold outreach | Requires an approved template | Plain text works |
| 24-hour window | Applies | Applies |
| Practical limit | Provider tier caps, template approval | Daily cap and warm-up |
Both serve exactly messages and connection — publish and comments are structurally
impossible on a messaging network.
whatsapp — the business API
WhatsAppAdapter.startConnect throws capability_unavailable on purpose: WhatsApp Business
accounts are provisioned by the vendor, not through an OAuth redirect inbox owns. There is no
self-serve flow to offer, so it says so rather than stubbing a redirect that fails confusingly.
You get a connection either by importing credentials with
admin, or by filing a connection request and letting an
operator connect it.
gupshup and zernio share one CloudApiProvider — both proxy Meta's Graph endpoint verbatim.
infobip has its own provider implementation. The credential's provider field selects between
them at send time; an unregistered key throws rather than silently sending nothing.
Webhook verification
POST /webhooks/whatsapp accepts either scheme, checked in this order:
- Shared secret.
x-infobip-authcompared againstINBOX_WHATSAPP_PROVIDER_REPORT_SECRETwith a timing-safe comparison. This is the provider-report path. - HMAC.
x-hub-signature-256, which must start withsha256=, verified asHMAC-SHA256(rawBody)under the WhatsApp webhook secret, length-checked beforetimingSafeEqual(which throws on a length mismatch).
The body is parsed before either check; unparseable JSON is rejected outright. verifyChallenge
handles the Meta-style subscription handshake — hub.mode=subscribe plus a hub.verify_token
matching the webhook secret echoes hub.challenge — which the Cloud API vendors proxy verbatim.
Template approval status arrives as its own webhook and normalizes to a template.status event:
not a thread event at all, it resolves the template by (network, remoteTemplateName) and
transitions its status to approved, rejected or pending.
whatsapp_session — the QR session
A session is linked by a human holding the handset. completeConnect runs when the codes have been
issued, not when anyone has used them, which is why this is the one adapter that returns
connected: false from connect — claiming otherwise would report a number as ready to send while no
handset is linked.
POST /v1/connections/whatsapp_session/start → { kind: "qr", qr, pairingCode? }
GET /v1/connections/:id/qr → { qr?, pairingCode?, status?, error? }
GET /v1/connections/:id/session-status → { connected, status?, needsAttention?, linkState? }
POST /v1/connections/:id/sync-contacts → { imported }
GET /v1/connections/:id/qr refreshes a code for a session still awaiting a scan. Both qr and
pairingCode rotate, and the adapter decides which this session is owed; error explains why
neither is present yet, so an empty poll response is diagnosable rather than blank.
GET /v1/connections/:id/session-status is the poll target while a QR is displayed. It reads the
vendor's live status, reconciles the local connected flag, and persists the current linkState
step onto every binding — so a session that moved qr_ready → linking → available is not still
advertising the step it was created on. A missed session.status webhook still converges through
this route. needsAttention separates "waiting will fix this" from "a human must act on the
handset"; both are otherwise just connected: false.
POST /v1/connections/:id/sync-contacts pulls the account's WhatsApp address book and imports it:
deduped against existing contacts via resolveOrCreate, each marked WhatsApp-reachable with a
whatsapp identifier set valid, and added to a segment named for the session. Group and broadcast
JIDs are skipped. It is idempotent — a re-sync creates no duplicate contacts, identifiers or segment
memberships. An adapter with no contact pull returns { "imported": 0 }.
Webhook verification
HMAC-SHA256 over the raw body, under INBOX_WHATSAPP_SESSION_WEBHOOK_SECRET. The signature is read
from x-openwa-signature, then x-webhook-signature, then x-signature; a sha256= prefix is
stripped when present.
Connect refuses to run without that secret, naming the variable — because without it every inbound delivery would fail verification and the session would link and then receive nothing. The secret must be 16–255 characters: that is the gateway's own constraint on registration, checked locally so the failure names your env var instead of arriving as a 400 three calls into the connect.
The two gateways can feed the same endpoint at once. openwa deliveries are recognised by shape —
a camelCase sessionId alongside a deliveryId/idempotencyKey envelope — not by a process-wide
provider switch.
The 24-hour messaging window
assertMessagingWindow(lastInboundAt) in apps/service/src/integrations/messaging-window.ts is the
gate on every free-form send. STANDARD_DM_WINDOW_SECONDS is 24 hours, measured from
thread.lastInboundAt — the provider's event time for the customer's last inbound message, not
when inbox happened to process it.
if (!lastInboundAt) return; // no inbound yet: the adapter's call
const ageSeconds = (Date.now() - lastInboundAt.getTime()) / 1000;
if (ageSeconds > windowSeconds) throw new AxisError('messaging_window_expired');
messaging_window_expired is a 422. A thread with no inbound at all is allowed through — first
contact rules belong to the adapter, not to this function. It runs on the thread reply path and on
campaign sends, so there is no way to route around it by choosing a different endpoint.
Opening a cold conversation
Once the window has lapsed, a plain reply cannot reach the contact. A pre-approved template can:
POST /v1/connections/{connectionId}/send-template
Idempotency-Key: 6f1e…
x-axis-tenant: ws_…
Scope inbox; returns 200. It sends a provider template to one address with no existing thread —
the cold-send path — keyed by the connection, so credentials and provider come from that connection.
This is also the flow-engine's entry node: FLOW_ENTRY_TYPE is send-template, the only node type
that may open a flow.
On whatsapp_session there is no template requirement, because there is no template catalogue to
approve against. What bites there instead is volume: session campaign sends are guarded by a daily
cap (default 200, campaignSessionDailyCap) narrowed further by the sender's warm-up state, plus a
sender-conflict window so two campaigns cannot occupy the same handset at once.
Warm-up state surfaces on ConnectionDto.warmup as { qualityRating, cooldownUntil, warmupDay, graduated }, present only when a warm-up row exists for the connection. There is deliberately no
qualityStatus field — the provider gives a rating and a cooldown window, and any status is derived
from those.