Projo FHIR Documentation
0.1.0 - ci-build
Projo FHIR Documentation - Local Development build (v0.1.0) built by the FHIR (HL7® FHIR® Standard) Build Tools. See the Directory of published versions
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.
Task?status=ready notification onto the fhir-events queue, consumed by handleTaskDue.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.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.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.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.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)
/whatsappWebhook endpoint.whatsappWebhook demultiplexes the payload: user messages → wa-inbound queue; status callbacks → wa-status queue.waInboundProcessor runs the following pipeline:
Communication?identifier=<wamid-system>|<wamid> — if a Communication already carries this Meta message id, the event is a duplicate and is discarded.Communication?recipient=...&status=in-progress,completed&_sort=-sent&_count=1 decides whether a session message is still admissible or whether a template is required.scanSenderIdentities({phone: from, bsuid: userId}) over IDENTITY_LOOKUP_SENDER_TYPES; scanReceiverIdentities(businessPhoneNumber) over IDENTITY_LOOKUP_RECEIVER_TYPES.CommunicationRequest?recipient=Patient/{id}&status=active.getOrCreateQR reads the QR ref from cr.payload[]. If absent, buildQuestionnaireResponse constructs one and ensureCRPayloadCarriesQRRef writes it back.applyAnswerToQR updates the QR inside a FHIR transaction bundled atomically with the inbound Communication.partOf=[CR], sender=Patient/{id}, identifier=[wamid], category=answer.Communication (category=question).last-inbound-message-timestamp extension on the CR.QuestionnaireResponse.status=completed and CommunicationRequest.status=completed.cr-status-events Subscription delivers the completed CR to handleCommunicationRequestStatus, which reflects the status onto the originating Task and runs extraction + clinical routing.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-requiredidentifier[] = [phone | bsuid] from the senderrecipient[] = the resolved Patient, or identifier-only if unresolvedabout[] = sender hints (profile name, language, etc.)wamid, timestamp, and template idThe 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.
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 = reminderbasedOn = the parent CRmedium = whatsappidentifier = 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.
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
Communicationfrom 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.
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-invitationwaba-template-reminderwaba-template-web-invitationwaba-template-web-reminderIf an override is absent, the deployment-wide default template name is used.
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:
identifier[],Communication.sender,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.
A parallel channel lets a patient answer questionnaires in a browser instead of over WhatsApp. The web-functions app owns this surface:
category=web-invitation Communication).GET renders the questionnaire.POST writes the QuestionnaireResponse and completes the CommunicationRequest atomically (single FHIR transaction).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 ready — not cancelled — so WhatsApp dispatch fires again and the patient is re-invited.