Webhook event reference
Every published event and the exact payload it carries.
Event reference
Four events are published today — see Webhooks for the envelope they arrive in. Two report messages arriving at your numbers; two report the outcome of messages you sent.
Subscribe only to what you handle. There is no wildcard: a future event will never start arriving at an endpoint that predates it, so adding events stays a deliberate act rather than a surprise in production.
message.received
Someone sent a message to one of your connected numbers.
Fires once per inbound message, after we have stored it. A number with receiving switched off produces no event, because nothing was taken in.
{
"id": "ce93fdad-1a30-4a67-a4d6-7c17e513240a",
"type": "message.received",
"api_version": "2026-07-20",
"occurred_at": "2026-07-20T11:06:18+00:00",
"data": {
"message": {
"id": "9bbe8e93-a33a-4221-9466-dc4847786444",
"type": "media",
"body": "",
"attachments": [
{ "index": 0, "mime_type": "image/jpeg", "filename": "image", "state": "processing" }
]
},
"contact": {
"id": "f57f3c61-fb23-45b9-ad6d-3d856ebf87d0",
"phone": "+923001234567",
"name": null
},
"conversation": { "id": "e207cce6-cc85-41c4-9fae-90b6db26b25d" },
"connection": { "id": "bbf1ba6b-be14-491c-9d9f-8d8317f1e542", "name": "Support line" }
}
}
| Field | Notes |
|---|---|
message.id |
Use with the API to fetch or reply |
message.type |
text or media |
message.body |
Caption when the message carries media; "" when there is none |
message.attachments[] |
Empty for a text message — see below |
contact.phone |
E.164. Always a real phone number |
contact.name |
The saved contact name, or null if unknown |
conversation.id |
Groups messages with the same contact on the same number |
connection.id |
Which of your numbers received it |
contact.phone is a genuine, dialable number. WhatsApp increasingly addresses
senders by an internal privacy identifier that looks numeric but is not a phone
number; we resolve it before the event is built, and drop the message rather than
hand you an identifier you could not reply to.
Group, broadcast, status and channel traffic is not delivered. Only direct one-to-one messages produce this event.
Attachments arrive in two stages
An attachment appears here with "state": "processing" and no media_id.
At this moment we know what is coming, but the file is still being fetched from
WhatsApp and scanned for malware.
Do not wait on it inside your handler. Respond 200, and act on the file when
media.available arrives.
media.available
An inbound attachment finished downloading and passed the malware scan. It now
has a stable media_id.
{
"id": "7b21c0de-3f44-4a8e-9d21-0c5a7e1b4f90",
"type": "media.available",
"api_version": "2026-07-20",
"occurred_at": "2026-07-20T11:06:31+00:00",
"data": {
"media": {
"id": "15bde241-97b6-4a35-af22-8b7bf2a5974a",
"mime_type": "image/jpeg",
"filename": "image",
"byte_size": 42476,
"sha256": "9f2c1ab0d4e7…c31b"
},
"message": { "id": "9bbe8e93-a33a-4221-9466-dc4847786444" },
"attachment_index": 0
}
}
Three ids, and which one to use
This payload carries three UUIDs. They are not interchangeable, and picking the
wrong one returns The media object was not found.
| Field | What it identifies | Use it for |
|---|---|---|
id (top level) |
The event itself | Deduplication. Never for lookups. |
data.media.id |
The file | Downloading — this is the one you want |
data.message.id |
The message it arrived with | Correlating back to message.received |
So the download uses data.media.id:
# data.media.id from the payload above — not the event id, not the message id
curl -o image.jpg \
-H 'Authorization: Bearer cap_…' \
https://your-domain.com/api/v1/media/15bde241-97b6-4a35-af22-8b7bf2a5974a/download
In code, reach for it explicitly rather than a bare id:
const mediaId = event.data.media.id; // correct
const wrong = event.id; // the event id — not a file
Correlate with the earlier message.received using data.message.id plus
attachment_index — the index matches the position in that event's
attachments array.
See Media for states, retention and the metadata endpoint.
A message with three attachments produces one message.received and up to three
media.available events, in no guaranteed order, each seconds to minutes later
depending on file size.
An attachment that fails the malware scan or exceeds your plan's size limit
produces no media.available event. Absence is the signal; there is no
rejection event to wait for. If you need certainty, reconcile against the
message record rather than blocking on an event that will not come.
message.delivered
A message you sent reached the recipient's device.
{
"id": "1f2e3d4c-5b6a-7980-9a1b-2c3d4e5f6071",
"type": "message.delivered",
"api_version": "2026-07-20",
"occurred_at": "2026-07-21T09:14:02+00:00",
"data": {
"message": {
"id": "9a8b7c6d-5e4f-4031-9283-1a2b3c4d5e6f",
"status": "delivered",
"type": "text"
},
"contact": {
"id": "eec60e96-3e2b-4823-84e7-7e0d761e84e0",
"phone": "+12025550405",
"name": "Ayesha Khan"
},
"conversation": { "id": "75b11a42-00f3-4ba6-b89d-0f66ade17402" },
"connection": { "id": "bbf1ba6b-be14-491c-9d9f-8d8317f1e542", "name": "Support" }
}
}
data.message.id is the same id POST /messages returned, so you can correlate
without storing anything else.
Delivered means the recipient's device acknowledged receipt. It does not mean anyone has read it — reading is not published today.
message.failed
A message you sent will not be delivered. This is terminal: there is no retry behind it and no later event that reverses it.
{
"id": "2a3b4c5d-6e7f-8091-a2b3-c4d5e6f70819",
"type": "message.failed",
"api_version": "2026-07-20",
"occurred_at": "2026-07-21T09:14:02+00:00",
"data": {
"message": {
"id": "0a140511-b7a5-4cfa-9add-a0eb6cbeed4d",
"status": "failed",
"type": "text",
"failure_code": "connection_not_ready"
},
"contact": {
"id": "eec60e96-3e2b-4823-84e7-7e0d761e84e0",
"phone": "+12025550405",
"name": null
},
"conversation": { "id": "75b11a42-00f3-4ba6-b89d-0f66ade17402" },
"connection": { "id": "bbf1ba6b-be14-491c-9d9f-8d8317f1e542", "name": "Support" }
}
}
Read failure_code, not just the event
The reasons need opposite responses, so branching on the event alone is not enough. This list is not exhaustive — codes come both from us and from the messaging engine, and new engine codes can appear without a version change.
Safe to resend once the cause is addressed:
failure_code |
What happened |
|---|---|
connection_not_ready |
The number was not linked when the send was attempted |
engine_not_ready |
The connection exists but its session was not usable yet |
engine_unavailable |
The messaging engine could not be reached |
gateway_dispatch_failed |
We could not hand the message over |
gateway_send_not_sent_confirmed |
Reviewed and confirmed never sent |
Do not resend automatically:
failure_code |
Why |
|---|---|
idempotency_conflict |
The send was a duplicate; the original may have gone out |
command_processing_timeout |
The outcome is genuinely unknown — resending risks a second message |
gateway_message_failed |
The engine refused it without a specific reason; check the recipient first |
gateway_command_failed |
Generic terminal failure; inspect before retrying |
Treat any code you do not recognise as the second group: surface it rather than retrying. Resending on an unknown code is how one failure becomes two messages to a real person.
A send can fail before it is ever attempted
POST /messages returning 202 means the message was accepted and queued, not
that it left the building. A rejected number, an unlinked device or a provider
refusal all arrive later as this event. A 2xx from the send call is not
confirmation of delivery — if your workflow depends on the message actually
arriving, wait for message.delivered.
campaign.started
A campaign began sending its first batch. Fires once per campaign, not once per batch — a large audience is sent in batches and only the first one announces.
{
"id": "ef40c55b-5865-4c2c-89fd-a27b4a2160c5",
"type": "campaign.started",
"api_version": "2026-07-20",
"occurred_at": "2026-07-23T11:21:36+00:00",
"data": {
"campaign": {
"id": "8747b453-23b7-401e-bc61-26308e8e7a97",
"name": "July wholesale offer",
"status": "running",
"recipient_count": 171,
"sent_count": 0,
"failed_count": 0,
"started_at": "2026-07-23T11:21:35+00:00",
"completed_at": null
}
}
}
campaign.completed
The campaign finished. The counters in the payload are final — the event is
announced only after the last batch settles, so sent_count and
failed_count here are the numbers the dashboard will show. If you reconcile
campaign outcomes into your own system, this one event replaces polling
GET /campaigns/{id}.
{
"id": "71516776-4aab-443c-b907-672a91688fd1",
"type": "campaign.completed",
"api_version": "2026-07-20",
"occurred_at": "2026-07-23T11:21:37+00:00",
"data": {
"campaign": {
"id": "8747b453-23b7-401e-bc61-26308e8e7a97",
"name": "July wholesale offer",
"status": "completed",
"recipient_count": 171,
"sent_count": 168,
"failed_count": 3,
"started_at": "2026-07-23T11:21:35+00:00",
"completed_at": "2026-07-23T11:24:02+00:00"
}
}
}
Per-recipient failures are not in the event — fetch
GET /campaigns/{id}/recipients for each person's status and
failure_code.
campaign.failed
The campaign stopped before sending — invalid content, an unusable
connection, or no reachable recipients at dispatch time. data.reason
carries the code. This is about the campaign as a whole; an individual
recipient failing does not produce it.
{
"id": "9c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
"type": "campaign.failed",
"api_version": "2026-07-20",
"occurred_at": "2026-07-23T11:30:00+00:00",
"data": {
"campaign": {
"id": "8747b453-23b7-401e-bc61-26308e8e7a97",
"name": "July wholesale offer",
"status": "failed",
"recipient_count": 0,
"sent_count": 0,
"failed_count": 0,
"started_at": null,
"completed_at": "2026-07-23T11:30:00+00:00"
},
"reason": "content_invalid"
}
}
campaign.paused
The campaign paused itself instead of continuing. Today this comes from the
canary: the small test batch a campaign sends first looked risky, so the
rest was held back rather than sent (see Campaigns). data.reason
is the machine code (canary_failed) and data.canary_reason is the
human-readable why. The campaign sits in paused until a person resumes it.
{
"id": "b2c3d4e5-6f70-4a1b-9c8d-2e3f4a5b6c7d",
"type": "campaign.paused",
"api_version": "2026-07-20",
"occurred_at": "2026-07-23T11:22:10+00:00",
"data": {
"campaign": {
"id": "8747b453-23b7-401e-bc61-26308e8e7a97",
"name": "July wholesale offer",
"status": "paused",
"recipient_count": 171,
"sent_count": 8,
"failed_count": 2,
"started_at": "2026-07-23T11:21:35+00:00",
"completed_at": null
},
"reason": "canary_failed",
"canary_reason": "the number's health is risky"
}
}
Deprecation
Adding a field or a new event type is additive and will not move api_version.
Removing or renaming anything is breaking, moves api_version, and will be
announced before it ships.
Ignore unrecognised fields rather than rejecting them, and your integration will survive every additive change without a deploy.