← Blog

·

Message status in the WhatsApp Business API: sent, delivered, read and failed via webhook

Updated on October 25, 2026

In the WhatsApp Business API, the status of a sent message arrives through the webhook, in the statuses array, not in the send response. The possible values are sent (left WhatsApp’s servers), delivered (reached the device), read (displayed in an open chat), played (voice message played for the first time) and failed (couldn’t be sent or delivered, with the reason in errors). Each event carries the message wamid, and your system should keep the most advanced status without letting a late event roll the record back.

This guide covers what each status means, the payloads, how to read errors and pricing, and a storage model in Node.js and Python.

The send response is not the status

When you call the messages endpoint, the 200 response returns the wamid:

{
  "messaging_product": "whatsapp",
  "contacts": [{ "input": "15551234567", "wa_id": "15551234567" }],
  "messages": [{ "id": "wamid.HBgLMTU1NTEyMzQ1NjcVAgARGBI0..." }]
}

That only means Meta accepted the request. The message can still fail, for example because of the per-user marketing limit or because the number isn’t on WhatsApp. Store the wamid at send time: it’s the key that links the message to the statuses that arrive later.

The five Cloud API statuses

The status webhook reference defines:

StatusMeaningIn the customer’s app
sentThe message left WhatsApp’s serversOne check mark
deliveredThe message reached the recipient’s deviceTwo check marks
readThe message was displayed in an open chat on the deviceTwo blue check marks
playedFirst playback of a voice messageBlue microphone
failedThe message couldn’t be sent or deliveredRed error triangle

Two details from the docs change how you process them:

  • read can come without delivered. If the customer has the chat open when the message arrives, Meta skips the delivered webhook and sends only read. Treat read as “delivered and read”.
  • played only exists for voice messages. It fires the first time the customer plays an audio message sent as voice (see how to send audio and voice notes).

The status payload

Every status arrives with field: "messages", just like incoming messages. The difference is that value holds statuses instead of messages:

{
  "object": "whatsapp_business_account",
  "entry": [{
    "id": "WABA_ID",
    "changes": [{
      "field": "messages",
      "value": {
        "messaging_product": "whatsapp",
        "metadata": { "display_phone_number": "15550000000", "phone_number_id": "PHONE_NUMBER_ID" },
        "statuses": [{
          "id": "wamid.HBgLMTU1NTEyMzQ1NjcVAgARGBI0...",
          "status": "delivered",
          "timestamp": "1761400000",
          "recipient_id": "15551234567",
          "pricing": {
            "billable": true,
            "pricing_model": "PMP",
            "type": "regular",
            "category": "marketing"
          }
        }]
      }
    }]
  }]
}

Status object fields:

FieldWhen presentWhat it’s for
idAlwaysThe sent message’s wamid
statusAlwaysOne of the five values above
timestampAlwaysWhen the event was generated (Unix, seconds)
recipient_idAlwaysRecipient’s number (or group ID, for groups)
biz_opaque_callback_dataIf you sent this field with the messageEchoes back your own identifier, such as an order or campaign ID
pricingOn sent and on one of delivered or readRate category and billing type
errorsOnly on failedError code and description

For group messages, recipient_id holds the group ID, and the participant’s number goes in recipient_participant_id.

A single POST can carry several statuses, including for different messages. Always iterate over the whole array.

The pricing object

pricing is the most reliable way to know whether and how a message was billed:

  • category: rate category, such as marketing, utility, authentication or service.
  • type: regular (billable), free_customer_service, free_entry_point (sent in the free window opened by a click-to-WhatsApp ad) or free_group_customer_service.
  • billable: deprecated by Meta. Use type and category.

As of October 1, 2026, Meta changed what type means: utility messages sent inside the customer service window now come as regular, and free_customer_service now indicates a service message within the phone number’s free tier (1,000 per month). Service messages beyond that tier also come as regular. If your cost report relies on type, review the rule. See how much a WhatsApp campaign costs for how to add it up.

From Graph API version 24.0 onward, the conversation object is no longer included in statuses, except for messages sent inside a free entry point window.

failed status: reading the error

When a message fails, the status includes errors:

{
  "id": "wamid.HBgLMTU1NTEyMzQ1NjcVAgARGBI0...",
  "status": "failed",
  "timestamp": "1761400005",
  "recipient_id": "15551234567",
  "errors": [{
    "code": 131049,
    "title": "This message was not delivered to maintain healthy ecosystem engagement.",
    "message": "This message was not delivered to maintain healthy ecosystem engagement.",
    "error_data": { "details": "In order to maintain a healthy ecosystem engagement, the message failed to be delivered." },
    "href": "/documentation/business-messaging/whatsapp/support/error-codes"
  }]
}

Store code and error_data.details. The codes that show up most often in failed statuses, according to the official error code list:

CodeCauseAction
131026Recipient not on WhatsApp, hasn’t accepted the latest terms or uses an outdated appConfirm the number; don’t retry in a loop
131047More than 24 hours since the customer’s last messageSend a template
131049Not delivered to protect engagement (per-user marketing limit)Wait at least 24 hours; see per-user marketing limits
131050Customer stopped marketing messages from the businessMark as opted out; don’t retry
131053Media upload failed, usually an unsupported typeCheck the file’s MIME type
131056Too many messages to the same recipient in a short timeWait before messaging that person again
132015Template paused for low qualityEdit the template and wait for approval
131000Unknown errorRetry; if it persists, open a support ticket

The full list with more codes is in WhatsApp API errors.

A failed message isn’t delivered and therefore isn’t billed under per-delivered-message pricing.

Order, duplicates and delays

Meta’s documentation doesn’t promise that statuses arrive in order, and it warns that webhook delivery retries can produce duplicate notifications. Retries continue for up to 7 days. In practice:

  • read can arrive before delivered, or delivered after read. Never overwrite a more advanced status with an older one.
  • The same status can arrive twice. Use id + status as the idempotency key.
  • A status can arrive hours or days later if your endpoint was down. Store the event’s timestamp, not the time you processed it.
  • delivered may never arrive, for example if the customer’s phone stays off. Don’t treat “no delivered within 5 minutes” as a failure.

Storing statuses without regressing

The simplest model: one row per sent message, a timestamp column for each stage and a field with the most advanced status. Node.js example with PostgreSQL (pg library):

const ORDER = { sent: 1, delivered: 2, read: 3, played: 4 };

export async function saveStatus(db, st) {
  const at = new Date(Number(st.timestamp) * 1000);

  if (st.status === 'failed') {
    const err = st.errors?.[0] ?? {};
    await db.query(
      `UPDATE sends SET failed_at = $2, error_code = $3, error_detail = $4,
         status = CASE WHEN status_rank = 0 THEN 'failed' ELSE status END
       WHERE wamid = $1`,
      [st.id, at, err.code ?? null, err.error_data?.details ?? err.message ?? null],
    );
    return;
  }

  const rank = ORDER[st.status];
  if (!rank) return; // unknown status: log it and move on

  await db.query(
    `UPDATE sends SET
       ${st.status}_at = COALESCE(${st.status}_at, $2),
       delivered_at = CASE WHEN $3 >= 3 THEN COALESCE(delivered_at, $2) ELSE delivered_at END,
       category = COALESCE($4, category),
       billing_type = COALESCE($5, billing_type),
       status = CASE WHEN $3 > status_rank THEN $6 ELSE status END,
       status_rank = GREATEST(status_rank, $3)
     WHERE wamid = $1`,
    [st.id, at, rank, st.pricing?.category ?? null, st.pricing?.type ?? null, st.status],
  );
}

What this code guarantees:

  • No regression. status_rank only goes up; a late delivered doesn’t erase a read.
  • Idempotent. Saving the same event twice yields the same result, thanks to COALESCE.
  • read implies delivery. If delivered was skipped, delivered_at is filled with the read time.
  • A failure doesn’t erase success. If the row already has a positive status, failed is recorded in the error fields without changing the main status, so you can investigate.

The same logic in Python, without a database, for tests:

ORDER = {"sent": 1, "delivered": 2, "read": 3, "played": 4}


def apply_status(send: dict, st: dict) -> dict:
    at = int(st["timestamp"])
    if st["status"] == "failed":
        err = (st.get("errors") or [{}])[0]
        send.update(failed_at=at, error_code=err.get("code"))
        if send.get("status_rank", 0) == 0:
            send["status"] = "failed"
        return send

    rank = ORDER.get(st["status"])
    if rank is None:
        return send
    send.setdefault(f"{st['status']}_at", at)
    if rank >= 3:
        send.setdefault("delivered_at", at)
    if rank > send.get("status_rank", 0):
        send.update(status=st["status"], status_rank=rank)
    return send

To test with real events on your machine, see how to test the WhatsApp webhook locally.

Linking statuses to your business with biz_opaque_callback_data

When sending, you can include biz_opaque_callback_data with your own identifier (for example "order:48213" or "campaign:black-friday"). It comes back on every status for that message. That saves a database lookup to find which order or campaign a wamid belongs to and makes per-campaign reporting easier.

Metrics you can derive from statuses

With the timestamps stored, the most common metrics are straightforward:

MetricCalculation
Delivery ratemessages with delivered_at or read_at ÷ messages sent
Read ratemessages with read_at ÷ messages delivered
Time to readread_at − sent_at
Failure rate by codecount of error_code ÷ messages sent
Estimated costsum of rates by category, only for messages with billing_type = regular

For customer service metrics (first response time, average handling time), see WhatsApp customer service metrics.

How to do it with Vectalk

Vectalk forwards Meta’s status webhook to your URLs unchanged, with statuses, pricing and errors in the format described above. Register your URL with POST https://apis.vectalk.com.br/api/webhooks and return 200 within 20 seconds.

Besides the webhook, Vectalk keeps a message history you can query by status or wamid, which helps reconcile events your system may have missed:

curl "https://apis.vectalk.com.br/api/messages?status=failed&direction=outbound&from=2026-10-01&to=2026-10-25" \
  -H "Authorization: Bearer $VECTALK_TOKEN"

curl "https://apis.vectalk.com.br/api/messages?messageId=wamid.HBgLMTU1NTEyMzQ1NjcVAgARGBI0..." \
  -H "Authorization: Bearer $VECTALK_TOKEN"

The supported filters (status, from, to, messageId, direction, page, limit) are documented under List messages, and the event format under Webhooks (docs in Portuguese). Message costs come on your Vectalk invoice, with no card at Meta. Create your account at apps.vectalk.com.br/register.

Frequently asked questions

Why did I get read without delivered?

Because the customer had the chat open when the message arrived. In that case, Meta sends only read. Consider the message delivered.

The message is stuck on sent. What happened?

It left WhatsApp’s servers but hasn’t reached the device yet, for example because the phone is off or offline. delivered arrives when the device receives it. If failed arrives later, the code explains why.

Is a failed message billed?

No. Meta bills delivered messages. A message that failed wasn’t delivered.

Can I query a message’s status through Meta’s API instead of waiting for the webhook?

The Cloud API delivers statuses via webhook, so your endpoint needs to be up and storing events. On Vectalk, you can also check the history through the list messages endpoint.

How long after sending can a status arrive?

There’s no fixed limit. read depends on when the customer opens the chat, and webhook delivery retries can last up to 7 days if your endpoint is down.


Official sources consulted on October 25, 2026: Status webhook reference, Webhooks overview, Error codes, Pricing, Audio messages, Vectalk docs: List messages.

Integre o WhatsApp oficial ao seu sistema

A Vectalk entrega a Cloud API da Meta com os mesmos payloads e webhooks, um token para todas as contas e documentação completa.