> ## Documentation Index
> Fetch the complete documentation index at: https://docs.callkaro.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Message Events

> message_received, message_sent and template_sent

## Event Overview

Three events cover every message that passes through a conversation. They share **one payload shape**, so a single handler works for all of them.

| Event              | Fires when                                      |
| ------------------ | ----------------------------------------------- |
| `message_received` | A contact sends your agent a message — any type |
| `message_sent`     | Your agent sends a non-template message         |
| `template_sent`    | Your agent sends a WhatsApp template            |

**Method:** `POST`\
**Content-Type:** `application/json`

<Note>
  The split between `message_sent` and `template_sent` is deliberate: a campaign integration usually wants template sends without every conversational reply alongside them. A template fires `template_sent` **only** — it does not also fire `message_sent`.
</Note>

<Warning>
  These are **opt-in**. Enable them under Dashboard → Webhook (account-wide) or in a chat agent's Webhook panel. See [Introduction](/webhook/introduction).
</Warning>

## Payload Structure

<CodeGroup>
  ```json message_received theme={null}
  {
    "event": "message_received",
    "data": {
      "userId": "6803fa770b666a64ab1694c1e",
      "agentId": "7103fa770b666a64ab1694c1e",
      "channel": "whatsapp",
      "message_id": "9f2c1b7e5a4d3f8c6b0e2a11",
      "msg_id": "wamid.HBgMOTE5ODc2NTQzMjEwFQIAEhgg",
      "direction": "inbound",
      "type": "text",
      "body": "Where is my order?",
      "template": "",
      "template_json": null,
      "from": "919876543210",
      "to": "919000000000",
      "contact_number": "919876543210",
      "status": "read",
      "agent_phone_number_id": "123456789012345",
      "wa_campaign_id": "",
      "timestamp": "2026-03-01 10:01:00"
    }
  }
  ```

  ```json message_sent theme={null}
  {
    "event": "message_sent",
    "data": {
      "userId": "6803fa770b666a64ab1694c1e",
      "agentId": "7103fa770b666a64ab1694c1e",
      "channel": "whatsapp",
      "message_id": "9f2c1b7e5a4d3f8c6b0e2a12",
      "msg_id": "wamid.HBgMOTE5ODc2NTQzMjEwFQIAERgg",
      "direction": "outbound",
      "type": "text",
      "body": "Your order #1234 shipped yesterday.",
      "template": "",
      "template_json": null,
      "from": "919000000000",
      "to": "919876543210",
      "contact_number": "919876543210",
      "status": "sent",
      "agent_phone_number_id": "123456789012345",
      "wa_campaign_id": "",
      "timestamp": "2026-03-01 10:02:00"
    }
  }
  ```

  ```json template_sent theme={null}
  {
    "event": "template_sent",
    "data": {
      "userId": "6803fa770b666a64ab1694c1e",
      "agentId": "7103fa770b666a64ab1694c1e",
      "channel": "whatsapp",
      "message_id": "9f2c1b7e5a4d3f8c6b0e2a13",
      "msg_id": "wamid.HBgMOTE5ODc2NTQzMjEwFQIAEBgg",
      "direction": "outbound",
      "type": "template",
      "body": "Hi Rahul, your order #1234 has shipped.",
      "template": "order_shipped",
      "template_json": {
        "name": "order_shipped",
        "language": { "code": "en" },
        "components": [
          { "type": "body", "parameters": [{ "type": "text", "text": "Rahul" }] }
        ]
      },
      "from": "919000000000",
      "to": "919876543210",
      "contact_number": "919876543210",
      "status": "sent",
      "agent_phone_number_id": "123456789012345",
      "wa_campaign_id": "spring_sale_2026",
      "timestamp": "2026-03-01 10:00:00"
    }
  }
  ```
</CodeGroup>

## Field Descriptions

| Field                   | Type           | Description                                                                                             |
| ----------------------- | -------------- | ------------------------------------------------------------------------------------------------------- |
| `userId`                | String         | Your CallKaro account ID                                                                                |
| `agentId`               | String         | The chat agent handling this conversation. Empty for sends made through the API rather than by an agent |
| `channel`               | String         | `whatsapp` today                                                                                        |
| `message_id`            | String         | CallKaro's own ID for the stored message. Unique and always present                                     |
| `msg_id`                | String         | WhatsApp's `wamid`. **Empty** when WhatsApp never accepted the message                                  |
| `direction`             | String         | `inbound` or `outbound`                                                                                 |
| `type`                  | String         | `text`, `template`, `interactive`, `image`, `video`, `audio`, `document`, `location`, `contact`, …      |
| `body`                  | String         | The message text. For a template, the rendered body text                                                |
| `template`              | String         | Template name. Empty unless `type` is `template`                                                        |
| `template_json`         | Object \| null | The full template payload as a **real object**, not a string. `null` when there is none                 |
| `from`                  | String         | Sender's number                                                                                         |
| `to`                    | String         | Recipient's number                                                                                      |
| `contact_number`        | String         | **The customer**, whichever direction this is — `from` on inbound, `to` on outbound                     |
| `status`                | String         | Delivery state at the moment of storage — `read` on inbound, `sent` or `failed` on outbound. See below  |
| `agent_phone_number_id` | String         | The WhatsApp `phone_number_id` this went through                                                        |
| `wa_campaign_id`        | String         | Campaign tag, if one was passed on the send                                                             |
| `timestamp`             | String         | `YYYY-MM-DD HH:MM:SS`, IST                                                                              |

<Tip>
  Use `contact_number` rather than branching on `direction` to work out which of `from`/`to` is the customer — it is already resolved for you.
</Tip>

## About `status`

`status` here is the state **at the moment the message was stored** — `sent` for an accepted outbound message, `failed` for a rejected one, and `read` for anything inbound (you are reading it, by definition). It is not the final state of an outbound message.

| Status                | Reached the user?                                                      |
| --------------------- | ---------------------------------------------------------------------- |
| `sent`                | **Not yet** — WhatsApp accepted it, but it is not on the user's device |
| `delivered`           | **Yes** — on their device                                              |
| `read`                | **Yes** — opened                                                       |
| `failed` / `not sent` | **No** — rejected or undeliverable                                     |

To follow a message to `delivered` or `read`, subscribe to [`message_status_updated`](/webhook/message-status-updated).

<Note>
  A **failed** send still fires `message_sent` or `template_sent`, with `status` set to the failure and `msg_id` empty. That is intentional — a rejected send is something you want to know about, not something that silently disappears. Check `status` before treating a `message_sent` as a success.
</Note>

## What never fires

* **Internal records** — `developer` notes, chat-reset markers and link-click records are not messages anyone sent
* **Test conversations** — the Test Chat tab does not deliver real messages, so it does not fire real webhooks
* **`call_ended`** on a chat agent's webhook — see [Introduction](/webhook/introduction)

## Example Implementation

### Node.js (Express)

```javascript theme={null}
const express = require('express');
const app = express();

app.use(express.json());

const seen = new Set(); // in production, use Redis or a DB

app.post('/webhook/callkaro', (req, res) => {
  // Acknowledge first, process after -- a slow response burns the timeout.
  res.sendStatus(200);

  const { event, data } = req.body;

  switch (event) {
    case 'message_received':
      console.log(`Inbound from ${data.contact_number}: ${data.body}`);
      break;

    case 'message_sent':
    case 'template_sent': {
      // Retries and multiple destinations both mean you can see this twice.
      const key = data.message_id;
      if (seen.has(key)) return;
      seen.add(key);

      if (data.status === 'failed' || data.status === 'not sent') {
        console.error(`Send FAILED to ${data.contact_number}`);
      } else {
        console.log(`Sent to ${data.contact_number}: ${data.template || data.body}`);
      }
      break;
    }

    default:
      // Ignore unknown events so new ones cannot break this handler.
      break;
  }
});

app.listen(3000);
```

### Python (FastAPI)

```python theme={null}
from fastapi import FastAPI, Request

app = FastAPI()

MESSAGE_EVENTS = {"message_received", "message_sent", "template_sent"}


@app.post("/webhook/callkaro")
async def handle_webhook(request: Request):
    payload = await request.json()
    event = payload.get("event")
    data = payload.get("data", {})

    if event in MESSAGE_EVENTS:
        print("event          :", event)
        print("contact        :", data.get("contact_number"))
        print("direction      :", data.get("direction"))
        print("type           :", data.get("type"))
        print("body           :", data.get("body"))
        print("status         :", data.get("status"))
        print("campaign       :", data.get("wa_campaign_id"))

        if data.get("type") == "template":
            print("template       :", data.get("template"))
            # already an object -- no json.loads needed
            print("template_json  :", data.get("template_json"))

        if data.get("status") in ("failed", "not sent"):
            print("!! send did not reach the user")

    # 2xx for everything, including events you do not handle.
    return {"status": "success"}
```

## Related

<CardGroup cols={2}>
  <Card title="Delivery Status" icon="check-double" href="/webhook/message-status-updated">
    Follow a message to delivered and read
  </Card>

  <Card title="Get WhatsApp Messages" icon="list" href="/api-reference/whatsapp-messages">
    Pull conversation history over the API instead
  </Card>
</CardGroup>
