WhatsApp Operators DailyThe Blueticks DispatchWednesday, August 19, 2026
Productivity

How to Track WhatsApp Message Delivery Status with Webhooks (Outbound Lifecycle, 2026)

A WhatsApp webhook closes the loop your send call leaves open: the outbound lifecycle event by event, honest reliability notes, and receiver code in Python and Node.

DRBy Daniel Roth · August 19, 2026 · 11 min read
How to Track WhatsApp Message Delivery Status with Webhooks (Outbound Lifecycle, 2026)

Your script fires a send. The call returns 201, your log line says "sent", and then nothing ever happens again. Whether that message reached WhatsApp in 400 milliseconds, sat behind a reconnecting engine for nine minutes, or failed outright, your code has no idea. Sending is a push. Knowing is a pull nobody wrote. Here is the return path.

How do you know a WhatsApp message actually arrived?

You register a webhook. A WhatsApp webhook is an HTTPS URL you own that the platform POSTs to every time an outbound message of yours changes state. Instead of asking the API "any news?" on a loop, the change arrives at your server as it happens, tagged with the message it belongs to.

The Blueticks outbound lifecycle is five events:

  • message.queued - accepted by the API, not yet dispatched
  • message.sending - engine has handed it to WhatsApp
  • message.delivered - confirmed present in WhatsApp's state
  • message.read - read receipt observed (best effort, see below)
  • message.failed - the send failed or was cancelled

One scoping rule matters before you build anything: these events fire for API-originated messages only. A message you type on your phone, or schedule from the Blueticks dashboard, emits nothing. The lifecycle belongs to messages your code created.

The other direction, inbound messages landing on your server and getting an auto-reply, is a separate pipeline I covered in receiving inbound WhatsApp messages and auto-replying. This article is strictly the outbound half.

The outbound delivery lifecycle, event by event

Each of the five WhatsApp webhook events marks one public transition, so you never get two events for the same step. The envelope is identical every time: an event id, a type, a timestamp, and a data object carrying the message record.

EventFires whenWhat you can do with it
message.queuedThe API accepts your send and writes the first lifecycle rowRecord the handle, start a timeout clock
message.sendingThe engine has dispatched the message toward WhatsAppDistinguish "our fault" from "WhatsApp's turn"
message.deliveredThe message is confirmed in WhatsApp's own stateMark the step complete, trigger the next action
message.readA read receipt is observed for the messageNice to have. Never gate a workflow on it
message.failedThe send failed or was cancelledBranch: retry, fall back to email, alert a human

A raw delivery event looks like this:

{
  "id": "evt_8fk2m1p0q7z3x9c4v6b2n5h1",
  "type": "message.delivered",
  "created_at": "2026-08-19T09:14:22.417Z",
  "data": {
    "id": "66c1f0a9e4b0a2d7c8901234",
    "to": "+15551234567",
    "type": "text",
    "text": "Your order shipped.",
    "waMessageKey": null,
  "status": "confirmed",
    "confirmedAt": "2026-08-19T09:14:22.104Z",
    "failureReason": null,
    "secret": "order-4471-shipped"
  }
}

Two things there will save you an afternoon. data.id is the same handle the send call returned, so that is your correlation key. And branch on the top-level type, not on data.status: the status field is a coarser projection that reads confirmed for both message.sending and message.delivered. The envelope type is the precise signal.

Close-up of a network patch panel with amber and green link lights, some lit and most dark

Registering a webhook for delivery events

Registering a WhatsApp API delivery status webhook takes one POST. You supply an HTTPS URL of up to 2048 characters, at least one event name, and an optional description. Back comes a webhook id, the event list, an enabled status and a timestamp.

Four steps, start to finish:

  1. Stand up a receiver at a public HTTPS URL that returns a 2xx quickly. Anything else counts as a failure.
  2. Register it against the events you want. Subscribing to all five and filtering in code costs you nothing.
  3. Send one message through POST /v1/scheduled-messages/{chatId} with no sendAt, which dispatches immediately and creates the lifecycle row the events hang off.
  4. Watch for message.queued first. If it never arrives, the problem is registration, not delivery.
curl -X POST "https://api.blueticks.co/v1/webhooks" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $BLUETICKS_API_KEY" \
  -d '{
    "url": "https://example.com/hooks/whatsapp-delivery",
    "events": [
      "message.queued",
      "message.sending",
      "message.delivered",
      "message.read",
      "message.failed"
    ],
    "description": "Delivery lifecycle stream"
  }'

The GET, PATCH and DELETE shapes on /v1/webhooks/{id} are in the live Blueticks API reference, which renders straight from the running spec.

What breaks: a 4xx from your endpoint is treated as permanent and is never retried. If your receiver returns 422 because an unexpected field showed up, that event is gone. Only 408, 425, 429, 5xx, timeouts and connection errors are retried, up to 8 attempts with backoff of 1 minute, 5 minutes, 15 minutes, 1 hour, 2 hours, 6 hours and 12 hours, roughly a 21-hour window, each attempt capped at a 10-second timeout. After 8 consecutive deliveries exhaust their retries the webhook is disabled and you have to re-enable it. For contrast, Meta's Cloud API retries "with decreasing frequency until the request succeeds, for up to 7 days". Different platform, different contract.

What each event certifies, and what it does not

The WhatsApp message delivered webhook is the event everyone wants and the one most likely to be misread. message.delivered certifies that the message is confirmed present in WhatsApp's own state for your account. It does not certify that the recipient's phone received it, and it is not the two-grey-ticks moment. Treat it as "WhatsApp has it", not "they have it".

EventCertifiesDoes not certify
message.queuedThe API accepted and stored the sendThat an engine is connected
message.sendingThe engine dispatched itThat WhatsApp accepted it
message.deliveredConfirmed in WhatsApp's stateRecipient device delivery, or two grey ticks
message.readA read receipt was observedThat the human read it, or that silence means unread
message.failedThis attempt did not succeedThat the recipient is unreachable forever

The naming lands where it does because of deduplication. Internally there are more states than five and several describe the same public transition, so message.delivered binds to the "confirmed" state and the neighbouring internal states are deliberately left unmapped. That stops you receiving the same delivery twice under two names, at the cost of a word that does less work than it looks like it does.

Blueticks sends from your own number over a linked WhatsApp session, the same linked device mechanism your laptop uses, not through the Meta Cloud API. None of Meta's message-status semantics apply here. Consent is still yours to collect, and no vendor can promise a number will never be restricted.

Point a URL at your own number's delivery stream and stop guessing. Start a Blueticks account, register one webhook, and your next API send tells you what happened to it.

Two pairs of hands exchanging a plain unmarked kraft-paper parcel in daylight

Closing the loop: send, wait for delivered, act

The loop has three moving parts: send and keep the returned handle, receive events and match them back to it, then act on the terminal state. Your receiver's only job inside the request cycle is to acknowledge fast. Parse, enqueue, return 200, and do the real work somewhere else.

Python, Flask, with a worker thread so the HTTP response is never blocked by your business logic:

import queue, threading
from flask import Flask, request

app = Flask(__name__)
events = queue.Queue()
seen = set()  # swap for Redis SETNX in production

@app.post("/hooks/whatsapp-delivery")
def receive():
    payload = request.get_json(silent=True) or {}
    events.put(payload)
    return "", 200          # acknowledge first, always

def worker():
    while True:
        evt = events.get()
        event_id = evt.get("id")
        if not event_id or event_id in seen:
            continue        # idempotent: same event id on every retry
        seen.add(event_id)
        handle(evt.get("type"), evt.get("data") or {})

def handle(event_type, data):
    ref = data.get("secret") or data.get("id")
    if event_type == "message.delivered":
        advance_workflow(ref)
    elif event_type == "message.failed":
        fall_back(ref, data.get("failureReason"))
    elif event_type == "message.queued":
        start_timeout_clock(ref, seconds=180)

threading.Thread(target=worker, daemon=True).start()

The same receiver in Node with Express:

import express from "express";
const app = express();
app.use(express.json());

const seen = new Set(); // swap for Redis SETNX in production

app.post("/hooks/whatsapp-delivery", (req, res) => {
  res.sendStatus(200);              // acknowledge first, always
  setImmediate(() => {
    const { id, type, data = {} } = req.body || {};
    if (!id || seen.has(id)) return;  // idempotent on retries
    seen.add(id);

    const ref = data.secret || data.id;
    if (type === "message.delivered") advanceWorkflow(ref);
    else if (type === "message.failed") fallBack(ref, data.failureReason);
    else if (type === "message.queued") startTimeoutClock(ref, 180);
  });
});

app.listen(3000);

Three details make this production-shaped rather than demo-shaped:

  • The envelope id is stable across retries. The same delivery re-POSTs an identical body, so deduplicating on id is exact rather than heuristic. A 15-second server-side collapse window catches genuinely duplicate emissions, but your own dedupe is what protects you across the 21-hour retry span.
  • data.secret is a correlation token you choose. Pass an opaque string of up to 256 characters on the send and it comes back on every event for that message, so you can join to your own order id with no lookup table. It is not a deduplication key: sending it twice sends two messages. For safe send retries there is a separate Idempotency-Key request header.
  • data.waMessageKey is null on these events. Match on data.id or data.secret instead.

What breaks: the timeout clock is not decoration. If the engine behind your number is disconnected, message.queued fires and nothing follows, possibly for a long time. A workflow that waits forever for message.delivered has silently stopped. Subscribe to session.connected and session.disconnected alongside the message events so you can tell the two apart.

Wiring the loop into an MCP connector or a Claude Code agent

This is where the asymmetry bites hardest. An agent with a WhatsApp connector can send: it calls the tool, the tool returns, and the conversation moves on with no way to learn what happened next. The agent is writing into the dark. Webhooks are the only mechanism that gives it a return path.

The Blueticks MCP server exposes a webhooks tool with create, list, get, update and delete actions, so the agent can register its own callback instead of waiting for a human to configure one. The practical pattern:

  1. The agent registers a webhook pointing at a small endpoint you control, subscribing to message.delivered and message.failed.
  2. It sends through the scheduled_messages tool and sets secret to a task id it already owns.
  3. Your endpoint writes the outcome somewhere the agent reads on its next turn: a task queue, a database row, a file it polls.
  4. The agent resumes with a fact instead of an assumption, and can say "delivered at 09:14" or "failed, falling back to email".

Step 3 is the part people skip. A webhook is asynchronous and an agent turn is synchronous, so something durable has to sit between them. One row keyed on your secret is enough. For the broader picture of what the connector can drive, see WhatsApp automation through the API.

A single runner's hand gripping a plain unmarked relay baton on a running track

Which delivery events are reliable today, and which are not

Three of the five are dependable and one is not. message.queued, message.sending and message.delivered are verified end to end and are what you should build on. message.failed fires on real failures. message.read is best effort, and its absence carries no information at all.

The honest version, which is not a table you will find on a vendor page:

EventStatusBuild on it?
message.queuedVerified end to endYes
message.sendingVerified end to endYes
message.deliveredVerified end to endYes
message.failedFires on genuine failuresYes
message.readKnown gap, fires rarelyNo

Blueticks' own internal known-issues note from 2026-04-24 puts it plainly: message.read "rarely fires even when the recipient clearly reads the message". The backend is wired correctly; the gap is upstream, in how the read state gets recorded at all. A server-side fallback has been proposed and does not exist today.

There is a second, permanent reason not to gate anything on reads, and it has nothing to do with us. WhatsApp lets every recipient turn read receipts off in their privacy settings, and when they do no read receipt is sent for a one-to-one chat. Group chats are the exception, where read receipts are always sent. Even a perfect implementation would produce a message.read for some recipients and permanent silence for others, with no way to tell the two apart. Any product promising reliable read tracking on one-to-one chats is describing something the platform does not offer.

Why polling for delivery status falls apart

Polling works for ten messages and collapses at a thousand. You can read status directly: POST /v1/messages/acks returns delivery state for up to 200 message keys per call, with an ack integer from -1 (error) through 0 (pending), 1 (server), 2 (device), 3 (read) and 4 (played). Useful endpoint. Terrible foundation for a workflow.

Run the arithmetic. 5,000 in-flight messages at 200 keys per call is 25 requests per sweep. Poll every 30 seconds and that is 72,000 calls a day, nearly all returning a state you already knew. Stretch the interval to five minutes and median detection latency becomes 150 seconds, useless for anything that has to react. Webhooks invert the cost: one request per actual state change, zero when nothing happens.

Use both, for different jobs. Webhooks drive behaviour. The batch ack endpoint handles reconciliation: a status column in a dashboard, or backfilling the window when your receiver was down and eight retries ran out. That sweep is the safety net that makes it reasonable to trust the stream the rest of the time. The send side itself is covered in sending a WhatsApp message from the API.

A tarnished brass doorbell button on a weathered door frame, its nameplate slot empty

What raw delivery events cannot do that Blueticks adds

Raw events tell you what happened to one message. They will not schedule the next one, hold a rate limit, group 800 sends into a campaign you can pause, or keep the session behind your number alive at 3am. That operational layer is why a whatsapp delivery status api is worth attaching to a product rather than a script.

Concretely, what sits around the event stream:

  • Campaign-level events. campaign.started, campaign.paused, campaign.resumed, campaign.completed and campaign.aborted are subscribable the same way, so a bulk run reports its own progress instead of you inferring it from 800 individual message events.
  • Session events. session.connected and session.disconnected separate "nothing delivered because the messages are bad" from "nothing delivered because the number went offline".
  • Scheduling. Omit sendAt and it sends now, set it and the queue holds it, so you are not running cron to fire messages at 09:00 local time.
  • A toolkit, not one endpoint. The same account is reachable through the REST API and the MCP server, over your own number, with the event stream wired to whichever you use.

Honest framing: you can build a delivery-status pipeline on the raw events alone, and plenty of people should.

Frequently asked questions

Does message.delivered mean the recipient received the message? No. It certifies the message is confirmed present in WhatsApp's own state for your account. It is not the recipient's device confirming receipt and it is not equivalent to two grey ticks. For end-user copy, "sent successfully" is accurate and "delivered to their phone" is not.

Why am I not getting a message.read event? Two reasons stack. A known gap on our side, recorded internally since 2026-04-24, means the read state often is not persisted even when the recipient reads the message. And recipients can switch read receipts off, in which case no read receipt is sent for us to report. Build on message.delivered instead.

Do I get delivery events for messages I send from my phone? No. The lifecycle fires for API-originated messages only. Messages typed on your phone or scheduled from the dashboard emit nothing, because the events hang off the record your API call created.

What happens if my server is down when an event fires? Non-2xx responses other than a permanent 4xx are retried up to 8 times with escalating backoff across roughly 21 hours. Every attempt carries an identical body and the same event id, so dedupe on that id. After 8 consecutive deliveries exhaust their retries the webhook is disabled. Reconcile anything you missed with the batch ack endpoint.

How is this different from Meta Cloud API webhooks? Different platform entirely. Blueticks sends from your own number over a linked WhatsApp session, so the event names, payload shape, retry policy and the meaning of each state are ours, not Meta's. Code written against Cloud API status objects will not parse these events, and Cloud API semantics should not be assumed to describe this behaviour.

Email

The Dispatch, every week.

One sharp WhatsApp growth tactic in your inbox each week. Joined by 238,000+ founders, marketers and support leads.

Free forever. No spam, unsubscribe in one click.