Skip to content

Communication architecture ​

How a business event in QRSETU becomes a WhatsApp message on a customer's phone, and how what happened to that message comes back. The complete path:

QRSETU app / Edge Function → outbox → dispatcher → Meta Cloud API → customer → webhooks → event ledger → derived status → Admin Communication Hub / analytics.

NOTHING ON THIS PAGE IS BUILT

This is the approved-pending design (ADR-0029), written before the code. Two prerequisites are open and named rather than assumed: the outbox has no drain worker (the public.outbox table exists; nothing drains it — the worker is shared scope with ADR-0025/0027), and the outbox topic CHECK constraint must gain comm.send by migration.

System context ​

The design rule underneath: features never talk to Meta. A feature records what should be said (a communication_messages row + an outbox entry, in the same transaction as the business write — the ADR-0027 enqueue discipline); only the dispatcher knows how to say it, and only the webhook EF knows how to hear back. That is what keeps a future channel (email, push) or a future tenant-owned WABA from touching any feature code.

The Edge Functions ​

Three, following the existing kit (_shared/: handleCors, requireAdmin, typed errors, ok/err, structured logging) and the repo's Type A/B/webhook taxonomy:

EFTypeJob
whatsapp-webhookWebhook (verify_jwt = false, signature-authenticated)GET answers Meta's hub.challenge verification handshake; POST verifies X-Hub-Signature-256 (HMAC SHA-256, app secret, constant-time compare), records the event verbatim, then processes — the razorpay-webhook pattern exactly, including idempotency via unique (provider, provider_event_id) and fast 200 (Meta retries with backoff and drops the subscription if the endpoint keeps failing)
communication-dispatchType B (internal, invoked by the outbox drain)loads due comm.send work, runs the policy gate (below), renders the template call, POST /{phone_number_id}/messages, writes the returned wamid, schedules retry on transient failure
manage-communicationType A (requireAdmin — blocked on the is_admin() decision, QRS-803)template create/edit/delete against POST /{waba_id}/message_templates, registry sync, campaign CRUD, consent administration

Secrets (all EF-side, never client): WHATSAPP_ACCESS_TOKEN (a System User token — see the assessment §2), WHATSAPP_APP_SECRET (webhook signatures), WHATSAPP_WEBHOOK_VERIFY_TOKEN (the GET handshake), WHATSAPP_PHONE_NUMBER_ID, WHATSAPP_WABA_ID. All six are ef_secret / auth_setting-class changes — the classes CLAUDE.md marks as invisible to every gate — so each deploy proves them with a live probe, never a claim.

The send path ​

The policy gate lives in exactly one place. Marketing without a consent row fails closed. A paused template or a per-user marketing cap (Meta error 131049) is treated as policy, not transience — alert, don't retry, because resending into a quality problem deepens it.

The receive path ​

Statuses arrive out of order (a read can precede its delivered), so communication_messages.status is derived from the event ledger by monotonic rank, never set directly from a webhook — the same rule orders.payment_status already follows, for the same reason. An event that correlates to no message dead-letters visibly (the payments lesson: noise is distinguishable from loss only if you keep it).

Retry model ​

Retries reuse the same message row and the same idempotency key; each attempt is an event. The automatic ladder stops at a small capped attempt count — beyond that, failure is information for the Admin Hub, and only a human re-drives.

Trigger map — what enqueues what ​

Platform eventRecipientTemplate categoryNotes
place-public-order succeedsbuyer (orders.buyer_phone, NOT NULL today)UTILITYorder reference + collect-on date; the buyer may be fully anonymous — phone is the identity
same eventmerchantUTILITY"new order" alert; complements the in-app notification, never replaces it
razorpay-webhook marks a payment collectedbuyerUTILITYpayment receipt with orders.reference
manage-order marks ready / collectedbuyerUTILITYthe Scan-to-collect loop's off-app half
signup / onboarding completedmerchantUTILITYwelcome + next step; any promotional content would force MARKETING categorization — keep it factual
reminder due (ADR-0016)merchantUTILITYa later channel for the existing reminders domain — expansion stays in @qrsetu/domain, the channel is delivery only
campaign executesconsented contactsMARKETINGconsent ledger is the audience source, fail closed

How this composes with what exists ​

  • Email (ADR-0008) keeps OTP and account email; WhatsApp becomes the primary customer-facing notification channel for India. The ADR-0008 transactional/promotional split (ZeptoMail vs Zoho Campaigns) maps 1:1 onto Meta's UTILITY vs MARKETING split — same consent model, same discipline.
  • In-app notifications stay the merchant product's surface; WhatsApp reaches the other side (buyers with no app) and merchants when they are not in the app. Proactive-principle guardrails apply unchanged: earn each interruption; a UTILITY receipt is earned by the transaction, a MARKETING send is earned by explicit opt-in.
  • The chat feature is a future bridge: whatsapp_inbound_messages keeps the raw material so a vendor's WhatsApp replies could one day surface through the same @qrsetu/domain chat logic — noted, not designed.
  • Scale-out to tenants (merchants/enterprises sending as themselves) changes rows, not shape: every message already carries workspace_id, every WABA row carries owner_workspace_id, and the dispatcher already resolves sender per message. Tech Provider onboarding, Embedded Signup and per-tenant billing are the P3+ programme in the assessment §6.