Webhooks

Message Events

message.inbound.* and message.outbound.* - a message arrived, changed, or was deleted

Six events, split by direction. Direction is decided by the message itself: anything from the contact is inbound, anything sent by an operator, a flow, an AI assistant, or your own API call is outbound.

Subscribe with the enum names MESSAGE_INBOUND_CREATED, MESSAGE_INBOUND_UPDATED, MESSAGE_INBOUND_DELETED, MESSAGE_OUTBOUND_CREATED, MESSAGE_OUTBOUND_UPDATED, MESSAGE_OUTBOUND_DELETED.

message.inbound.created

type: message.inbound.created

A contact sent a message on any connected channel.

data

type Data = {
  chat: ChatPayload;
  message: MessagePayload;  // direction is always "INBOUND"
  contact?: ContactPayload; // present when the sender resolves to a contact
};

See ChatPayload, MessagePayload, ContactPayload.

Example

{
  "id": "evt_1a2b3c4d-5e6f-7081-92a3-b4c5d6e7f809",
  "type": "message.inbound.created",
  "timestamp": "2026-09-30T18:00:00.000Z",
  "data": {
    "chat": {
      "id": "6804f4c2a6f9f35f6e66f1a1",
      "integrationId": "67f95b0126a4d1c9e3f0aa12",
      "channel": "WHATSAPP",
      "whatsappId": "wa_99887766",
      "firstName": "Alex",
      "conversationId": "6804f4d5a6f9f35f6e66f1c4",
      "contactId": "6804f4d5a6f9f35f6e66f1a2"
    },
    "message": {
      "id": "6804f4f01c34d0012ab8a055",
      "chatId": "6804f4c2a6f9f35f6e66f1a1",
      "direction": "INBOUND",
      "type": "TEXT",
      "text": "where is my order?",
      "receivedAt": "2026-09-30T18:00:00.000Z",
      "conversationId": "6804f4d5a6f9f35f6e66f1c4",
      "contactId": "6804f4d5a6f9f35f6e66f1a2",
      "peopleId": "6804f4d5a6f9f35f6e66f1a2",
      "channel": "WHATSAPP",
      "fromModel": "People",
      "author": { "kind": "People", "id": "6804f4d5a6f9f35f6e66f1a2" },
      "raw": { "providerMessageId": "wamid.HBgLMTU1NTEyMzQ1NjcVAgAS" }
    },
    "contact": {
      "peopleId": "6804f4d5a6f9f35f6e66f1a2",
      "phoneNumber": "+15551234567",
      "firstName": "Alex",
      "contactId": "6804f4d5a6f9f35f6e66f1a2"
    }
  },
  "meta": {
    "eventId": "evt_1a2b3c4d-5e6f-7081-92a3-b4c5d6e7f809",
    "event": "message.inbound.created",
    "occurredAt": "2026-09-30T18:00:00.000Z",
    "organisationId": "6716b2f0a1c34d0012ab89ef",
    "externalId": "crmco-client-5541",
    "partnerId": "6716b1001c34d0012ab89abc",
    "apiVersion": "2026-04-01",
    "attempt": 1
  }
}

This is the event you reply to. Take data.chat.id and pass it as chatId to sendMessage.

message.outbound.created

type: message.outbound.created

A message was sent to the contact - by an operator in the dashboard, by a flow, by an AI assistant, or by your own sendMessage call.

data - identical to message.inbound.created, with message.direction always "OUTBOUND" and sentAt set instead of receivedAt.

Check author.kind to see who replied. A human operator is User, the AI assistant is AIAssistant (Bot is a flow or bot, not the AI). Both arrive as message.outbound.created, and nothing else in the payload separates them - so this is how you label a reply in your own UI, or decide whether to count it against an operator's workload.

Your own sends come back to you as message.outbound.created. If you mirror blindly you will duplicate messages you already have. Deduplicate on the _id returned by the mutation, which equals data.message.id here.

message.inbound.updated / message.outbound.updated

type: message.inbound.updated · message.outbound.updated

The message content changed - an edit.

message.outbound.updated no longer carries delivery-status changes. Those moved to the opt-in message.outbound.status event. If you were reading deliveryStatus here, subscribe to that event or you will stop seeing status transitions.

data

type Data = {
  chat: ChatPayload;
  message: MessagePayload;
  contact?: ContactPayload;
  previous?: Partial<MessagePayload>; // only the fields that changed
};

previous holds the prior values of the changed fields, when the emitting code supplied them. Diff against it rather than re-reading the whole message.

Example - an edit with the prior values

{
  "id": "evt_5f6a7b8c-9d0e-1f20-3a4b-5c6d7e8f9012",
  "type": "message.outbound.updated",
  "timestamp": "2026-09-30T18:00:04.000Z",
  "data": {
    "chat": { "id": "6804f4c2a6f9f35f6e66f1a1", "channel": "WHATSAPP" },
    "message": {
      "id": "6804f5011c34d0012ab8a077",
      "chatId": "6804f4c2a6f9f35f6e66f1a1",
      "direction": "OUTBOUND",
      "type": "TEXT",
      "text": "Your pizza is on the way - about 15 minutes 🍕",
      "sentAt": "2026-09-30T18:00:00.000Z"
    },
    "previous": { "text": "Your pizza is on the way 🍕" }
  },
  "meta": {
    "eventId": "evt_5f6a7b8c-9d0e-1f20-3a4b-5c6d7e8f9012",
    "event": "message.outbound.updated",
    "occurredAt": "2026-09-30T18:00:04.000Z",
    "organisationId": "6716b2f0a1c34d0012ab89ef",
    "externalId": "crmco-client-5541",
    "partnerId": "6716b1001c34d0012ab89abc",
    "apiVersion": "2026-04-01",
    "attempt": 1
  }
}

A failed send surfaces on message.outbound.status with errorMessage set. It is not a webhook error and not a 4xx on your endpoint - see Channel constraints → Delivery failures.

message.inbound.deleted / message.outbound.deleted

type: message.inbound.deleted · message.outbound.deleted

The message was hard-deleted. The payload is deliberately minimal - the full message no longer exists to describe.

data

type Data = {
  chat: {
    id: string;
    integrationId?: string;
    channel?: "WHATSAPP" | "TELEGRAM" | "VIBER" | "INSTAGRAM" | "WEB";
  };
  message: {
    id: string;
    chatId: string;
    direction: "INBOUND" | "OUTBOUND";
    type: MessageType;
  };
  deletedAt: string; // ISO-8601 UTC
};

Example

{
  "id": "evt_9a0b1c2d-3e4f-5061-7283-94a5b6c7d8e9",
  "type": "message.inbound.deleted",
  "timestamp": "2026-09-30T18:02:11.000Z",
  "data": {
    "chat": {
      "id": "6804f4c2a6f9f35f6e66f1a1",
      "integrationId": "67f95b0126a4d1c9e3f0aa12",
      "channel": "WHATSAPP"
    },
    "message": {
      "id": "6804f4f01c34d0012ab8a055",
      "chatId": "6804f4c2a6f9f35f6e66f1a1",
      "direction": "INBOUND",
      "type": "TEXT"
    },
    "deletedAt": "2026-09-30T18:02:11.000Z"
  },
  "meta": {
    "eventId": "evt_9a0b1c2d-3e4f-5061-7283-94a5b6c7d8e9",
    "event": "message.inbound.deleted",
    "occurredAt": "2026-09-30T18:02:11.000Z",
    "organisationId": "6716b2f0a1c34d0012ab89ef",
    "externalId": "crmco-client-5541",
    "partnerId": "6716b1001c34d0012ab89abc",
    "apiVersion": "2026-04-01",
    "attempt": 1
  }
}

There is no contact and no text on a delete. Match on data.message.id against what you already stored.

On this page