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.