API reference

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.

Was this guide useful? Report an issue