Appearance
Delivery Status Tracking
When a WhatsApp message is delivered to the end-user's device, read by them, or fails to deliver, Meta sends a status webhook event. The system processes these events to update the Message's deliveryStatus field and emits a MessageDeliveryStatusChangedIntegrationEvent for downstream consumers (e.g. panel real-time updates).
Scope: WhatsApp only in this iteration. Messenger and Instagram do not have status webhook subscriptions enabled yet.
State Machine
pending ──► sent ──► delivered ──► read
│ │ │ │
└──────────┴───────────┴────► failed (terminal)Transition rules
| From state | Event | To state | Side effects |
|---|---|---|---|
pending or sent | delivered | delivered | Sets deliveredAt = occurredAt |
pending, sent, or delivered | read | read | Sets readAt = occurredAt; if deliveredAt is null, also sets deliveredAt = occurredAt |
| any non-terminal | failed | failed | Sets errorCode, errorMessage |
| any | any backward move | no-op | Event is discarded; no DB write; no integration event published |
failed | any | no-op | failed is terminal — no further transitions |
Monotonic guarantee
Transitions are enforced by Message.transitionDeliveryStatus(status, occurredAt, ...) via a rank map:
pending=0, sent=1, delivered=2, read=3, failed=99 (terminal)A transition is accepted only if the incoming rank is STRICTLY HIGHER than the current rank. This handles out-of-order webhook delivery (e.g. a delivered event arriving after a read event is silently dropped).
failed is assigned rank 99 so it can be reached from any non-failed state, but nothing can transition out of it.
Read with missing delivered (Meta skips delivered event)
Meta may send a read event without a preceding delivered event. When this happens:
deliveryStatustransitions toreadreadAtis set tooccurredAtdeliveredAt(currently null) is ALSO set tooccurredAt
This prevents a null deliveredAt on a read message, which would be confusing for downstream consumers.
Webhook Pipeline
Status events arrive at POST /meta-webhook alongside inbound message events. The normalizer extracts status events and the ProcessIncomingMetaEventHandler dispatches them:
Meta webhook POST /meta-webhook
→ MetaWebhookController
→ ProcessIncomingMetaEventHandler
│
├─ kind='message' → PersistIncomingMessageCommand
└─ kind='status' → UpdateMessageDeliveryStatusCommandUpdateMessageDeliveryStatusCommand
Handler steps:
- Look up
Messageby(orgId, externalMessageId). - If not found: log
WARN communications.delivery_status.unknown_external_idand return (drop-and-log). The webhook job MUST NOT fail. - If
message.direction !== 'outbound': logWARN communications.delivery_status.non_outbound_messageand return (defensive guard — Meta should never send status events for inbound messages). - Call
message.transitionDeliveryStatus(status, occurredAt, errorCode?, errorMessage?). - If transition returned
false(no-op): return without writing or publishing. - If transition returned
true(state changed): persist the updated Message, then publishMessageDeliveryStatusChangedIntegrationEventAFTER the transaction commits.
Integration Event
Published after every effective state change:
ts
// apps/api/src/shared/integration-events/communications/message-delivery-status-changed.integration-event.ts
{
orgId: string,
conversationId: string,
messageId: string,
status: 'delivered' | 'read' | 'failed',
occurredAt: Date,
errorCode?: string, // present when status='failed'
errorMessage?: string // present when status='failed'
}pending and sent are never emitted — Meta does not send webhook events for those states; they are set locally by the outbound send pipeline.
Error Handling on Failed Status
When a failed status arrives with error detail:
json
{
"kind": "status",
"status": "failed",
"errors": [{ "code": "131026", "title": "Receiver incapable of receiving messages" }]
}The handler persists message.errorCode = '131026' and message.errorMessage = 'Receiver incapable of receiving messages' on the Message row. These fields are surfaced in the IMessageResponse DTO for the panel to display an error badge.
Idempotency
Webhook events may be delivered more than once by Meta. The state machine is idempotent:
- A second
deliveredevent whendeliveryStatusis alreadydeliveredis a rank-equal no-op (not strictly higher). - A duplicate
readevent when alreadyreadis likewise a no-op. - No-op transitions do NOT publish
MessageDeliveryStatusChangedIntegrationEvent.
This means even if Meta delivers the same webhook 10 times, the panel receives exactly one event per actual state transition.
Observability
Log events emitted by the delivery-status pipeline:
| Event | Level | When |
|---|---|---|
communications.delivery_status.unknown_external_id | WARN | Webhook for externalMessageId not in DB |
communications.delivery_status.non_outbound_message | WARN | Webhook for an inbound message (defensive) |
communications.delivery_status.transitioned | INFO | Successful state change (status, messageId) |
communications.delivery_status.noop | DEBUG | Monotonic guard dropped a backward/duplicate transition |
Scope Limitations (v1)
- WhatsApp only. Messenger and Instagram status subscriptions are not activated in this iteration.
MessageDeliveryStatusChangedIntegrationEventis published to the in-process EventBus (not queued via BullMQ). If consumer set grows, a durable queue should be introduced.readAtis exposed inIMessageResponse. Whether agents should see the exact read timestamp is a UX/privacy decision left to a future iteration — it is currently surfaced.