Track WhatsApp delivery status in your app: sent, delivered, read and failed
Store the status of every WhatsApp message you send, handle receipts that arrive late or twice, read the failure reason, and fall back to polling when a webhook is missed. TypeScript example with a REST API.
November 1, 2026 · 8 min read
A 202 from the send call does not mean the customer has your message. It means the message was accepted. Whether it reaches the phone, and whether anyone opens it, comes back later as status updates. If your app shows "order update sent" without ever checking, support will hear about the gaps first.
This guide builds the status side: one table, one small function that only lets a message move forward, a webhook handler for message.status, and a polling fallback for the ones you miss.
SendFromChat is an independent product and is not affiliated with Meta or WhatsApp. It runs on Meta's official WhatsApp Cloud API, and you keep your own WhatsApp Business account. The statuses, event fields and endpoints below come from our REST API reference and the code behind it, read on 1 Nov 2026.
What was tested. The status function in step 2 was run locally against lists of out-of-order and repeated updates. The webhook route and the polling call were not run against a live API key, and we did not send real WhatsApp traffic with this exact code. Start with a test key, and adapt names such as db, which stands in for your own storage.
The four statuses
| Status | What it tells you |
|---|---|
sent |
WhatsApp accepted the message |
delivered |
It reached the customer's phone |
read |
The customer opened it |
failed |
It did not go through; error says why |
Two things follow from that:
acceptedis not in this list. It is what the send call answers (202). The first update you receive afterwards issent.- A missing
readproves nothing. Not every customer has read receipts on, so "delivered but never read" is not the same as "ignored". Usereadas a hint, not as proof.
What you need first
- A SendFromChat account (30-day free trial, no card) with a WhatsApp number connected (getting started).
- A live key to see real statuses. A test key runs the checks and stops before sending, so no message exists and no status will follow (REST API).
- A webhook endpoint that verifies signatures. The webhook guide builds that part; here we only add the status handling.
1. Keep the message id when you send
The send response contains the id. Store it next to your own record, because every later status refers to it.
// lib/send-order-update.ts
export async function sendOrderUpdate(orderId: string, to: string) {
const res = await fetch("https://sendfromchat.com/api/v1/messages", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.SENDFROMCHAT_API_KEY}`,
"Content-Type": "application/json",
"Idempotency-Key": `order-${orderId}-shipped`,
},
body: JSON.stringify({
to,
template: { name: "order_shipped", language: "en_US", variables: ["Ana", `#${orderId}`] },
}),
});
const body = await res.json();
if (!res.ok) throw new Error(`send failed: ${body.error?.type}`);
// body.id is the message id. Status events carry it as data.messageId.
await db.messages.insert({ messageId: body.id, orderId, to, status: "accepted" });
return body.id as string;
}
The Idempotency-Key means a retry after a timeout returns the first response instead of sending twice (retries without double-sending). A retried call returns the same id, so your insert should ignore a duplicate.
2. Let a message only move forward
Webhook deliveries can repeat, and failed ones are retried for about a day, so an old status can arrive after a newer one. Rank the statuses and ignore anything that does not move forward.
// lib/status.ts
export type Status = "accepted" | "sent" | "delivered" | "read" | "failed";
const RANK: Record<Status, number> = { accepted: 0, sent: 1, delivered: 2, read: 3, failed: 99 };
/** Returns the status to store, or null if the update changes nothing. */
export function nextStatus(current: Status, incoming: Status): Status | null {
if (current === incoming) return null; // repeat
if (current === "failed") return null; // stay failed
if (incoming === "failed") {
// A failure after "delivered" or "read" is odd. Log it, don't overwrite.
return RANK[current] >= RANK.delivered ? null : "failed";
}
return RANK[incoming] > RANK[current] ? incoming : null;
}
The failed rules are a choice, not something the API forces on you. We keep an already delivered message as delivered and look at the odd case in the logs. Change it if your product needs different behaviour.
SendFromChat already drops out-of-order receipts before it sends you an event, but a repeated event can still reach you after a retry. Your own check costs little and also protects you if you ever feed in statuses from another source.
3. Handle message.status in the webhook
Subscribe to message.status when you register the endpoint (Developers page, or POST /webhook-endpoints). Each event looks like this:
{
"id": "evt_…",
"type": "message.status",
"created": "2026-11-01T09:00:00Z",
"data": {
"messageId": "wamid.HBgL…",
"to": "+4915112345678",
"status": "delivered",
"error": null,
"whatsappAccountId": "…",
"at": "2026-11-01T09:00:03Z"
}
}
// inside handleEvent(event), from the webhook guide
case "message.status": {
const { messageId, status, error, at } = event.data;
const row = await db.messages.find({ messageId });
if (!row) break; // not one of ours, or the insert in step 1 hasn't landed yet (see below)
const next = nextStatus(row.status, status);
if (!next) break;
await db.messages.update(
{ messageId },
{ status: next, error: next === "failed" ? (error ?? "Delivery failed") : null, statusAt: at },
);
if (next === "failed") await notifyOps(row.orderId, error);
break;
}
Verify the signature on the raw body, answer with a 2xx within 5 seconds and skip event ids you have already handled. All three are covered step by step in the webhook guide.
A status can beat your own insert. If the event arrives before step 1 has written the row, find returns nothing. Either write the row before you call the API, or have the handler keep unmatched statuses for a minute and retry. Dropping them silently is how orders end up stuck on "accepted".
4. Read the failure reason
When status is failed, error holds the reason. Show it to a person, don't just count it. Branch on the send call's error type for refusals at send time, and on the webhook's error for failures after it. The send-time type values are in the REST API errors table. For Meta's own numeric error codes, see WhatsApp Cloud API error codes.
Two send-time refusals are WhatsApp's rules rather than bugs: window_closed (free-form text outside the 24-hour window, see the window explained) and opted_out. Neither is sent, so no status follows.
5. Poll as a fallback
Failed webhook deliveries are retried for about a day, but your server can be down longer, or you may want a one-off check. GET /messages/{id} returns the current status.
curl https://sendfromchat.com/api/v1/messages/wamid.HBgL… \
-H "Authorization: Bearer $SENDFROMCHAT_API_KEY"
{
"id": "wamid.HBgL…",
"direction": "outbound",
"type": "template",
"status": "delivered",
"error": null,
"to": "+4915112345678",
"whatsappAccountId": "…",
"createdAt": "2026-11-01T09:00:00.000Z"
}
A small job that looks at messages still on accepted or sent after, say, 15 minutes and calls this endpoint closes most gaps. Run the result through the same nextStatus function so a poll can never move a message backwards. A message id from another workspace returns message_not_found.
6. Test it
- Register an endpoint and send one message with a live key to your own phone. Watch the row go
accepted,sent,delivered, andreadonce you open the chat. - Replay the same signed event twice, using the
curltest from the webhook guide. The second run must change nothing. - Feed your handler a
deliveredbefore asent(a unit test is enough; our API should not send you this order). The row should end ondeliveredand the latesentshould be ignored.
Checklist before you go live
- The message id from the send response is stored before any status can arrive.
- Statuses only move forward;
failedis handled on purpose. - Webhook signature verified,
2xxwithin 5 seconds, event ids deduplicated. - The
errortext is visible to a person, not only logged. - A polling job covers messages stuck on
acceptedorsent. - "Read" is shown as a hint, never as proof.
Common questions
Why did I get an older status after a newer one? Webhook deliveries can repeat and are retried for about a day, so an event can arrive late. Rank the statuses and ignore updates that do not move forward, as in step 2.
Does sent mean the customer got it? No. sent means WhatsApp accepted it; delivered is the step that reaches the phone.
Why is there no status for my test key? A test key runs every check and stops. Nothing is sent, so there is no message and no receipt.
How long are failed webhook deliveries retried? For about a day, with backoff, per our REST API reference. After that, use the polling call.
Do status updates cost anything? Our REST API reference counts the messages you send toward your plan's monthly allowance, and says nothing about a charge for status events. Meta's per-message charges are billed to your own WhatsApp Business account; see the pricing guide.
A note for EU and UK customers
SendFromChat stores data in Mumbai, India. Transfers from the EU and UK are covered by the Standard Contractual Clauses and the UK Addendum in our Data Processing Agreement, and the sub-processors are listed publicly. A status table that holds phone numbers is personal data in your own systems too, so decide how long you keep it. Our GDPR guide covers consent, opt-out and records. This is not legal advice.
Try it
Start the 30-day free trial (no card), connect a number, register a webhook endpoint and send yourself one message. Related: order notifications from your app, OTP login codes, and adding WhatsApp with Claude Code.
Sources
- SendFromChat REST API reference: statuses,
GET /messages/{id}, webhook events, retries, idempotency, errors. Read 1 Nov 2026. - SendFromChat code behind the API (the message route and the status update function): forward-only updates and the
message.statusfields. Read 1 Nov 2026.
Do this from your AI assistant
SendFromChat connects Claude, ChatGPT, Cursor or any MCP client to the WhatsApp Business API — templates, bulk sends, inbox, chatbots and sequences, just by asking. 30 days free, no card.
Start freeMore guides
Do you need a Facebook login for the WhatsApp Business API? What 'Continue with Facebook' asks
What the Facebook window asks you to do when you connect a WhatsApp number, which two permissions it gives an app, what you keep, and how to remove the access later. Based on Meta's own pages, read 31 Oct 2026.
WhatsApp number won't connect? What to check when the code doesn't arrive or Facebook's window won't load
A troubleshooting list for connecting a number to the WhatsApp Business API: the verification code that never arrives (SMS or voice), landlines and IVR menus, a number already on WhatsApp, a blocked Facebook sign-in window, and a trial number limit.
WhatsApp Business API requirements: what to have ready before you connect a number
A plain checklist for 2026. A phone number that can get an SMS or call, a number not in use on the app, a display name that passes review, a Meta business portfolio and a card on the WhatsApp account.
Send WhatsApp messages from Node.js: a code example
Send WhatsApp messages from Node.js with built-in fetch — a first test with no template, then templates, safe retries, errors by type and a signed webhook that replies.