Working with an AI agent? Download the full documentation as a Markdown file to use as context.
Download full .md"Was my message delivered? Was it read?" sounds like one question with one answer. Build on top of WhatsApp, Instagram and Facebook Messenger directly and you discover it is three questions with three different answers — and the differences are not just in the JSON. The behavior diverges: the channels disagree on which lifecycle states exist at all, on what "delivered" even means, on how a "read" receipt is addressed, and on whether a failure ever reaches you asynchronously.
This is a tour of where the three Meta messaging APIs actually diverge on status reporting, and the work it takes to collapse them into a single webhook contract. If you've integrated one of these channels, the other two will surprise you in exactly the ways below.
A delivery lifecycle reads the same on paper everywhere — sent → delivered → read, plus failed. Here is what each channel actually reports on Meta's own API — this table is the raw platform behavior, before any unifying layer (ours or anyone's) touches it:
| Meta API reports | Messenger | ||
|---|---|---|---|
| sent (accepted by Meta) | ✅ statuses[].status=sent |
❌ never | ❌ never |
| delivered (on device) | ✅ statuses[].status=delivered |
✅ message_deliveries |
❌ no delivery event |
| read | ✅ per-message, privacy-gated | ✅ message_reads, watermark |
✅ messaging_seen, per-message |
| failed (asynchronous) | ✅ statuses[].status=failed |
❌ none | ❌ none |
| addresses a message by | id (Meta mid) |
mids[] / watermark timestamp |
echo mid / read mid |
Four columns of asymmetry, straight from Meta. Each one is a behavioral landmine, not a formatting nuisance — and the rest of this page is about what it takes to hide them behind one contract.
WhatsApp gives you the full lifecycle: an explicit sent the moment Meta accepts the
message, a delivered when it reaches the device, a read, and an asynchronous failed.
Messenger and Instagram give you two states, not four. There is no sent — the closest
thing is the synchronous HTTP response from the Send API. And, crucially, there is no
asynchronous failed event (more on that below). So a naive integration that waits for a
terminal read or failed on Instagram will wait forever: those signals are structurally
absent. The first status you can ever observe on IG/Messenger is delivered.
On WhatsApp and Messenger, delivered is a genuine device-level receipt: Meta is telling
you the message reached the recipient's phone.
Instagram has no delivery receipt at all. Its messaging webhook exposes messaging_seen
(read) and message echoes — but nothing that says "delivered." So where does a delivered
event come from? We synthesize it. When you send an Instagram DM, Meta echoes your own
message back to your webhook (message_echoes, is_echo: true) roughly 1–3 seconds later,
once the message has been accepted into the conversation thread. That echo is the earliest
and best "it's in the system" signal Instagram offers, so we treat it as delivered.
Be honest about what that means: Instagram's delivered is "accepted into the thread," not
"on the recipient's device." It is the strongest signal the platform gives, but it is not
semantically identical to the WhatsApp/Messenger receipt. A unified API should expose one
event — and document the asterisk rather than pretend the channels are the same.
There's a second hazard hiding in the echo trick. A single Instagram account can have several
apps attached (your integration, a Meta Business Suite session, the IG mobile app, another
vendor). Meta echoes every outbound message on that account to every subscribed app.
So you receive echoes for messages you never sent. Treating those as your own delivered
events would emit phantom statuses for messages that don't exist in your system. The fix is
to resolve every echo's mid against your own outbound records and drop the foreign
echoes — only echoes that match a message you actually sent become a delivered event.
WhatsApp and Instagram report reads per message: the receipt carries the message id, and you mark that one message read.
Messenger doesn't send message ids on reads. It sends a watermark — a timestamp that
means "everything in this conversation up to this moment has been read." One read event can
therefore stand for a dozen messages. To map it onto per-message status, you have to expand
the watermark: look up every message you sent to that user at or before the watermark
timestamp that isn't already read or failed, and emit a read for each. One inbound Meta
event fans out into many outbound status events — what we call read cascading.
And reads are not guaranteed on every channel. WhatsApp read receipts depend on the
recipient's privacy setting: if they've turned read receipts off, the read status never
arrives, no matter that they read the message. Any logic that blocks on read as a
terminal state will hang on those users — delivered is the only safe terminal success
signal to design around.
This is the difference that bites hardest in production. WhatsApp reports failures
asynchronously: a message can be accepted at send time, then fail later in delivery and
arrive as a failed status with a Meta error code. In multi-device setups WhatsApp can even
report delivered and failed for the same message — delivered to one device, failed on
another. Your handler has to tolerate non-linear lifecycles.
Messenger and Instagram have no asynchronous failure channel. If a send fails, you learn
about it exactly once: in the synchronous HTTP response to your Send API call. After Meta
accepts the message, silence means success — there is no later event that retracts it. So
the same logical question, "did this message fail?", is answered by a webhook on one channel
and only by an API return code on the other two. A unified layer has to bridge that: surface
WhatsApp's async failed as an event, and treat the synchronous send error as the failure
signal everywhere else.
WhatsApp delivery statuses reference Meta's mid. Messenger deliveries reference mids[].
Messenger reads reference no id at all — just the watermark timestamp. Instagram references
the echo mid and the read mid. None of these is the id you got back when you sent the
message in a unified API — and they're inconsistent with each other.
So the unifying move is to mint our own UUID at send time, return it from the send call,
and reconcile every incoming Meta status back to it: a Meta mid is looked up against an
in-memory cache first (zero database hits on the hot path) and the database as fallback; a
Messenger watermark is resolved by (channel, recipient, time-range). From the caller's
side, the message you sent and every status that follows it carry the same id, regardless
of which of the three identity schemes Meta used underneath.
After all of the above, the client sees one shape. Every status — on any channel — arrives at
your webhook_url as the same envelope:
{
"event": "message.delivered",
"channel_id": 42,
"channel_type": "instagram",
"timestamp": "2026-06-25T10:00:05Z",
"data": {
"message_id": "a1b2c3d4-...",
"status": "delivered",
"recipient": "..."
}
}
message_id is always the UUID from your send response. event is one of message.sent,
message.delivered, message.read, message.failed. The full per-channel event matrix and
payload fields live in Receiving Messages —
this article is the why behind that contract.
A few non-obvious properties fall out of unifying real channels rather than an idealized one:
(message, status) transition is de-duplicated so a retransmit
never doubles an event.message.sent and message.failed fire on
WhatsApp only. message.delivered and message.read fire on all three — but Instagram's
delivered is the echo signal, and read may never come on WhatsApp if receipts are off.
We expose one API and document the edges, instead of faking states the platform doesn't
emit."Unified messaging API" usually sells the easy half — one endpoint to send. The hard half is the part you only hit after the message leaves: three platforms that disagree on which delivery states exist, what "delivered" means, how a read is addressed, and whether failure is ever reported asynchronously. Collapsing that into one webhook with one stable id, while being honest about the seams, is most of the actual engineering.
Ready to build on it? Sending Messages covers the send side, Receiving Messages is the full status and webhook contract, and you can start for free.
They differ. WhatsApp reports the full lifecycle — sent, delivered, read and an asynchronous failed. Messenger and Instagram report only delivered and read: there is no 'sent' event and no asynchronous failure event on those two channels.
No. Instagram's messaging webhook exposes a read receipt (messaging_seen) and message echoes, but no delivery event. A 'delivered' signal has to be synthesized from the echo Meta sends back about 1–3 seconds after the message is accepted into the conversation thread — so it means 'accepted into the thread', not 'on the device'.
WhatsApp read receipts depend on the recipient's privacy setting. If they have read receipts turned off, the read status never arrives even though they read the message. Treat delivered as the terminal success state; don't block on read.
Messenger reports reads with a watermark — a timestamp meaning 'everything up to here has been read' — instead of per-message IDs. A single read event therefore covers every message sent at or before that timestamp, so it has to be expanded into one read status per message.
Fiwano maps every channel onto one webhook contract: message.sent, message.delivered, message.read and message.failed, each referencing the same UUID you got from the send call. It synthesizes Instagram's delivered from echoes (dropping echoes from other apps on the account), expands Messenger's read watermark per message, and surfaces WhatsApp's asynchronous failures — while documenting which events fire on which channel.
Fiwano API Documentation