DEV Community

Cover image for WhatsApp Message Statuses Arrive Out of Order: Modelling Sent, Delivered, Read and Failed
WAPGROWAI for WAPGROWAI

Posted on

WhatsApp Message Statuses Arrive Out of Order: Modelling Sent, Delivered, Read and Failed

Every outgoing WhatsApp message produces a few status webhooks: sent, delivered, read, or failed. If you store whichever one arrived last, your data goes wrong in ways that are hard to spot: a "read" message drops back to "delivered", a failure is overwritten by a late "sent", or a status arrives for a message you haven't saved yet and is lost. Treat delivery as a state that only moves forward, keep each status's timestamp separately, accept that delivered is sometimes never sent, and hold statuses that arrive before the message row exists.

The snippets are small, self-contained versions of the patterns, and each was run against the cases shown.

What Meta sends, and what it doesn't promise

Meta's status webhook reference defines the statuses:

  • sent: "the message was successfully sent from our servers."
  • delivered: "message was successfully delivered to the WhatsApp user's device."
  • read: "the message was displayed in an open chat thread in the WhatsApp user's device."
  • failed: "failure to send or deliver the message to the WhatsApp user's device", with an errors array.
  • played: the first time a voice message is played.

Three things that docs don't promise shape the design:

  • Order. Nothing in Meta's webhook docs says statuses arrive in order. Each one is its own webhook request, so plan for any order.
  • Uniqueness. Webhooks your endpoint doesn't acknowledge with a 200 are retried, with decreasing frequency, for up to 7 days, and the webhooks overview warns that "These retries can result in duplicate webhook notifications."
  • Every status. When a message is delivered and read at the same time, for example because the customer is already in the chat, the status reference says "the "delivered" webhook is not sent because it's implied that the message was delivered since it was read." Code that waits for delivered before showing read will wait forever.

A state that only moves forward

Give the statuses a rank, and only move up it. failed sits outside the ladder: it ends the message, but only if the phone never had it.

// A message's delivery state: the status only moves forward, but every
// timestamp is kept, however late its webhook arrives.
export type Status = "accepted" | "sent" | "delivered" | "read" | "failed";

export type MessageState = {
  status: Status;
  sentAt?: number;
  deliveredAt?: number;
  readAt?: number;
  failedAt?: number;
  errorCode?: number;
};

const RANK: Record<Exclude<Status, "failed">, number> = { accepted: 0, sent: 1, delivered: 2, read: 3 };

export const applyStatus = (state: MessageState, status: Status, at: number, errorCode?: number): MessageState => {
  const next = { ...state };
  if (status === "sent") next.sentAt ??= at;
  // The real delivery time wins over one inferred from a read receipt.
  if (status === "delivered") next.deliveredAt = Math.min(next.deliveredAt ?? at, at);
  if (status === "read") {
    next.readAt ??= at;
    // A read message was delivered, even if that webhook never comes.
    next.deliveredAt ??= at;
  }

  if (status === "failed") {
    // A failure only counts before the phone has the message.
    if (next.deliveredAt === undefined && next.readAt === undefined) {
      return { ...next, status: "failed", failedAt: at, errorCode };
    }
    return next;
  }

  if (state.status === "failed") return next;
  return RANK[status] > RANK[state.status] ? { ...next, status } : next;
};
Enter fullscreen mode Exit fullscreen mode

Each rule, and the case it protects against:

  • Rank, don't replace. A late delivered after read doesn't move the message back. A duplicate read changes nothing.
  • Keep every timestamp. The status column answers "where is it now"; the timestamps answer "when did each thing happen", which is what reply-time and read-rate reports need. A late delivered still records its time.
  • Infer delivered from read. Meta doesn't send delivered when read covers it, so set deliveredAt from the read. If a real delivered turns up later with an earlier time, it wins.
  • Fail only before delivery. A failed after sent is a real failure: record it with its error code. A failed arriving after the message was delivered or read contradicts what you already know, so keep the delivered state and log the oddity.
  • Nothing revives a failure. Once failed, a stray sent doesn't turn it back into a success.

Do it in one statement

Reading the row, deciding in code and writing it back works until two webhook workers handle two statuses for the same message at once. Then the second write can overwrite the first. Put the rule in the UPDATE itself, so the database applies it atomically:

// The same forward-only rule as one atomic statement, so two webhook workers
// can't race each other. status_rank: accepted 0, sent 1, delivered 2, read 3.
export const STATUS_UPDATE_SQL = `
UPDATE messages
SET status      = CASE WHEN status <> 'failed' AND status_rank < :rank THEN :status ELSE status END,
    status_rank = CASE WHEN status <> 'failed' AND status_rank < :rank THEN :rank ELSE status_rank END,
    sent_at      = COALESCE(sent_at,      CASE WHEN :status = 'sent'                 THEN :at END),
    delivered_at = CASE
                     WHEN :status = 'delivered' AND (delivered_at IS NULL OR :at < delivered_at) THEN :at
                     WHEN :status = 'read' THEN COALESCE(delivered_at, :at)
                     ELSE delivered_at
                   END,
    read_at      = COALESCE(read_at,      CASE WHEN :status = 'read'                 THEN :at END)
WHERE wamid = :wamid`;

export const FAILED_UPDATE_SQL = `
UPDATE messages
SET status = 'failed', failed_at = :at, error_code = :code
WHERE wamid = :wamid AND delivered_at IS NULL AND read_at IS NULL`;
Enter fullscreen mode Exit fullscreen mode

A status_rank column makes "only move forward" a single comparison. The test runs these statements against SQLite with the same out-of-order sequences as the in-memory model.

Statuses that arrive before the message

There's a race at the start of every send. You call /messages, Meta returns the message ID, and you save the row. But the sent webhook can arrive while your code is still between those last two steps. A handler that looks up the message ID, finds nothing and returns has just lost that status, and if it was the failed one, the message will look "accepted" forever.

Hold statuses for unknown IDs, and replay them when the row is saved:

// A status can arrive before your own code has saved the message ID that the
// send returned. Hold it, then replay it once the message row exists.
import { applyStatus, type MessageState, type Status } from "./status-model.ts";

type Pending = { status: Status; at: number; errorCode?: number };

export const createStatusStore = () => {
  const messages = new Map<string, MessageState>();
  const early = new Map<string, Pending[]>();

  const onStatus = (wamid: string, status: Status, at: number, errorCode?: number) => {
    const state = messages.get(wamid);
    if (!state) {
      early.set(wamid, [...(early.get(wamid) ?? []), { status, at, errorCode }]);
      return "held" as const;
    }
    messages.set(wamid, applyStatus(state, status, at, errorCode));
    return "applied" as const;
  };

  const onSaved = (wamid: string) => {
    let state: MessageState = { status: "accepted" };
    for (const p of early.get(wamid) ?? []) state = applyStatus(state, p.status, p.at, p.errorCode);
    early.delete(wamid);
    messages.set(wamid, state);
    return state;
  };

  return { onStatus, onSaved, get: (wamid: string) => messages.get(wamid) };
};
Enter fullscreen mode Exit fullscreen mode

In production, the holding area is a table, and the replay runs inside the transaction that saves the message. Expire held statuses after a while, because some will be for messages sent by other tools on the same number.

Things that look like statuses but aren't

  • Marking inbound messages as read is a request you make, POST /<PHONE_NUMBER_ID>/messages with "status": "read" and the inbound message_id. It's unrelated to the read receipts you receive for messages you sent.
  • Pricing information rides on the status webhooks too. The comments in Meta's sample payload say pricing is "only included with sent status, and one of either delivered or read status". If your billing reports read it, take it from whichever status carries it, not only from delivered.

Checklist

  • Rank statuses and only move forward; never overwrite with "the latest webhook".
  • Keep sent_at, delivered_at and read_at separately.
  • Treat read as implying delivered; Meta may skip the delivered webhook.
  • Accept failed only before delivery, store its error code, and never revive a failed message.
  • Apply the rule in a single conditional UPDATE.
  • Hold statuses for unknown message IDs and replay them when the row is saved.
  • Expect duplicates, and make every status update idempotent.

WAPGROWAI is built by DI Solutions, a custom software and AI development company. This series covers what we've learned building WAPGROWAI on the WhatsApp Cloud API. Related: why template sends fail silently and handling webhooks in Node.js.

Top comments (0)