WhatsApp API & Developers

WhatsApp Message Status Webhooks: Sent, Delivered, Read

Your campaign report says 94% delivered. Your database says some messages are still "sent" from last Tuesday, a few went straight from nothing to "read", and one is somehow both delivered and failed. Nobody's lying. That's what WhatsApp message status webhooks look like when you store them one at a time in arrival order.

Statuses are the only way to know what happened after the API accepted your send. They tell you whether a message reached the phone, whether it was opened, why it failed, and how Meta will bill it. They also arrive out of order, skip steps, and occasionally contradict each other.

This guide explains each status, the payload field by field, the edge cases Meta documents, and a small schema that turns the stream into a correct current state. It's part of our complete guide to the WhatsApp API.

The five statuses and what each proves

Status updates arrive on the messages webhook field, in a statuses array (inbound customer messages use a messages array instead). Meta's status webhook reference defines five values:

statusWhat it provesWhat the sender sees in WhatsApp
sentThe message left Meta's serversOne grey check
deliveredIt reached at least one of the user's devicesTwo grey checks
readIt was displayed in an open chat on the user's deviceTwo blue checks
playedA voice message was played for the first timeBlue microphone
failedIt couldn't be sent or delivered; an errors array says whyRed error triangle

A normal text or template produces up to three webhooks: sent, delivered, read. played applies only to voice messages and started arriving from March 17, 2026. And there's no status for "the API accepted it": that's the HTTP 200 and wamid you got back from the send call.

One documented shortcut catches people out. If the user is already looking at your chat when the message lands, it's delivered and read at the same moment, and Meta sends only the read webhook. A read status therefore implies delivery, even when you never saw delivered.

Anatomy of a status payload

Here's a delivered status for a utility template, as a v24-or-later webhook would carry it, including the business-scoped user ID fields Meta added in 2026:

{
  "object": "whatsapp_business_account",
  "entry": [{
    "id": "102290129340398",
    "changes": [{
      "field": "messages",
      "value": {
        "messaging_product": "whatsapp",
        "metadata": { "display_phone_number": "15550783881", "phone_number_id": "106540352242922" },
        "statuses": [{
          "id": "wamid.HBgLMTY1MDM4Nzk0MzkVAgARGBI3MTE5MjVBOTE3MDk5QUVFM0YA",
          "status": "delivered",
          "timestamp": "1793267112",
          "recipient_id": "16505551234",
          "recipient_user_id": "US.13491208655302741918",
          "biz_opaque_callback_data": "order-1042-shipped",
          "pricing": {
            "billable": true,
            "pricing_model": "PMP",
            "type": "regular",
            "category": "utility"
          }
        }]
      }
    }]
  }]
}

The fields worth storing:

  • id is the wamid from your send response. Join on it.
  • timestamp is when the event happened, in Unix seconds. Use it, not your server's arrival time.
  • recipient_id is the user's phone number, or a group ID for Groups API messages, in which case recipient_participant_id names the member. It can be missing for users with usernames; recipient_user_id (the BSUID) is the stable identifier.
  • biz_opaque_callback_data echoes any string, up to 512 characters, that you attached when sending. It's the cleanest way to tie a status to your own record without a lookup.
  • pricing appears on the sent status and on one of delivered or read. More on it below.
  • errors appears only on failed, with code, title, message and error_data.details.

On Graph API v24.0 and later, the old conversation object is gone from status webhooks unless the message was sent inside a free entry point window. If your code still reads conversation.origin.type to work out cost, move it to pricing.category.

Ordering, gaps and contradictions

Storing "the latest webhook wins" breaks in five documented ways:

  1. Out of order. Webhooks are delivered concurrently and retried, so read can arrive before delivered, and a retried sent can show up last.
  2. Skipped steps. As above, a message read on arrival never gets a delivered webhook.
  3. Contradictions. Meta's support page notes that a user logged in on several devices can trigger both delivered and failed for one message. Meta's rule: anything that produced delivered reached at least one device.
  4. Duplicates. Failed deliveries to your endpoint are retried for up to 7 days, and retries go to every subscribed app.
  5. Silence. Meta keeps trying to deliver for the message's time-to-live, 30 days by default and 10 minutes for authentication templates. If no delivered arrives within the TTL, assume the message was dropped. failed itself can arrive with a small delay, so allow a buffer before concluding anything.

For Groups API messages there's one more twist: you get statuses per participant, so key them on the wamid plus recipient_participant_id.

A schema that gets it right

The fix is to rank statuses and only ever move a message forward. Ranking failed above sent but below delivered handles Meta's multi-device rule automatically:

StatusRank
accepted (your send succeeded)0
sent1
failed2
delivered3
read4
played5

In Postgres, an append-only event log plus an upsert that refuses to go backwards:

CREATE TABLE wa_messages (
  wamid            text PRIMARY KEY,
  recipient        text,
  status           text     NOT NULL DEFAULT 'accepted',
  status_rank      smallint NOT NULL DEFAULT 0,
  error_code       integer,
  pricing_category text,
  pricing_type     text,
  status_at        timestamptz
);

CREATE TABLE wa_status_events (
  wamid       text        NOT NULL,
  status      text        NOT NULL,
  occurred_at timestamptz NOT NULL,
  payload     jsonb       NOT NULL,
  PRIMARY KEY (wamid, status)
);

-- 1. Log each event once; retries and duplicates fall through.
--    $1 wamid, $2 status, $3 Unix timestamp, $4 raw status object
INSERT INTO wa_status_events (wamid, status, occurred_at, payload)
VALUES ($1, $2, to_timestamp($3), $4)
ON CONFLICT DO NOTHING;

-- 2. Move the message forward, never backward. Works even if the
--    webhook arrives before you've stored the send response.
--    $1 wamid, $2 status, $3 rank, $4 Unix timestamp, $5 error code or NULL
INSERT INTO wa_messages (wamid, status, status_rank, status_at, error_code)
VALUES ($1, $2, $3, to_timestamp($4), $5)
ON CONFLICT (wamid) DO UPDATE
SET status      = EXCLUDED.status,
    status_rank = EXCLUDED.status_rank,
    status_at   = EXCLUDED.status_at,
    error_code  = EXCLUDED.error_code
WHERE wa_messages.status_rank < EXCLUDED.status_rank;

-- 3. Pricing rides on sent and on delivered or read: keep it whenever it appears.
--    $1 wamid, $2 pricing.category, $3 pricing.type (skip this query if absent)
UPDATE wa_messages
SET pricing_category = $2, pricing_type = $3
WHERE wamid = $1;

The upsert in step two matters more than it looks. The sent webhook can beat your own code to the database, because your send handler is still writing the API response when Meta's webhook arrives. When you store the send response, insert with ON CONFLICT (wamid) DO UPDATE SET recipient = EXCLUDED.recipient so you fill in details without touching the status.

We ran this logic against the awkward cases (read before delivered, delivered then failed, failed then delivered, webhook before send, a duplicate read) and each ends in the state Meta's rules say it should. If your sends can be retried, pair this with de-duplicating outbound messages so one business event never produces two wamids.

Reading the pricing object

Status webhooks are also your running bill. Meta charges per delivered message, and the pricing object tells you how each one was classified:

  • category is the rate applied: marketing, utility, authentication, authentication-international, service, marketing_lite for the Marketing Messages API, referral_conversion for free entry point messages, and group variants for the Groups API.
  • type says whether it's charged. regular means billable, and as of October 1, 2026 that includes utility templates sent inside an open customer service window and service messages beyond a number's free tier. free_customer_service now means a service message within that free tier (1,000 a month per number). free_entry_point means it fell inside a free entry point window.
  • billable still appears but Meta says it will be deprecated; use type and category together instead.

Count regular messages by category and recipient country per month and you have an estimate to check against Meta's invoice, before any provider markup. Rates themselves live on Meta's rate cards; our WhatsApp API pricing guide explains how to read them.

Status events in Tenashi

Tenashi's webhooks report the same lifecycle for numbers linked to Tenashi: message.sent, message.delivered, message.read and message.failed, each carrying the full message with its current status. Two differences matter. message.sent fires for every message that leaves your number, whether it came from the API, the team inbox, an automation, a broadcast, or someone typing on the phone. And message.read only fires when the recipient has read receipts on.

The same discipline applies: delivery is at least once and not always in order, so de-duplicate on the event id and trust the message's status rather than arrival order. If you'd rather poll, GET /api/v1/messages/:id returns the current state: queued, sent, delivered, read, or failed with an error.

Where Tenashi fits

Status tracking matters most when a person needs to act on it: chase the customer who never opened the quote, call the one whose message failed. Tenashi puts that state inside a shared team inbox on the number you already use, linked by QR scan, with a CRM and follow-ups under every chat, and sends the same events to your webhook. Pricing is flat, from $18/mo, with the API on every paid plan.

Tenashi isn't Meta's Cloud API, so there's no per-message pricing object to reconcile, and no template or OTP sending at scale. For those, keep the schema above.

Read the API docs · See every feature

FAQ

Why did I get a read status without a delivered status?

Meta skips the delivered webhook when a message is delivered and read at the same moment, typically because the user had your chat open when it arrived. A read status always implies delivery. Rank statuses so that read sits above delivered, and never require a delivered event before accepting a read.

Can a WhatsApp message be both delivered and failed?

Yes, occasionally. Meta documents that a user logged in on several devices can trigger a delivered status for one device and a failed status for another. Meta's rule is that any message with a delivered status reached at least one device, so let delivered and read outrank failed in your stored state.

Why don't some delivered messages ever get a read status?

Read means the message was displayed in an open chat on the user's device. Plenty of delivered messages are never opened, or not opened for days. Treat read as a positive signal when it arrives, and never treat its absence as proof the person ignored you. Delivered is the reliable floor.

How long should I wait before assuming a message was dropped?

Meta retries delivery for the message's time-to-live: 30 days by default and 10 minutes for authentication templates, with custom values possible for some templates. If no delivered status arrives within that window, assume it was dropped. Failed statuses can arrive with a short delay, so add a buffer.

Which status webhook includes pricing information?

The pricing object appears on the sent status and on one of the delivered or read statuses for the same message, so capture it whenever it shows up. Use pricing.category for the rate applied and pricing.type to see whether it was charged, since Meta plans to deprecate the billable flag.


Statuses turn a send into a story you can act on. If you want that story in front of your team, not just in a table, read Tenashi's API docs or get started.