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 dispatchedmessage.sending- engine has handed it to WhatsAppmessage.delivered- confirmed present in WhatsApp's statemessage.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.
| Event | Fires when | What you can do with it |
|---|---|---|
message.queued | The API accepts your send and writes the first lifecycle row | Record the handle, start a timeout clock |
message.sending | The engine has dispatched the message toward WhatsApp | Distinguish "our fault" from "WhatsApp's turn" |
message.delivered | The message is confirmed in WhatsApp's own state | Mark the step complete, trigger the next action |
message.read | A read receipt is observed for the message | Nice to have. Never gate a workflow on it |
message.failed | The send failed or was cancelled | Branch: 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.

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:
- Stand up a receiver at a public HTTPS URL that returns a 2xx quickly. Anything else counts as a failure.
- Register it against the events you want. Subscribing to all five and filtering in code costs you nothing.
- Send one message through
POST /v1/scheduled-messages/{chatId}with nosendAt, which dispatches immediately and creates the lifecycle row the events hang off. - Watch for
message.queuedfirst. 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".
| Event | Certifies | Does not certify |
|---|---|---|
message.queued | The API accepted and stored the send | That an engine is connected |
message.sending | The engine dispatched it | That WhatsApp accepted it |
message.delivered | Confirmed in WhatsApp's state | Recipient device delivery, or two grey ticks |
message.read | A read receipt was observed | That the human read it, or that silence means unread |
message.failed | This attempt did not succeed | That 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.

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
idis stable across retries. The same delivery re-POSTs an identical body, so deduplicating onidis 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.secretis 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 separateIdempotency-Keyrequest header.data.waMessageKeyis null on these events. Match ondata.idordata.secretinstead.
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:
- The agent registers a webhook pointing at a small endpoint you control, subscribing to
message.deliveredandmessage.failed. - It sends through the
scheduled_messagestool and setssecretto a task id it already owns. - Your endpoint writes the outcome somewhere the agent reads on its next turn: a task queue, a database row, a file it polls.
- 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.

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:
| Event | Status | Build on it? |
|---|---|---|
message.queued | Verified end to end | Yes |
message.sending | Verified end to end | Yes |
message.delivered | Verified end to end | Yes |
message.failed | Fires on genuine failures | Yes |
message.read | Known gap, fires rarely | No |
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.

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.completedandcampaign.abortedare 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.connectedandsession.disconnectedseparate "nothing delivered because the messages are bad" from "nothing delivered because the number went offline". - Scheduling. Omit
sendAtand 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.



