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:
| Status | Meaning | In the customer’s app |
|---|---|---|
sent | The message left WhatsApp’s servers | One check mark |
delivered | The message reached the recipient’s device | Two check marks |
read | The message was displayed in an open chat on the device | Two blue check marks |
played | First playback of a voice message | Blue microphone |
failed | The message couldn’t be sent or delivered | Red error triangle |
Two details from the docs change how you process them:
readcan come withoutdelivered. If the customer has the chat open when the message arrives, Meta skips thedeliveredwebhook and sends onlyread. Treatreadas “delivered and read”.playedonly 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:
| Field | When present | What it’s for |
|---|---|---|
id | Always | The sent message’s wamid |
status | Always | One of the five values above |
timestamp | Always | When the event was generated (Unix, seconds) |
recipient_id | Always | Recipient’s number (or group ID, for groups) |
biz_opaque_callback_data | If you sent this field with the message | Echoes back your own identifier, such as an order or campaign ID |
pricing | On sent and on one of delivered or read | Rate category and billing type |
errors | Only on failed | Error 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 asmarketing,utility,authenticationorservice.type:regular(billable),free_customer_service,free_entry_point(sent in the free window opened by a click-to-WhatsApp ad) orfree_group_customer_service.billable: deprecated by Meta. Usetypeandcategory.
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:
| Code | Cause | Action |
|---|---|---|
131026 | Recipient not on WhatsApp, hasn’t accepted the latest terms or uses an outdated app | Confirm the number; don’t retry in a loop |
131047 | More than 24 hours since the customer’s last message | Send a template |
131049 | Not delivered to protect engagement (per-user marketing limit) | Wait at least 24 hours; see per-user marketing limits |
131050 | Customer stopped marketing messages from the business | Mark as opted out; don’t retry |
131053 | Media upload failed, usually an unsupported type | Check the file’s MIME type |
131056 | Too many messages to the same recipient in a short time | Wait before messaging that person again |
132015 | Template paused for low quality | Edit the template and wait for approval |
131000 | Unknown error | Retry; 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:
readcan arrive beforedelivered, ordeliveredafterread. Never overwrite a more advanced status with an older one.- The same status can arrive twice. Use
id+statusas 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. deliveredmay never arrive, for example if the customer’s phone stays off. Don’t treat “nodeliveredwithin 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_rankonly goes up; a latedelivereddoesn’t erase aread. - Idempotent. Saving the same event twice yields the same result, thanks to
COALESCE. readimplies delivery. Ifdeliveredwas skipped,delivered_atis filled with thereadtime.- A failure doesn’t erase success. If the row already has a positive status,
failedis 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:
| Metric | Calculation |
|---|---|
| Delivery rate | messages with delivered_at or read_at ÷ messages sent |
| Read rate | messages with read_at ÷ messages delivered |
| Time to read | read_at − sent_at |
| Failure rate by code | count of error_code ÷ messages sent |
| Estimated cost | sum 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.
