Projo FHIR Documentation
0.1.0 - ci-build Switzerland flag

Projo FHIR Documentation - Local Development build (v0.1.0) built by the FHIR (HL7® FHIR® Standard) Build Tools. See the Directory of published versions

WhatsApp Mapping

WhatsApp Mapping

Projo's WhatsApp integration is a split-brain design: the orchestrator (projo) speaks FHIR only, the sidecar (whatsapp-functions) speaks FHIR to projo and the Meta Cloud API to WhatsApp. The two halves are coupled by a single FHIR resource — the CommunicationRequest (CR) — and never share code, state, or credentials.

Outbound flow (questionnaire delivery)

  1. A per-CarePlan due-event Subscription fires a Task?status=ready notification onto the fhir-events queue, consumed by handleTaskDue.
  2. handleTaskDue creates a CommunicationRequest with medium=whatsapp carrying the Questionnaire reference in payload[]. This CR is the entire contract surface between projo and the sidecar — projo never invokes the Cloud API directly.
  3. The cr-events Subscription (criteria CommunicationRequest?status=active,revoked&medium=whatsapp&category:not=identity-resolution) delivers the new CR to the sidecar's cr-events queue.
  4. crEventRouter on the sidecar resolves the CR, sends the WhatsApp invitation template, and writes a category=invitation Communication linked back to the CR via partOf.
  5. The sidecar resolves the sender Organization's cached wa-phone-number-id extension. If absent, it queries the Meta Graph API GET /{WABA-ID}/phone_numbers, matches by normalized digits, and caches the result back onto the Organization.
  6. The sidecar sends each question template and writes a category=question Communication per message.
sequenceDiagram
    participant FA as Fire Arrow
    participant Q as fhir-events queue
    participant P as projo (handleTaskDue)
    participant FHIR as FHIR server
    participant CRQ as cr-events queue
    participant S as sidecar (crEventRouter)
    participant M as Meta Cloud API

    FA->>Q: Task?status=ready notification
    Q->>P: dequeue
    P->>FHIR: POST CommunicationRequest (medium=whatsapp, payload=[Questionnaire])
    FHIR-->>P: 201 Created
    Note over FHIR,S: cr-events Subscription fires
    FHIR->>CRQ: deliver CR
    CRQ->>S: dequeue
    S->>FHIR: GET sender Organization (+ wa-phone-number-id ext)
    alt phone-number-id missing
        S->>M: GET /{WABA-ID}/phone_numbers
        M-->>S: phone numbers
        S->>FHIR: PATCH Organization (cache wa-phone-number-id)
    end
    S->>M: send invitation template
    S->>FHIR: POST Communication (category=invitation, partOf=CR)
    S->>M: send question template(s)
    S->>FHIR: POST Communication (category=question, partOf=CR)

Inbound flow (patient reply)

  1. Meta posts the inbound event to the sidecar's /whatsappWebhook endpoint.
  2. whatsappWebhook demultiplexes the payload: user messages → wa-inbound queue; status callbacks → wa-status queue.
  3. waInboundProcessor runs the following pipeline:
    • Idempotency: Communication?identifier=<wamid-system>|<wamid> — if a Communication already carries this Meta message id, the event is a duplicate and is discarded.
    • 24h window: Communication?recipient=...&status=in-progress,completed&_sort=-sent&_count=1 decides whether a session message is still admissible or whether a template is required.
    • Identity scan: scanSenderIdentities({phone: from, bsuid: userId}) over IDENTITY_LOOKUP_SENDER_TYPES; scanReceiverIdentities(businessPhoneNumber) over IDENTITY_LOOKUP_RECEIVER_TYPES.
    • Active CR lookup: CommunicationRequest?recipient=Patient/{id}&status=active.
    • QuestionnaireResponse: getOrCreateQR reads the QR ref from cr.payload[]. If absent, buildQuestionnaireResponse constructs one and ensureCRPayloadCarriesQRRef writes it back.
    • Apply answer: applyAnswerToQR updates the QR inside a FHIR transaction bundled atomically with the inbound Communication.
    • Inbound Communication: partOf=[CR], sender=Patient/{id}, identifier=[wamid], category=answer.
    • Next question: sends the next question Communication (category=question).
    • Timestamp: stamps the last-inbound-message-timestamp extension on the CR.
  4. On the final answer: QuestionnaireResponse.status=completed and CommunicationRequest.status=completed.
  5. The cr-status-events Subscription delivers the completed CR to handleCommunicationRequestStatus, which reflects the status onto the originating Task and runs extraction + clinical routing.

Unroutable inbound (reverse CR)

When identity resolution cannot reattach an inbound message to an active CR (see Identity Resolution), the sidecar files a reverse CommunicationRequest rather than dropping the message:

  • category = identity-resolution-required
  • identifier[] = [phone | bsuid] from the sender
  • recipient[] = the resolved Patient, or identifier-only if unresolved
  • about[] = sender hints (profile name, language, etc.)
  • inbound-context extensions carrying the originating wamid, timestamp, and template id

The reverse CR and the inbound Communication are written in a single atomic FHIR transaction (one bundle). The identity-resolution-events Subscription picks the CR up and routes it to projo's identity-resolution pipeline.

Reminder flow

The orchestrator's sweeper periodically scans active CRs for the next-reminder-timestamp extension. When the timestamp has passed, it issues a reminder CR via conditional create:

  • category = reminder
  • basedOn = the parent CR
  • medium = whatsapp
  • identifier = conditional-create key (prevents duplicate reminders for the same parent + slot)

The sidecar's cr-events Subscription picks the reminder CR up, sends the WhatsApp reminder template, and marks the reminder CR completed.

24-hour window logic

Meta's Cloud API distinguishes session messages (free-form text within 24h of the last inbound user message) from template messages (pre-approved templates, required outside the window). Projo's rule:

If the most recent inbound Communication from this sender is older than 24 hours, the next outbound must use a template.

The window is BSUID-aware — it is computed per business phone number (per-WABA), not globally. A patient conversing with two tenants has two independent windows.

Multi-WABA support (issue #238)

Each Organization carries its own wa-phone-number-id and waba-account-id extensions; there is no global WABA identity. The sidecar resolves per-WABA credentials from Azure Key Vault at runtime — never from the FHIR server — and selects the correct credential set from the CR's sender Organization.

Org-specific template overrides are read from the Organization's extensions:

  • waba-template-invitation
  • waba-template-reminder
  • waba-template-web-invitation
  • waba-template-web-reminder

If an override is absent, the deployment-wide default template name is used.

Sender addressing: E.164 vs BSUID

Meta's webhook payload may omit from (the E.164 phone number) while still providing from_user_id (or contacts[].wa_id) — the BSUID, a WhatsApp-internal stable user identifier. The sidecar requires at least one of the two and treats BSUID as a first-class input for:

  • routing decisions,
  • reverse-CR identifier[],
  • inbound Communication.sender,
  • the outbound Cloud API recipient field (BSUD is accepted by Meta where a phone is accepted).

BSUIDs persist across phone-number changes on the user's device, so anchoring identity on BSUID is more stable than anchoring on E.164.

Web channel (ADR 0011)

A parallel channel lets a patient answer questionnaires in a browser instead of over WhatsApp. The web-functions app owns this surface:

  1. Patient taps the web-answer-link in the WhatsApp bubble (a category=web-invitation Communication).
  2. GET renders the questionnaire.
  3. POST writes the QuestionnaireResponse and completes the CommunicationRequest atomically (single FHIR transaction).
  4. "Next" chains the browser session to the next due Task rather than returning to WhatsApp.

Abort path: sessionSweeper revokes the CR after WEB_ANSWER_SESSION_TTL_MINUTES. The orchestrator then reverts the originating Task to readynot cancelled — so WhatsApp dispatch fires again and the patient is re-invited.