Skip to content

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 stateEventTo stateSide effects
pending or sentdelivereddeliveredSets deliveredAt = occurredAt
pending, sent, or deliveredreadreadSets readAt = occurredAt; if deliveredAt is null, also sets deliveredAt = occurredAt
any non-terminalfailedfailedSets errorCode, errorMessage
anyany backward moveno-opEvent is discarded; no DB write; no integration event published
failedanyno-opfailed 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:

  • deliveryStatus transitions to read
  • readAt is set to occurredAt
  • deliveredAt (currently null) is ALSO set to occurredAt

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'  → UpdateMessageDeliveryStatusCommand

UpdateMessageDeliveryStatusCommand ​

Handler steps:

  1. Look up Message by (orgId, externalMessageId).
  2. If not found: log WARN communications.delivery_status.unknown_external_id and return (drop-and-log). The webhook job MUST NOT fail.
  3. If message.direction !== 'outbound': log WARN communications.delivery_status.non_outbound_message and return (defensive guard — Meta should never send status events for inbound messages).
  4. Call message.transitionDeliveryStatus(status, occurredAt, errorCode?, errorMessage?).
  5. If transition returned false (no-op): return without writing or publishing.
  6. If transition returned true (state changed): persist the updated Message, then publish MessageDeliveryStatusChangedIntegrationEvent AFTER 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 delivered event when deliveryStatus is already delivered is a rank-equal no-op (not strictly higher).
  • A duplicate read event when already read is 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:

EventLevelWhen
communications.delivery_status.unknown_external_idWARNWebhook for externalMessageId not in DB
communications.delivery_status.non_outbound_messageWARNWebhook for an inbound message (defensive)
communications.delivery_status.transitionedINFOSuccessful state change (status, messageId)
communications.delivery_status.noopDEBUGMonotonic guard dropped a backward/duplicate transition

Scope Limitations (v1) ​

  • WhatsApp only. Messenger and Instagram status subscriptions are not activated in this iteration.
  • MessageDeliveryStatusChangedIntegrationEvent is published to the in-process EventBus (not queued via BullMQ). If consumer set grows, a durable queue should be introduced.
  • readAt is exposed in IMessageResponse. Whether agents should see the exact read timestamp is a UX/privacy decision left to a future iteration — it is currently surfaced.