Your message was accepted. Has the customer actually received it?

WhatsApp delivery tracking starts to mislead people when an application calls every successful API response “delivered”. Your server submitted a message, but the customer may not have received it yet. A support agent looking at that label can make the wrong decision about following up.

Let’s separate submission from delivery first. Once those mean different things in the database, we can handle repeated and delayed events without making the dashboard jump backwards.

Save the submission outcome honestly

When the WhatsApp Cloud API returns a message ID, store it with the local send intent. Label that intent accepted. Keep the time of submission, the response time and any error classification.

A timeout is a different outcome. The provider may have accepted the message even though the response never reached you. Mark that send uncertain and investigate it through supported correlation or reconciliation. Don’t invent a provider message ID and don’t automatically assume it failed.

The local intent ID and provider message ID serve different purposes. Keep both. A customer reply may quote the provider ID, while your retry and audit logic needs the stable internal operation.

Treat status callbacks as events

Store status callbacks in an event table before updating the dashboard view. A useful record includes the business phone ID, provider message ID, status, provider timestamp, receive time and any error details.

Repeated delivery of an event should be safe. Prefer the provider’s event identifier when available. If it doesn’t supply one, define a documented deduplication key from the relevant fields and preserve the raw event for investigation. Don’t assume timestamp alone is unique.

Provider webhook documentation describes delivery-status notifications separately from message submission. Check the exact payload for the integration you use. WhatsApp provider webhook reference.

Build the dashboard projection

We need a derived view, sometimes called a projection, from the stored events. For the ordinary progress path, evidence of read is stronger than an earlier sent callback. A late sent event shouldn’t erase that evidence.

Failure needs more care. It isn’t simply another step after read. If contradictory events appear, preserve them and raise a conflict instead of displaying whichever one arrived last.

import assert from 'node:assert/strict';
function project(events) {
  const unique = new Map(events.map(e => [e.id, e]));
  const statuses = new Set([...unique.values()].map(e => e.status));
  if (statuses.has('failed') && (statuses.has('delivered') || statuses.has('read'))) return 'conflict';
  if (statuses.has('read')) return 'read';
  if (statuses.has('delivered')) return 'delivered';
  if (statuses.has('failed')) return 'failed';
  if (statuses.has('sent')) return 'sent';
  return 'accepted';
}
const read = { id: 'r', status: 'read' };
const sent = { id: 's', status: 'sent' };
assert.equal(project([read, sent, read]), 'read');
assert.equal(project([sent, read]), 'read');
assert.equal(project([{ id: 'f', status: 'failed' }]), 'failed');
assert.equal(project([read, { id: 'f', status: 'failed' }]), 'conflict');
assert.equal(project([]), 'accepted');
console.log('PASS: duplicate and reordered status evidence; conflicts remain visible');

The example tests repeated events and a delayed lower-progress event. It also treats failure-only evidence differently from conflicting delivery and failure evidence.

This is an application policy, not a replacement for the provider’s documented status semantics. Adapt the recognised statuses and conflict rules to your actual payloads.

Handle a status arriving before your send row is ready

Callbacks and your API response handling don’t share a transaction. A callback can reach your system before the worker commits the provider ID to the send-intent row.

Don’t discard an unknown message ID immediately. Store an orphan event keyed by the business and provider ID, then attach it when the send record becomes available. Put an age limit and alert on unresolved events so a mapping bug doesn’t create an invisible backlog.

For multi-tenant applications, scope every lookup by the business phone or verified account context. Matching only on a caller-supplied customer number can attach delivery evidence to the wrong business.

Keep the event history behind the current label

A dashboard should make the current state easy to understand while retaining the evidence behind it. Show separate submission and delivery times. When sending is uncertain, use that word rather than a reassuring green tick.

Store errors in structured fields so you can group failures without exposing tokens or personal content in logs. An agent may need an explanation such as “provider rejected the recipient”, while an engineer needs the original code and correlation IDs.

Don’t derive message read time from a customer’s later chat activity. That may be useful engagement information, but it isn’t a read receipt for the message you’re tracking.

Test a messy event timeline

Take the same status history and deliver it in several orders. Duplicate a callback, delay another, and deliver one before the local send mapping exists. The final view should reflect the same evidence or explicitly show a conflict.

Also restart the projection worker between storing an event and updating the view. On replay, the update should be safe to repeat. Recording a deduplication marker before applying the state change in a separate transaction can accidentally lose that change.

Track accepted-to-delivered latency only for records with the evidence to calculate it. Report missing and unresolved observations separately. Excluding them silently can make delivery look faster as failures increase.

Start by renaming your API-success label to accepted. That gives operators an honest distinction immediately. Then build the event history and projection so the later labels have evidence behind them.

Run the example and adapt it

Save the code as 06-message-delivery-tracker.mjs and run it with Node.js 24 or later:

node 06-message-delivery-tracker.mjs

The code above includes assertions you can run locally. It tests the local decision logic with controlled inputs; it doesn’t connect to a live provider or establish production performance. Keep that boundary when adapting it to your application.

Technical references