---
title: "WhatsApp Baileys webhook"
description: "Messages, read receipts, contacts and the connection lifecycle — in an envelope that never says which event it is."
lang: en
canonical: https://doc.apibrasil.io/webhooks/api-whatsapp-baileys
markdown: https://doc.apibrasil.io/webhooks/api-whatsapp-baileys.md
source: apibrasil-documentation
---

# WhatsApp Baileys webhook

> Messages, read receipts, contacts and the connection lifecycle — in an envelope that never says which event it is.

Baileys reports everything that happens on your number, but the notification arrives unlabelled: the event type is lost in the relay and you work it out from the shape of data. This page is the table that does that reading.

## How to receive

Everything arrives on a single URL, registered as webhook_wh_message on the device. The POST comes with User-Agent APIBRASIL/2.0 and two custom headers — device and version — and no signature. The receiver below is the recognition table as code: it works out the type from the shape, in the order that avoids false positives.

**Where to register the URL and what lands on it**

| Field | What it receives | Events |
| --- | --- | --- |
| `webhook_wh_message` | EVERY event in this channel, on the same URL — messages, statuses, contacts, QR code and connection. | every event of the channel |

The events of this channel are independent: there is no order between them.

## Events (6)

### `messages.upsert` — Message received

**When it fires:** When someone sends a message to your number. RECOGNIZED by data.key and data.message both existing, with data.key.fromMe equal to false.

**Fields**

| Name | Type | Meaning |
| --- | --- | --- |
| `key.remoteJid` | string | Who sent it, WITH the @s.whatsapp.net suffix. Unlike Wpp, it is not stripped here. |
| `key.fromMe` | boolean | false here. This is what separates incoming from outgoing. |
| `key.id` | string | The message's id on WhatsApp. |
| `pushName` | string | The name the person displays on WhatsApp. |
| `message` | object | The content. For plain text it comes as message.conversation; other types use other keys. |
| `messageType` | string | The type, as Baileys names it: "conversation" for plain text. |
| `messageTimestamp` | number | Unix, in seconds. |
| `instanceId` | string | The instance's UUID in Evolution. |

**Sample body, as it arrives**

```json
[
  {
    "session": "<device_token>",
    "channelName": "<your account>",
    "data": {
      "key": {
        "remoteJid": "5531994359434@s.whatsapp.net",
        "fromMe": false,
        "id": "3EB0C767D26B8B5A1C2F"
      },
      "pushName": "João da Silva",
      "message": {
        "conversation": "Hi! I'd like to know more about the API."
      },
      "messageType": "conversation",
      "messageTimestamp": 1751551282,
      "instanceId": "802fdb80-8c48-4c76-94d3-2118f3d3a69b"
    }
  }
]
```

### `send.message` — Message sent

**When it fires:** When your number sends — through the API or from the handset. SAME SHAPE as the incoming one; what changes is data.key.fromMe equal to true.

**Fields**

| Name | Type | Meaning |
| --- | --- | --- |
| `key.remoteJid` | string | Who it went to, with the suffix. |
| `key.fromMe` | boolean | true here. It is the ONLY shape difference from an incoming message. |
| `key.id` | string | The message id. |
| `message` | object | The content that was sent. |
| `messageTimestamp` | number | Unix, in seconds. |

**Sample body, as it arrives**

```json
[
  {
    "session": "<device_token>",
    "channelName": "<your account>",
    "data": {
      "key": {
        "remoteJid": "5531994359434@s.whatsapp.net",
        "fromMe": true,
        "id": "3F6ED9C4DD811BFC2FD9"
      },
      "message": {
        "conversation": "Of course! I'll generate it for you right away."
      },
      "messageType": "conversation",
      "messageTimestamp": 1751551340,
      "instanceId": "802fdb80-8c48-4c76-94d3-2118f3d3a69b"
    }
  }
]
```

### `messages.update` — Delivered or read

**When it fires:** When a message of yours changes state. RECOGNIZED by data.status together with data.messageId — and by the ABSENCE of data.message, which is what separates it from a message.

**Fields**

| Name | Type | Meaning |
| --- | --- | --- |
| `messageId` | string | Evolution's internal id — NOT the same id that came in key.id on the message. |
| `keyId` | string | THIS is the one that matches the original message's key.id. It is what joins them. |
| `remoteJid` | string | The conversation, with the suffix. |
| `fromMe` | boolean | Here at the root of data, not inside key. |
| `participant` | string | In a group, whose state this is. |
| `status` | string | The state, uppercase: DELIVERY_ACK, READ, SERVER_ACK, PLAYED. |
| `instanceId` | string | The instance's UUID. |

**Sample body, as it arrives**

```json
[
  {
    "session": "<device_token>",
    "channelName": "<your account>",
    "data": {
      "messageId": "cmb6unurm0pm4ps5xjinc8383",
      "keyId": "3F6ED9C4DD811BFC2FD9",
      "remoteJid": "553194359434:9@s.whatsapp.net",
      "fromMe": true,
      "participant": "553194359434:9@s.whatsapp.net",
      "status": "DELIVERY_ACK",
      "instanceId": "802fdb80-8c48-4c76-94d3-2118f3d3a69b"
    }
  }
]
```

### `qrcode.updated` — QR code for pairing

**When it fires:** When the instance needs to be authenticated. RECOGNIZED by data.qrcode existing — the simplest check in the channel.

**Fields**

| Name | Type | Meaning |
| --- | --- | --- |
| `qrcode.instance` | string | The instance name. |
| `qrcode.pairingCode` | string \| null | The pairing code, when requested. Null in the QR flow. |
| `qrcode.code` | string | The QR's contents, so you can render the image yourself. |
| `qrcode.base64` | string | The ready-made image, as a PNG data URL. |

**Sample body, as it arrives**

```json
[
  {
    "session": "<device_token>",
    "channelName": "<your account>",
    "data": {
      "qrcode": {
        "instance": "apibrasilbaileys",
        "pairingCode": null,
        "code": "2@uze42H2e6c…",
        "base64": "data:image/png;base64,…"
      }
    }
  }
]
```

### `connection.update` — The connection changed state

**When it fires:** On every connection change: connecting, open, close. RECOGNIZED by data.state being a string. When the state is open, the notification also carries wuid and the profile picture — in the others, only state and statusReason.

**Fields**

| Name | Type | Meaning |
| --- | --- | --- |
| `instance` | string | The instance name. |
| `state` | string | connecting, open or close. This is the field that decides whether you need to reconnect. |
| `statusReason` | number | The reason code. 200 when it is normal. |
| `wuid` | string | Only when state is open: the number that just connected, with the suffix. |
| `profilePictureUrl` | string | Only when state is open. Profile picture URL, temporary. |

**Sample body, as it arrives**

```json
[
  {
    "session": "<device_token>",
    "channelName": "<your account>",
    "data": {
      "instance": "apibrasilbaileys",
      "wuid": "553171599698@s.whatsapp.net",
      "profilePictureUrl": "https://pps.whatsapp.net/v/t61.24694-24/…",
      "state": "open",
      "statusReason": 200
    }
  }
]
```

### `contacts.upsert` — Contacts synced

**When it fires:** When the instance syncs the address book — usually right after connecting. It is the ONLY event where data is an ARRAY, which is why the array check comes first in the table.

**Fields**

| Name | Type | Meaning |
| --- | --- | --- |
| `[].remoteJid` | string | The contact, with the @s.whatsapp.net suffix. |
| `[].pushName` | string | The name the contact displays. |
| `[].profilePicUrl` | string \| null | The picture, when visible. |
| `[].instanceId` | string | The instance's UUID. |

**Sample body, as it arrives**

```json
[
  {
    "session": "<device_token>",
    "channelName": "<your account>",
    "data": [
      {
        "remoteJid": "5515996139173@s.whatsapp.net",
        "pushName": "Camillo Alex",
        "profilePicUrl": null,
        "instanceId": "802fdb80-8c48-4c76-94d3-2118f3d3a69b"
      },
      {
        "remoteJid": "553187840265@s.whatsapp.net",
        "pushName": "Marcos Noronha",
        "profilePicUrl": null,
        "instanceId": "802fdb80-8c48-4c76-94d3-2118f3d3a69b"
      }
    ]
  }
]
```

## What usually bites

- **There is no type field. The shape is the type.** — The body is { session, channelName, data } and nothing else. Evolution sends the event name in event, but APIBrasil's relay delivers only the inner data. There is no field to route on — you have to recognize the shape, in the order given by the event table.
- **session is the device token; channelName is your account** — The names mislead. In the relay, session gets the device_token and channelName gets your account's identifier. Neither is the instance name — that lives in data.instance, and only in some events.
- **data is sometimes an array** — For contacts, data is a LIST of contacts, not an object. Reading data.remoteJid returns undefined; the path is data[0].remoteJid. It is the first check your router has to make, before any property access.
- **An incoming message and an outgoing one have the SAME shape** — Both arrive with data.key and data.message. What separates them is data.key.fromMe — true is yours, false is the contact's. Without that check, your system replies to its own messages.
- **One destination only, and it is called webhook_wh_message** — Unlike WhatsApp Wpp, which has four URLs, here everything lands on one. The field name suggests messages, but QR codes, connection events and contacts come through it too.
- **No signature. Identification comes in headers.** — There is no HMAC in this channel — Wpp has one, this does not. The POST arrives with User-Agent APIBRASIL/2.0, Origin https://webhook.apibrasil.cloud and two custom headers: device, with the device token, and version, with the server version. Those are what you check the origin against.

## Good practices

- **The ORDER of the checks is not optional** — Array first, otherwise contacts breaks any property access. Then qrcode and state, which are unambiguous. Only then status (which requires message to be absent) and finally key/message. Swapping the last two classifies an ACK as a message.
- **Handle the unknown, do not discard it** — Evolution publishes 33 events and only six shapes are known here. A new shape falls into the final branch — log it instead of ignoring it, otherwise the channel's first new feature arrives as silence.
- **Respond 200, and in under 30 seconds** — Any status other than 200 is recorded as a failure and raises a notification. The relay's HTTP client waits 120s, but the job running it is killed at 30 — in practice, your limit is 30 seconds.
- **There are two attempts, not three** — The relay job declares tries = 2. If the second one fails, the notification is lost — there is no redelivery after that.

## Sample receivers

**Node**

```node
app.post("/webhook/baileys", express.json(), (req, res) => {
  const { session, channelName, data } = req.body

  // Check the origin from the headers: there is no signature in this channel.
  if (req.get("device") !== DEVICE_TOKEN) return res.sendStatus(401)

  res.sendStatus(200) // you have 30 seconds

  // ORDER MATTERS — from the most specific shape to the most generic.
  if (Array.isArray(data)) {
    console.log(`${data.length} contacts synced`)
  } else if (data.qrcode) {
    showQrCode(data.qrcode.base64)
  } else if (typeof data.state === "string") {
    if (data.state === "close") alertConnectionDropped(session)
  } else if (data.status && !data.message) {
    // keyId is what matches the original message's key.id.
    console.log(`${data.keyId} is now ${data.status}`)
  } else if (data.key) {
    if (data.key.fromMe) return // your own message: do not reply to yourself
    console.log(`${data.pushName}: ${data.message?.conversation}`)
  } else {
    console.warn("Unknown Baileys shape", data)
  }
})
```

**Python**

```python
@app.post("/webhook/baileys")
def webhook_baileys():
    if request.headers.get("device") != DEVICE_TOKEN:
        return "", 401

    body = request.get_json(force=True)
    data = body["data"]

    # ORDER MATTERS — from the most specific shape to the most generic.
    if isinstance(data, list):
        print(f"{len(data)} contacts synced")
    elif "qrcode" in data:
        show_qrcode(data["qrcode"]["base64"])
    elif isinstance(data.get("state"), str):
        if data["state"] == "close":
            alert_connection_dropped(body["session"])
    elif data.get("status") and "message" not in data:
        # keyId is what matches the original message's key.id.
        print(f"{data['keyId']} is now {data['status']}")
    elif "key" in data:
        if data["key"]["fromMe"]:
            return "", 200  # your own message
        print(f"{data.get('pushName')}: {data['message'].get('conversation')}")
    else:
        print("Unknown Baileys shape", data)

    return "", 200
```

**PHP**

```php
<?php

if (($_SERVER['HTTP_DEVICE'] ?? null) !== DEVICE_TOKEN) {
    http_response_code(401);
    exit;
}

http_response_code(200); // you have 30 seconds

$body = json_decode(file_get_contents('php://input'), true);
$data = $body['data'];

// ORDER MATTERS — from the most specific shape to the most generic.
if (array_is_list($data)) {
    error_log(count($data) . ' contacts synced');
} elseif (isset($data['qrcode'])) {
    showQrCode($data['qrcode']['base64']);
} elseif (is_string($data['state'] ?? null)) {
    if ($data['state'] === 'close') {
        alertConnectionDropped($body['session']);
    }
} elseif (isset($data['status']) && !isset($data['message'])) {
    // keyId is what matches the original message's key.id.
    error_log("{$data['keyId']} is now {$data['status']}");
} elseif (isset($data['key'])) {
    if ($data['key']['fromMe']) {
        exit; // your own message
    }
    error_log(($data['pushName'] ?? '') . ': ' . ($data['message']['conversation'] ?? ''));
} else {
    error_log('Unknown Baileys shape');
}
```

## See also

- Webhooks: [Webhooks](https://doc.apibrasil.io/webhooks.md)
- Documentation index: https://doc.apibrasil.io/llms.txt
- HTML version of this page: https://doc.apibrasil.io/webhooks/api-whatsapp-baileys
