Webhooks

WhatsApp webhook events

Receive inbound messages, delivery updates and connection changes at your own HTTPS endpoint.

Events

Messages: message.accepted, message.queued, message.sent, message.delivered, message.read, message.failed, message.received and message.reply_correlated. After a message is sent: message.reaction (someone reacted, or took a reaction back), message.edited, message.revoked (deleted for everyone) and poll.vote. Groups and calls: group.participants_changed (people joined, left, or were made or unmade admins) and call.received (with rejected saying whether the connection declined it). Connections: connection.connected, connection.degraded, connection.disconnected and connection.requires_repair. Schedules: schedule.created and schedule.sent. People who ask to stop: recipient.opted_out. AI, when it is enabled on a connection: ai.reply_generated and ai.reply_blocked.

Subscribe to exact names, to a family with message.*, or to everything with *. A name that is not a real event is rejected rather than stored, so a typo cannot leave you silently subscribed to nothing.

What each connection state means

Most connection events heal by themselves, and two do not. Knowing which is which is the difference between an alert and a false alarm: connection.degraded means the session is impaired but usable, connection.disconnected means it dropped and is retrying on its own, and both of those recover without anyone intervening. connection.requires_repair means the session cannot recover and someone has to re-pair the number. connection.suspended is not a fault either — it is a deliberate pause that will not lift on its own, so it needs a resume rather than a repair.

If you alert on one thing, alert on connection.requires_repair. A number that sits in degraded and then disconnected is usually a number that will need a human, which makes the first two events the leading indicator rather than the incident. For how these map to re-pairing, see the 30-day risk checklist for numbers running in production.

What a reply carries

This is the part worth reading twice. When an inbound message can be linked to an alert you sent, message.received carries that alert and the structured context you attached to it — not an identifier to go and fetch. A workflow tool that can only act on the request body in front of it still knows which order, ticket or property the reply is about.

POST your endpoint — message.received
{
  "id": "whd_9f2c81a0e34b4d1e8c5a7b93f0d2e611",
  "type": "message.received",
  "created_at": "2026-09-22T09:14:03.118Z",
  "workspace_id": "019bf4a1-0000-7000-8000-000000000000",
  "data": {
    "message_id": "019bf4b2-0000-7000-8000-000000000001",
    "conversation_id": "019bf4b2-0000-7000-8000-0000000000c4",
    "connection": "main",
    "from": { "phone": "+15551234567" },
    "body": "Can it come Friday instead?",
    "kind": "text",
    "media": null,
    "received_at": "2026-09-22T09:14:02.740Z",

    "reply_to_message_id": "019bf4a9-0000-7000-8000-00000000003f",
    "correlation": {
      "method": "single_open_alert",
      "confidence": 0.9,
      "explanation": "Exactly one unanswered alert was open in this conversation."
    },

    "correlated_message": {
      "message_id": "019bf4a9-0000-7000-8000-00000000003f",
      "body": "Order CR-2048 has shipped. ETA Thursday.",
      "truncated": false,
      "status": "delivered",
      "sent_at": "2026-09-22T08:02:11.004Z",
      "created_at": "2026-09-22T08:02:10.887Z"
    },

    "context": {
      "included": true,
      "sensitivity": "normal",
      "payload": { "carrier": "DHL", "eta": "Thursday", "order_id": "CR-2048" },
      "keys": ["carrier", "eta", "order_id"],
      "omitted_keys": []
    },

    "history": [
      {
        "message_id": "019bf4a9-0000-7000-8000-00000000003f",
        "direction": "outbound",
        "body": "Order CR-2048 has shipped. ETA Thursday.",
        "truncated": false,
        "at": "2026-09-22T08:02:10.887Z"
      }
    ],
    "history_truncated": false
  }
}

Driving a review queue

In draft mode a reply is proposed and waits for a human. ai.reply_generated carries the proposal — the draft text, the run id, the message it answers and what the run cost — so a workflow tool can put it in front of somebody without polling. Approve it with POST /v1/ai/runs/{id}/send, optionally passing an edited body, or decline it with /dismiss and a reason.

ai.reply_blocked fires when a rule you set stopped the run instead: context_policy for context you marked restricted, budget_exceeded for a spent monthly ceiling, recipient_opted_out when the person has asked not to be messaged. Blocked is not failed — nothing went wrong, so the two carry different names and a reason rather than leaving you to infer which happened.

When someone asks you to stop

A direct-chat message that is only a stop phrase — “STOP”, “unsubscribe”, “please stop messaging me” — opts that number out of your workspace. The message is delivered to you as usual, then recipient.opted_out follows once, with phone, source (inbound_keyword), message_id, conversation_id, connection and opted_out_at. From then on a send to that number is refused with RECIPIENT_OPTED_OUT (HTTP 422), including messages already queued or scheduled, and the AI does not reply.

Only a whole message counts, so “can you stop by at 5?” does not, and neither does a photo captioned “stop” or a message in a group. Only English phrases are recognised, and “cancel” is not one, because it usually answers an appointment reminder. For anyone you are asked to stop contacting any other way, add them yourself. Read and change the list with GET, POST and DELETE /v1/opt-outs.

When the reply is a photo or a voice note

A reply is not always text. message.received carries kind — text, image, video, audio or document — and, when a file came with it, a media object with its mimetype, filename, size, duration and a voice flag that tells a WhatsApp voice note apart from an audio file someone forwarded.

body is the caption, and an empty caption is normal rather than missing. Fetch the file itself from media.download_url, which is an authenticated ChatRail endpoint scoped to your workspace — the transport’s own file reference is never exposed. That field is null when the transport reported a file it will not serve; the message still arrives, because a reply matters whether or not its attachment can be fetched.

POST your endpoint — message.received with a voice note
{
  "id": "whd_2a71c04f9e1b4c6d8f3a5e70b2c1d904",
  "type": "message.received",
  "created_at": "2026-09-22T09:20:41.552Z",
  "workspace_id": "019bf4a1-0000-7000-8000-000000000000",
  "data": {
    "message_id": "019bf4c3-0000-7000-8000-000000000002",
    "connection": "main",
    "from": { "phone": "+15551234567" },
    "body": "",
    "kind": "audio",
    "media": {
      "kind": "audio",
      "mimetype": "audio/ogg; codecs=opus",
      "filename": null,
      "byte_size": 18244,
      "duration_seconds": 7,
      "voice": true,
      "download_url": "/v1/messages/019bf4c3-0000-7000-8000-000000000002/media"
    },
    "received_at": "2026-09-22T09:20:41.108Z"
  }
}

Resolve download_url against the API base URL and request it with your API key. It streams the file back with the stored content type, Content-Disposition: attachment and X-Content-Type-Options: nosniff. An outbound attachment does not carry a download_url: that URL is the one you supplied when you sent it.

Correlation is stated, not implied

correlation.method tells you which rule produced the link and confidence how much to trust it. native_quote (confidence 1.0) means the customer used WhatsApp’s own reply-to and the link is exact. single_open_alert means exactly one alert was awaiting an answer. model_choice means several alerts were open and a model read the reply and chose one; confidence is its probability, and it is only used above a fixed floor. recent_outbound is an explicit guess at the most recent candidate, with confidence decaying by age. none means no defensible link was found, and reply_to_message_id is null.

Threshold on confidence before you let a reply trigger anything consequential. ChatRail returns no correlation rather than a confident wrong one, which is the behaviour that makes thresholding worth doing. The reply correlation guide covers keeping that link in your own system.

Context that is withheld says so

Context you marked restricted, and context excluded from AI, is not delivered to an endpoint. You receive {"included": false, "sensitivity": "restricted", "reason": "restricted"} in its place, so a missing value is never indistinguishable from a value that was never attached. Those values remain readable from the messages API, where you are the one making the request.

keys and omitted_keys report exactly which fields survived the size budget. history is a bounded window of recent turns in the same conversation, and history_truncated tells you when older turns existed but did not fit.

Verification

Every request carries chatrail-signature and chatrail-timestamp. The signature is an HMAC-SHA256 over <timestamp>.<raw body>, hex encoded and prefixed v1=. Verify against the raw bytes you received: re-serialising parsed JSON changes key order and escaping, and the signature will not match. During a secret rotation more than one v1= value may be present — accept the request if any of them verifies.

Reliable processing

Return a successful response quickly and do the work asynchronously. Deduplicate on the envelope id: retries and provider redeliveries mean the same event can arrive more than once, and your handler should be safe when it does.

BUILD A CONTROLLED TEST

Start with one number and one real workflow.

Validate the connection, delivery state and reply path before increasing traffic.

Start free
STRAIGHT ANSWERS

Questions before you build?

Start with the documentation or review the practical answers below.

Is ChatRail the official Meta Cloud API?

No. ChatRail uses a WhatsApp Linked Devices session. Review the transport trade-offs before production use.

Can I use it without AI?

Yes. Sending, delivery tracking and webhooks work without enabling AI.

Where should I start?

Use the Quickstart for your first request, then choose a workflow tutorial.