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:

  • accepted is not in this list. It is what the send call answers (202). The first update you receive afterwards is sent.
  • A missing read proves nothing. Not every customer has read receipts on, so "delivered but never read" is not the same as "ignored". Use read as 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

  1. Register an endpoint and send one message with a live key to your own phone. Watch the row go accepted, sent, delivered, and read once you open the chat.
  2. Replay the same signed event twice, using the curl test from the webhook guide. The second run must change nothing.
  3. Feed your handler a delivered before a sent (a unit test is enough; our API should not send you this order). The row should end on delivered and the late sent should 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; failed is handled on purpose.
  • Webhook signature verified, 2xx within 5 seconds, event ids deduplicated.
  • The error text is visible to a person, not only logged.
  • A polling job covers messages stuck on accepted or sent.
  • "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.status fields. 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 free