# Event reference

> All twenty webhook event types, the fields each one really sends, which fields you might expect and will not get, realistic payloads, and the ping test event.

Source: https://docs.amomic.in/webhooks/events

---

Twenty event types exist. The list is closed: subscribing an endpoint to a name that is not on it is refused when you save. This page is the field-level contract for each one — what is sent, when, and what is **not** in the payload even though you might expect it.

[Getting events](/webhooks/overview) covers the envelope and headers these fields arrive inside; [Verifying signatures](/webhooks/signatures) covers proving they came from Amomic-AI.

| Type | Sent when |
|---|---|
| [`message.received`](#message-received) | A customer sends a WhatsApp message to one of your numbers |
| [`message.echo`](#message-echo) | Your team replies from the WhatsApp Business app on a coexistence number, or edits or deletes one of those messages |
| [`message.status`](#message-status) | WhatsApp reports sent, delivered, read or failed for a message sent from your number |
| [`conversation.started`](#conversation-started) | A new conversation opens on web chat, WhatsApp or a call |
| [`conversation.ended`](#conversation-ended) | **Not sent today.** It can be subscribed to, but nothing emits it |
| [`call.started`](#call-started) | A call connects to your AI agent |
| [`call.ended`](#call-ended) | A call finishes, answered or not |
| [`call.outcome`](#call-outcome) | A finished AI call has been read: what happened and the answers it got |
| [`ticket.created`](#ticket-created) | A ticket is opened |
| [`ticket.updated`](#ticket-updated) | Someone changes a ticket |
| [`ticket.replied`](#ticket-replied) | Your team replies on a ticket — and whether the reply arrived |
| [`lead.created`](#lead-created) | A web chat visitor leaves contact details |
| [`campaign.launched`](#campaign-launched) | A campaign starts sending |
| [`campaign.completed`](#campaign-completed) | Every recipient in a campaign has reached a final state |
| [`payment.due_created`](#payment-duecreated-payment-paid-payment-declined) | Duesly raises a recurring payment |
| [`payment.paid`](#payment-duecreated-payment-paid-payment-declined) | A Duesly payment is settled |
| [`payment.declined`](#payment-duecreated-payment-paid-payment-declined) | A Duesly payment's reminders ran out unpaid |
| [`appointment.booked`](#appointment-booked-appointment-rescheduled-appointment-cancelled) | A time is booked on a calendar |
| [`appointment.rescheduled`](#appointment-booked-appointment-rescheduled-appointment-cancelled) | A booked appointment moves to a new time |
| [`appointment.cancelled`](#appointment-booked-appointment-rescheduled-appointment-cancelled) | A booked appointment is cancelled |

Plus one that is not on the list and that your endpoint receives anyway: [`ping`](#the-ping-test-event).

## Three rules that apply to every event

### A field with no value is left out

Each event copies a fixed list of fields and **drops any whose value is empty**. A field with no value is not `"summary": null` — the key is simply not in the JSON. Zero and `false` are values, so `"duration_seconds": 0` is sent.

The rule applies to the top level of `data`. Nested objects such as `customer` on `conversation.started` are sent as they are and can contain `null` values.

::: danger This is the field-level bug that gets past testing
Your first test calls all connect, so `event.data.summary.trim()` works. The first event without that field takes the handler down with a `TypeError`, your endpoint returns `500`, and the event is retried and fails four times. Fifteen failed events in a row and the endpoint is [switched off](/webhooks/reliability#fifteen-failed-events-disables-the-endpoint).

Treat **every** field as optional unless the table says *Always*. Read with `?.` and `??` in JavaScript, `.get()` in Python, `Optional[...]` in a typed model.

```javascript
// Wrong — assumes the key is there
const words = event.data.summary.split(" ");

// Right
const words = (event.data.summary ?? "").split(" ");
```
:::

### What is in the table is what you get

Payloads are not internal records with a few things stripped out. Each event names the fields it sends and nothing else, so an internal field never appears by accident. Where you might reasonably expect a field and it is **not** sent, the section says so. If you need something that is not listed, fetch it from the REST API using an id in the event.

### Every payload carries `event_id`

Every event (not `ping`) has a `data.event_id`. It is an internal id and **not** the dedupe key — deduplicate on the envelope's top-level `id`. See [Getting events](/webhooks/overview#envelope).

---

## `message.received`

Sent when a customer sends a message to one of your connected WhatsApp numbers. Every inbound message counts — text, media, a button tap, a reaction — and it is sent whether or not an agent replies.

| Field | Type | Presence | Notes |
|---|---|---|---|
| `channel` | string | Always | Always `"whatsapp"`. |
| `message_id` | string | Always | WhatsApp's own `wamid.…` for the inbound message. |
| `from` | string | Always | The customer's number as `+` and digits — `+919767452908`. |
| `type` | string | Always | WhatsApp's message type: `text`, `image`, `audio`, `video`, `document`, `button`, `interactive`, `reaction`, `location` and others. WhatsApp owns this list; write a default branch. |
| `referral` | object | Only for a message that came from a Click-to-WhatsApp ad | The ad: `source_id`, `source_type`, `source_url`, `headline`, `body`, `media_type`, `ctwa_clid` and `captured_at`, each present when WhatsApp supplied it. |
| `event_id` | string | Always | Internal id. Not the dedupe key. |

```json title="POST to your endpoint"
{
  "id": "evt_1b7d4f9a2c6e80b3d5f7a9c1",
  "type": "message.received",
  "created_at": "2026-10-14T05:44:00Z",
  "tenant_id": "ten_01JA4M8Q2V7K9X3D5N6P1R0TZB",
  "data": {
    "channel": "whatsapp",
    "message_id": "wamid.HBgMOTE5NzY3NDUyOTA4FQIAEhgUM0E4RjI1QjcxQzE5RDA0RTZBNTMA",
    "from": "+919767452908",
    "type": "text",
    "event_id": "evt_01JF9X2K6N0Q4T8W2Y6A0C4E8G"
  }
}
```

```json title="The first message from someone who tapped your ad"
{
  "id": "evt_8e3b0c5a1f7d29e46b1c0a83",
  "type": "message.received",
  "created_at": "2026-10-14T05:12:37Z",
  "tenant_id": "ten_01JA4M8Q2V7K9X3D5N6P1R0TZB",
  "data": {
    "channel": "whatsapp",
    "message_id": "wamid.HBgMOTE5ODQ1MjkwODE3FQIAEhgUM0EyQzc5RjQ0QjE4MDZBN0MyMTUA",
    "from": "+919845290817",
    "type": "text",
    "referral": {
      "source_id": "120214583097460312",
      "source_type": "ad",
      "source_url": "https://fb.me/4kQx7Lm2a",
      "headline": "AMC plans from ₹1,499 a year",
      "body": "Two free services and priority technician visits.",
      "media_type": "image",
      "ctwa_clid": "ARAkLm4Qx9zT2vB7cN1",
      "captured_at": "2026-10-14T05:12:36+00:00"
    },
    "event_id": "evt_01JF9VZ3H7K1M5Q9S3V7X1Z5BD"
  }
}
```

::: warning Not in this payload: the text, the conversation, the deployment
`message.received` does **not** carry the message's `text`, a `conversation_id`, or a `deployment_id`. Earlier versions of this page listed them; they are not sent, so do not code against them.

To get what the customer wrote, read it from the conversation:

1. Find the conversation. Walk [`GET /conversations`](/api/conversations) (newest activity first) and take the first `whatsapp` conversation whose `customer.phone` equals `from`. If you also subscribe to `conversation.started`, you can keep your own map of number to `conversation_id` and skip the walk.
2. Read [`GET /conversations/{id}/messages`](/api/conversations) to the last page and take the newest `user` turn.

The event can arrive a moment before the message is stored, so if the newest `user` turn is older than the envelope's `created_at`, wait a few seconds and read again. [Send an order update on WhatsApp](/recipes/order-updates#when-the-customer-replies) has the code.
:::

::: note When nothing is sent
A WhatsApp number set to **Off** with [**Don't keep these conversations**](/channels/whatsapp-reply-modes#off) drops inbound messages without storing them, and no `message.received` is sent for them (except opt-out and opt-in keywords). Web chat and calls never send `message.received`; for those, `conversation.started` is the arrival signal.
:::

The event is the message arriving, not a request for you to answer it. A deployed agent replies on its own. Use it to mirror inbound traffic, catch keywords, attribute ad clicks, or reset your own clock for [the 24-hour window](/channels/whatsapp-window), which opens on exactly this message.

---

## `message.echo`

Sent when your team answers a customer **from the WhatsApp Business app on their own phone**, rather than through Amomic-AI, and when they edit or delete one of those messages.

This only happens on a [coexistence](/channels/whatsapp) number — one worked from the app and through the platform at the same time. On a standalone number it never fires.

::: warning Without this, a mirrored transcript is half a conversation
If you sync conversations into a CRM or helpdesk, `message.received` gives you the customer's side and none of the replies a colleague typed on their handset. The record then shows a business ignoring people it has already answered.
:::

| Field | Type | Presence | Notes |
|---|---|---|---|
| `channel` | string | Always | Always `"whatsapp"`. |
| `message_id` | string | Always | The `wamid.…` of the echoed message. On `edit` and `revoke` this is the id of the *edit*, not of the message it changes. |
| `to` | string | Always | The **customer's** number as `+` and digits. `to`, not `from`, because your business sent this one. |
| `origin` | string | Always | Always `"whatsapp_business_app"`. |
| `text` | string | When there is text | The message body, or the caption on media. Media with no caption reports a short placeholder such as `📷 Photo`. |
| `type` | string | When known | `text`, a media type such as `image` or `audio`, or `edit` / `revoke`. Write a default branch. |
| `original_message_id` | string | On `edit` and `revoke` only | The `wamid.…` of the message being changed — one you were told about in an earlier `message.echo`. |
| `conversation_id` | string | Always | `con_…`. Use it with [`GET /conversations/{id}`](/api/conversations). |
| `deployment_id` | string | When known | Opaque; safe to log. |
| `event_id` | string | Always | Internal id. |

```json title="A reply typed on the owner's phone"
{
  "id": "evt_9f2c4d2a8b4e6f9051c3a7e2",
  "type": "message.echo",
  "created_at": "2026-10-14T05:46:31Z",
  "tenant_id": "ten_01JA4M8Q2V7K9X3D5N6P1R0TZB",
  "data": {
    "channel": "whatsapp",
    "message_id": "wamid.HBgMOTE5NzY3NDUyOTA4FQIAERgSNEE1RjJCOTk0QzBEN0UzMjFBAA",
    "to": "+919767452908",
    "origin": "whatsapp_business_app",
    "text": "Our technician Ravi will reach you by 4 pm today.",
    "type": "text",
    "conversation_id": "con_01JF9N6Q0S4V8X2Z6B0D4F8HYB",
    "deployment_id": "dep_01JE2H6K0N4Q8T2W6Y0A4C8EAB",
    "event_id": "evt_01JF9X3M7P1R5U9W3Y7A1C5E9H"
  }
}
```

```json title="The same message, deleted for everyone"
{
  "id": "evt_9f2c4d2a8b4e6f9051c3a7e3",
  "type": "message.echo",
  "created_at": "2026-10-14T05:47:05Z",
  "tenant_id": "ten_01JA4M8Q2V7K9X3D5N6P1R0TZB",
  "data": {
    "channel": "whatsapp",
    "message_id": "wamid.HBgMOTE5NzY3NDUyOTA4FQIAERgSN0IzRDhFMjE5QTRGMEM2NUQAAA",
    "to": "+919767452908",
    "origin": "whatsapp_business_app",
    "text": "🚫 Message deleted",
    "type": "revoke",
    "original_message_id": "wamid.HBgMOTE5NzY3NDUyOTA4FQIAERgSNEE1RjJCOTk0QzBEN0UzMjFBAA",
    "conversation_id": "con_01JF9N6Q0S4V8X2Z6B0D4F8HYB",
    "deployment_id": "dep_01JE2H6K0N4Q8T2W6Y0A4C8EAB",
    "event_id": "evt_01JF9X4N8Q2S6V0X4Z8B2D6F0J"
  }
}
```

::: warning `edit` and `revoke` are the two you cannot ignore
They are the only events that say *what you already stored is no longer true*. Look up `original_message_id` in your own store: replace the body on `edit` (the `text` is the **new** text), mark it deleted on `revoke`.
:::

::: note An echo opens no window
A colleague replying on their phone does not open [the 24-hour window](/channels/whatsapp-window) on the platform side, and does not buy you the right to send free-form text through the API.
:::

---

## `message.status`

Sent when WhatsApp reports a state change for a message sent from your number. One event per change, so a delivered and read message produces several.

That means **every** message sent from the number: your [`POST /messages`](/api/messages) sends, but also the agent's replies, your team's replies from the inbox and campaign messages. Expect statuses for `message_id` values you never stored, and ignore them.

| Field | Type | Presence | Notes |
|---|---|---|---|
| `channel` | string | Always | Always `"whatsapp"`. |
| `message_id` | string | Always | The `wamid.…` — the same value `POST /messages` returned. Your join key. |
| `status` | string | Always | `sent`, `delivered`, `read` or `failed`. WhatsApp owns this list; write a default branch. |
| `recipient` | string | When known | The customer's number as WhatsApp reports it: digits with **no** leading `+`, unlike `from` on `message.received`. Normalise before comparing. |
| `event_id` | string | Always | Internal id. |

```json title="POST to your endpoint"
{
  "id": "evt_9f2c4b1e7a3d5c8f0b2e6d41",
  "type": "message.status",
  "created_at": "2026-10-14T04:58:12Z",
  "tenant_id": "ten_01JA4M8Q2V7K9X3D5N6P1R0TZB",
  "data": {
    "channel": "whatsapp",
    "message_id": "wamid.HBgMOTE5ODQwMzMxMjc2FQIAERgSMkI5RUE4QzQ3RjE2MDlEQjNBAA",
    "status": "delivered",
    "recipient": "919840331276",
    "event_id": "evt_01JF9W1H5K9N3Q7T1W5Y9B3D7F"
  }
}
```

::: warning Not in this payload: why a message failed
A `failed` status carries **no reason and no error code**. The event tells you the message failed, not why. Common causes are a number that is not on WhatsApp, a customer who blocked you, or an account-level restriction; [Sender health](/channels/whatsapp#sender-health) and [WhatsApp troubleshooting](/channels/whatsapp#troubleshooting) cover them.
:::

::: warning Status events can arrive out of order
A `read` can land before the `delivered` that logically came first, especially when one of them was retried. Store each status against the `message_id` with the envelope's `created_at`, and let a later timestamp win — or keep them all and derive the current state on read.
:::

There is no event for the moment the API accepts your send. `POST /messages` answers that itself with `status: "sent"`, which means accepted for delivery and nothing more.

---

## `conversation.started`

Sent when a new conversation opens: a web chat visitor's first message, a WhatsApp customer starting a new thread, a call your agent answers or places, or a thread a colleague starts from the WhatsApp Business app on a coexistence number.

| Field | Type | Presence | Notes |
|---|---|---|---|
| `conversation_id` | string | Always | `con_…`. The handle for [`GET /conversations/{id}`](/api/conversations). |
| `channel` | string | Always | `web`, `whatsapp` or `voice`. |
| `customer` | object | Always | Who the channel says this is. See below. |
| `deployment_id` | string | Only on a thread started from the WhatsApp Business app | Opaque. |
| `event_id` | string | Always | Internal id. |

`customer` has these keys, any of which can be `null`:

| Key | Notes |
|---|---|
| `external_id` | The channel's own id for the person: the WhatsApp number without `+`, the caller's number, or the web visitor's id. |
| `display_name` | WhatsApp profile name, or the name from a lead form. Not proof of identity. |
| `phone` | `+` and digits, when the channel knows it. |
| `email` | When a lead form collected one. |
| `attested` | `true` when the channel itself vouches for the identity (a WhatsApp number, a caller ID), `false` when the person typed it. |
| `contact_id`, `contact_attested` | The contact this was matched to, when known. Often `null` at this moment, because matching happens after the conversation opens. |

A thread started from the WhatsApp Business app sends only `external_id`, `display_name`, `phone` and `attested`.

```json title="POST to your endpoint"
{
  "id": "evt_9f2c4c8d012e6a37f5b90c14",
  "type": "conversation.started",
  "created_at": "2026-10-14T05:44:01Z",
  "tenant_id": "ten_01JA4M8Q2V7K9X3D5N6P1R0TZB",
  "data": {
    "conversation_id": "con_01JF9N6Q0S4V8X2Z6B0D4F8HYB",
    "channel": "whatsapp",
    "customer": {
      "external_id": "919767452908",
      "display_name": "Anjali D",
      "email": null,
      "phone": "+919767452908",
      "attested": true,
      "contact_id": null,
      "contact_attested": false
    },
    "event_id": "evt_01JF9X2P7R1T5W9Y3A7C1E5G9K"
  }
}
```

A web chat visitor who never filled in a lead form has only `external_id` set. An all-`null` customer is an ordinary case, not an error.

Don't use this as your call signal: use [`call.started`](#call-started) and [`call.ended`](#call-ended).

---

## `conversation.ended`

::: warning Not sent today
`conversation.ended` is on the list and you can tick it, but **nothing in the platform sends it**. An endpoint subscribed only to `conversation.ended` receives only test pings.

To learn that a conversation has finished, read `status` on [`GET /conversations/{id}`](/api/conversations), or run the scheduled walk in [Sync conversations](/recipes/sync-conversations).
:::

---

## `call.started`

Sent when a call connects to your AI agent: an inbound call it picks up, or an outbound call the customer answers. An outbound call nobody answers never sends `call.started` — only `call.ended`. Calls your team places from a ticket never send it.

| Field | Type | Presence | Notes |
|---|---|---|---|
| `call_id` | string | Always | `call_…`. The handle for [`GET /calls/{id}`](/api/calls). |
| `number` | string | When known | **Your** number on this call: the one dialled for an inbound call, the caller ID for an outbound one. |
| `event_id` | string | Always | Internal id. |

```json title="POST to your endpoint"
{
  "id": "evt_9f2c4e91c3a05f27d8b16e03",
  "type": "call.started",
  "created_at": "2026-10-14T05:31:07Z",
  "tenant_id": "ten_01JA4M8Q2V7K9X3D5N6P1R0TZB",
  "data": {
    "call_id": "call_01JF9R2T6W0Y4A8C2E6G0J4LXY",
    "number": "+918047361920",
    "event_id": "evt_01JF9R2V7X1Z5B9D3F7H1K5M9P"
  }
}
```

::: note Not in this payload: direction and the customer's number
`call.started` does not say whether the call is inbound or outbound, or who is on the other end. `call.ended` and `call.outcome` carry `direction` and `to`; for a call you placed, you already have both from `POST /calls`.
:::

---

## `call.ended`

Sent when a call finishes, for any reason: answered and completed, rang out, busy, refused, or failed. It is also sent for calls your team places from a ticket.

| Field | Type | Presence | Notes |
|---|---|---|---|
| `call_id` | string | Always | Matches `call.started` and `POST /calls`. |
| `duration_seconds` | integer | Always | Talk time. `0` for a call nobody answered. |
| `outcome` | string | Always | How the line ended: `caller_hangup`, `agent_hangup`, `idle`, `max_duration`, `no_answer`, `busy` and others. Calls your team places from a ticket report `hangup` or `no_answer`. The full list is in [Calls](/api/calls). Write a default branch. |
| `direction` | string | When known | `inbound`, `outbound`, or `click_to_call` for a call your team placed from a ticket. |
| `to` | string | When known | **The customer's** number, in both directions: the number dialled on an outbound call, the caller on an inbound one. |
| `event_id` | string | Always | Internal id. |

```json title="POST to your endpoint"
{
  "id": "evt_9f2c4f3a7b28d05e91c4a760",
  "type": "call.ended",
  "created_at": "2026-10-14T05:35:14Z",
  "tenant_id": "ten_01JA4M8Q2V7K9X3D5N6P1R0TZB",
  "data": {
    "call_id": "call_01JF9R2T6W0Y4A8C2E6G0J4LXY",
    "duration_seconds": 247,
    "outcome": "caller_hangup",
    "direction": "inbound",
    "to": "+919948066213",
    "event_id": "evt_01JF9R6Y1A5C9E3G7J1L5N9Q3S"
  }
}
```

```json title="A call nobody answered"
{
  "id": "evt_9f2c500b4d19e63af07b2c85",
  "type": "call.ended",
  "created_at": "2026-10-14T05:41:52Z",
  "tenant_id": "ten_01JA4M8Q2V7K9X3D5N6P1R0TZB",
  "data": {
    "call_id": "call_01JF9SB4D8F2H6K0M4P8R2T6VW",
    "duration_seconds": 0,
    "outcome": "no_answer",
    "direction": "outbound",
    "to": "+919895172640",
    "event_id": "evt_01JF9SC5E9G3J7L1N5Q9S3U7WX"
  }
}
```

::: warning Not in this payload: summary, recording, status, your metadata
- **No `summary`.** The reading of the call arrives separately on [`call.outcome`](#call-outcome).
- **No recording.** There is no `recording_uri` here and no recording-ready event. If the call was recorded, `recording_uri` is on [`GET /calls/{call_id}`](/api/calls).
- **No `status`.** Only `outcome`. Read `status` from `GET /calls/{call_id}` if you need it.
- **No `campaign_id` and no `metadata`.** The `metadata` you sent with `POST /calls` comes back on `call.outcome`, not here.
:::

## `call.outcome`

Sent when a finished AI call has been read: what happened, and every answer to its [capture](/api/call-outcomes). Usually within a minute of `call.ended`, and it can arrive before it. Every finished AI call is read, inbound and outbound, with or without a capture. Calls your team places from a ticket are not.

| Field | Type | Presence | Notes |
|---|---|---|---|
| `call_id` | string | Always | Matches `call.ended` and `POST /calls`. |
| `direction` | string | When known | `inbound` or `outbound`. |
| `to` | string | When known | The customer's number. |
| `campaign_id` | string | Campaign calls | The call campaign this call belongs to. |
| `deployment_id` | string | When known | The deployment that answered or placed the call. |
| `disposition` | string | Always | One of `completed`, `partial`, `callback_requested`, `not_interested`, `wrong_person`, `hung_up_early`, `no_conversation`, `unanswered`. See [Dispositions](/api/call-outcomes#dispositions). |
| `goal_met` | string | Always | `yes`, `no` or `unclear`. |
| `success` | boolean | When there is a success rule and its field was answered | Your rule applied to the answers. |
| `summary` | string | When one was written | One or two sentences, in English. |
| `sentiment` | string | When read | `positive`, `neutral` or `negative`. Absent on calls nobody answered or nobody spoke on. |
| `next_step` | string | Always | `none`, `call_back`, `send_whatsapp` or `human_follow_up`. |
| `next_step_reason` | string | When `next_step` isn't `none` | A short reason. |
| `fields` | object | Always | Answers by field key. Unanswered fields are absent; `{}` when there was no capture. |
| `captured` | integer | Always | Fields answered. |
| `expected` | integer | Always | Fields on the capture. |
| `missing_required` | array | When a required field is unanswered | Their keys. |
| `metadata` | object | Calls you placed with `POST /calls` | The `metadata` you sent, unchanged. |
| `event_id` | string | Always | Internal id. |

```json title="POST to your endpoint"
{
  "id": "evt_9f2c4f3a7b28d05e91c4a761",
  "type": "call.outcome",
  "created_at": "2026-10-14T05:36:02Z",
  "tenant_id": "ten_01JA4M8Q2V7K9X3D5N6P1R0TZB",
  "data": {
    "call_id": "call_01JF9T7Y1A5C9E3G7J1L5N9QRS",
    "direction": "outbound",
    "to": "+919811204587",
    "deployment_id": "dep_01JE4K8M2Q6T0W4Y8B2D6F0HCD",
    "disposition": "partial",
    "goal_met": "unclear",
    "summary": "Rahul wants to renew the AMC for his split AC but the call dropped before he chose a visit slot.",
    "sentiment": "neutral",
    "next_step": "call_back",
    "next_step_reason": "The visit slot is still open.",
    "fields": { "renew_amc": true },
    "success": true,
    "captured": 1,
    "expected": 2,
    "metadata": { "crm_record_id": "opp_88213" },
    "event_id": "evt_01JF9T9A3C7E1G5J9L3N7Q1S5U"
  }
}
```

::: note It can arrive more than once for the same call
A call can be read again, for example when someone presses **Re-read** on the call's page in the console. Each reading sends a new `call.outcome` with a new `id`. Keep the most recent one for each `call_id`.
:::

---

## `ticket.created`

Sent when a ticket is opened — through [`POST /tickets`](/api/tickets), by an agent during a conversation, or by a person in the console.

| Field | Type | Presence | Notes |
|---|---|---|---|
| `ticket_id` | string | Always | `tkt_…`. The handle for [`GET /tickets/{id}`](/api/tickets). |
| `public_ref` | string | Always | `AM-` and eight characters. The reference you quote to a customer. |
| `subject` | string | Always | The ticket subject. |
| `status` | string | Always | `new` at creation. |
| `priority` | string | Always | `low`, `normal`, `high` or `urgent`. |
| `channel` | string | When known | Where the ticket came from: `web`, `whatsapp`, `voice`, `email`, or `api` for tickets you opened with `POST /tickets`. |
| `event_id` | string | Always | Internal id. |

```json title="POST to your endpoint"
{
  "id": "evt_9a4c7e2b5f8d13a6c0e9b4d7",
  "type": "ticket.created",
  "created_at": "2026-10-14T05:12:00Z",
  "tenant_id": "ten_01JA4M8Q2V7K9X3D5N6P1R0TZB",
  "data": {
    "ticket_id": "tkt_7c2e9a41d05b8f36e1a4c927",
    "public_ref": "AM-5B19C0E2",
    "subject": "Washing machine drum making grinding noise",
    "status": "new",
    "priority": "high",
    "channel": "whatsapp",
    "event_id": "evt_01JF9P8R2T6W0Y4A8C2E6G0KZA"
  }
}
```

::: note Not in this payload: the description and the requester
No description, no requester name, phone or email, no tags, no SLA time. They are on the ticket, one [`GET /tickets/{ticket_id}`](/api/tickets) away, and keeping them out means a customer's problem is not sitting in your webhook access logs.
:::

---

## `ticket.updated`

Sent when someone changes a ticket's status, priority, assignee, category or tags — in the console or with [`PATCH /tickets/{id}`](/api/tickets). **Same fields as `ticket.created`**, showing the ticket as it is now.

```json title="POST to your endpoint"
{
  "id": "evt_9f2c5204bd71e38c05a9f612",
  "type": "ticket.updated",
  "created_at": "2026-10-14T05:48:30Z",
  "tenant_id": "ten_01JA4M8Q2V7K9X3D5N6P1R0TZB",
  "data": {
    "ticket_id": "tkt_7c2e9a41d05b8f36e1a4c927",
    "public_ref": "AM-5B19C0E2",
    "subject": "Washing machine drum making grinding noise",
    "status": "pending",
    "priority": "urgent",
    "channel": "whatsapp",
    "event_id": "evt_01JF9XA6C0E4G8J2L6N0Q4S8UW"
  }
}
```

::: warning No diff, no previous values
The event tells you the ticket's state **now**. It does not say what changed, what it was before, or who changed it. Compare against your stored copy. Since events are unordered, let the envelope's `created_at` decide: an older event must not overwrite a newer state.

Escalating or acknowledging an overdue ticket does not send `ticket.updated`. Neither does a new customer message or internal note on the ticket.
:::

---

## `ticket.replied`

Sent when someone on your team replies to the customer on a ticket. It carries the six fields of `ticket.updated` plus the two that make it worth subscribing to.

| Field | Type | Presence | Notes |
|---|---|---|---|
| `ticket_id`, `public_ref`, `subject`, `status`, `priority`, `channel` | | | As on `ticket.created`. |
| `delivery_state` | string | Always | What happened to the reply when it was sent. See below. |
| `delivery_channel` | string | When known | Where it was actually sent — not always the origin channel. A voice or web ticket with a phone number is answered on WhatsApp. |
| `event_id` | string | Always | Internal id. |

```json title="POST to your endpoint"
{
  "id": "evt_9f2c6318cf82f49d16bad723",
  "type": "ticket.replied",
  "created_at": "2026-10-14T05:52:11Z",
  "tenant_id": "ten_01JA4M8Q2V7K9X3D5N6P1R0TZB",
  "data": {
    "ticket_id": "tkt_7c2e9a41d05b8f36e1a4c927",
    "public_ref": "AM-5B19C0E2",
    "subject": "Washing machine drum making grinding noise",
    "status": "open",
    "priority": "urgent",
    "channel": "whatsapp",
    "delivery_state": "awaiting_customer",
    "delivery_channel": "whatsapp",
    "event_id": "evt_01JF9XC8E2G6J0L4N8Q2S6UAXY"
  }
}
```

### `delivery_state`

| Value | What it means |
|---|---|
| `sent` | The reply went out. |
| `awaiting_customer` | WhatsApp's [24-hour window](/channels/whatsapp-window) was closed, so the customer was sent a notification instead. Your team's words reach them when they open it. |
| `queued` | Not delivered yet. A retry is scheduled. |
| `held` | Nothing has been attempted yet because the workspace has to change a setting first — most often, which WhatsApp number ticket replies leave from. The reply is kept and is sent once the setting is answered. |
| `failed` | The attempt failed and will be retried. |
| `undeliverable` | It will not be delivered: no channel can carry it. |

::: warning One event per reply, with the state at that moment
`ticket.replied` is sent once, right after the first delivery attempt. A reply that is `queued`, `held` or `awaiting_customer` and goes through later does **not** send a second event. Read the ticket's thread in the console for the final state.

The reply text is not in the payload, deliberately: what an agent wrote to a customer is not posted to a third-party endpoint by default.
:::

---

## `lead.created`

Sent when a web chat visitor leaves contact details in the lead form.

| Field | Type | Presence | Notes |
|---|---|---|---|
| `contact_id` | string | When known | The contact this lead was saved as. Can be absent if the contact couldn't be saved at that moment; the lead is still real. |
| `name` | string | When given | |
| `email` | string | When given | |
| `phone` | string | When given | |
| `deployment_id` | string | When known | The deployment active on that web chat. |
| `event_id` | string | Always | Internal id. |

```json title="POST to your endpoint"
{
  "id": "evt_5d2a8c1e4b7f90d6a3c5e8b2",
  "type": "lead.created",
  "created_at": "2026-10-14T04:23:00Z",
  "tenant_id": "ten_01JA4M8Q2V7K9X3D5N6P1R0TZB",
  "data": {
    "contact_id": "ct_01JF9M4P8R2T6W0Y4A8C2E6ZCC",
    "name": "Karthik Subramanian",
    "phone": "+919884207153",
    "deployment_id": "dep_01JE2H6K0N4Q8T2W6Y0A4C8EAB",
    "event_id": "evt_01JF9M4P8R2T6W0Y4A8C2E6GLD"
  }
}
```

The visitor gave a name and phone number and no email, so there is no `email` key. A lead form can ask for one, two or all three fields, and a visitor can leave any blank — route on whichever identifier is present.

---

## `campaign.launched`

Sent when a [campaign](/campaigns/overview) starts sending — from the console or the API.

| Field | Type | Presence | Notes |
|---|---|---|---|
| `campaign_id` | string | Always | Opaque. |
| `name` | string | Always | The campaign's name. |
| `type` | string | Always | `whatsapp_broadcast` or `voice_batch`. |
| `recipients` | integer | Always | Recipients who will be sent to, **after** opt-outs, duplicates and unusable numbers are removed. Not the size of the list you picked. |
| `event_id` | string | Always | Internal id. |

---

## `campaign.completed`

Sent once every recipient has reached a final state. The counts are rebuilt from the recipient records first, so they are exact.

| Field | Type | Presence | Notes |
|---|---|---|---|
| `campaign_id` | string | Always | |
| `name`, `type` | string | Always | |
| `total` | integer | Always | Every recipient. |
| `sent`, `delivered`, `read` | integer | Always | `0` is sent as `0`. |
| `failed` | integer | Always | Sends that failed. |
| `skipped` | integer | Always | **Not failures.** A sending rule excluded these people — opted out, no consent for the channel, over a frequency cap. |
| `event_id` | string | Always | Internal id. |

```json title="POST to your endpoint"
{
  "id": "evt_4b1c93d7a2e50f68b1904c27",
  "type": "campaign.completed",
  "created_at": "2026-10-12T12:41:09Z",
  "tenant_id": "ten_01JA4M8Q2V7K9X3D5N6P1R0TZB",
  "data": {
    "campaign_id": "cmp_01JF6B8D2F6H0K4M8P2R6T0VWX",
    "name": "Diwali AMC offer",
    "type": "whatsapp_broadcast",
    "total": 2184,
    "sent": 2093,
    "delivered": 1987,
    "read": 1412,
    "failed": 37,
    "skipped": 54,
    "event_id": "evt_01JF6Z3B7D1F5H9K3M7P1R5T9V"
  }
}
```

::: note Not in this payload: call counts
A `voice_batch` campaign's answered and unanswered counts are not in the event. Read the per-call results from [`GET /campaigns/{id}/outcomes`](/api/campaigns).
:::

::: warning `skipped` is not an error rate
A campaign that skipped 54 of 2,184 worked correctly — those were people a sending rule protected. Alert on `failed`, not `skipped`.
:::

There is no per-recipient event. Read [`GET /campaigns/{id}/recipients`](/api/campaigns) when the campaign completes.

---

## `payment.due_created`, `payment.paid`, `payment.declined`

The three [Duesly](/apps/duesly) events. Each sends a slightly different set of fields:

| Field | `due_created` | `paid` | `declined` | Notes |
|---|---|---|---|---|
| `due_id` | Always | Always | Always | Opaque. |
| `subscriber_id` | Always | Always | Always | The customer on the recurring plan. |
| `amount` | Always | Always | Always | In rupees. On `paid`, what was actually received. |
| `currency` | Always | — | — | `INR`. |
| `period` | Always | Always | Always | Human label — `"October 2026"`. |
| `due_date` | Always | — | — | `YYYY-MM-DD`. |
| `reference` | — | When given | — | A gateway payment id, or the reference the business recorded. |
| `source` | — | Always | — | `gateway` or `manual`. See below. |
| `event_id` | Always | Always | Always | Internal id. |

```json title="POST to your endpoint"
{
  "id": "evt_7d3e21f9c4b80a52e6317d94",
  "type": "payment.paid",
  "created_at": "2026-10-06T06:22:03Z",
  "tenant_id": "ten_01JA4M8Q2V7K9X3D5N6P1R0TZB",
  "data": {
    "due_id": "due_01JF2C6E0G4J8L2N6Q0S4U8WXY",
    "subscriber_id": "sub_01JD9F3H7K1M5P9R3T7V1X5ZAB",
    "amount": 1499.0,
    "period": "October 2026",
    "reference": "pay_PqR7sT9uV1wX2y",
    "source": "gateway",
    "event_id": "evt_01JF2D7F1H5K9M3P7R1T5V9XZA"
  }
}
```

::: warning What `source` can tell you
`manual` means someone marked the due paid in the console. `gateway` means a payment link settled it — **or** the payment was recorded through [`POST /apps/duesly/dues/{id}/paid`](/api/apps-duesly), which also reports `gateway` today. If your integration records payments through the API, don't use `source` to tell those apart from link payments.
:::

::: info `payment.declined` does not mean the customer refused
It means the reminder ladder ran out and Duesly has stopped asking. The payment is still outstanding; it is now a human's job. See [the reminder ladder](/apps/duesly#the-reminder-ladder).
:::

No payment links or gateway credentials are ever in these payloads.

---

## `appointment.booked`, `appointment.rescheduled`, `appointment.cancelled`

The three appointment events. All three carry the same shape, whoever made the change — an agent mid-conversation or a member of staff in the console.

They are the only programmatic access to appointments: **there is no appointments REST API**. The `appointments:read` and `appointments:write` scopes appear in the console's key dialog but unlock no route today, so build these payloads into your own records rather than planning to fetch an appointment back.

| Field | Type | Presence | Notes |
|---|---|---|---|
| `appointment_id` | string | Always | Opaque. On `appointment.rescheduled` this is the **new** appointment — see the warning below. |
| `public_ref` | string | Always | The short reference the customer is given: `"AP-1A2B3C4D"`. |
| `schedule_id` | string | Always | The calendar the time was taken from. |
| `status` | string | Always | `booked` on a booking and a reschedule, `cancelled` on a cancellation. |
| `start_at` | string | Always | ISO 8601 with a UTC offset — `"2026-10-16T04:30:00+00:00"`, not the `Z` form the envelope uses. |
| `end_at` | string | Always | Same format. |
| `timezone` | string | Always | IANA zone of the calendar — `"Asia/Kolkata"`. The times above are UTC; this is the zone the customer thinks in. |
| `service_name` | string | When the booking names a service | The label, not an id. |
| `channel` | string | When known | Where the booking came from: `web`, `whatsapp`, `voice` or `console`. |
| `agent_id` | string | When an agent booked it | Absent for console bookings. |
| `previous_appointment_id` | string | On `rescheduled` | The appointment this one replaces. |
| `previous_start_at` | string | On `rescheduled` | The time it used to be at. |
| `reason` | string | On `rescheduled` and `cancelled`, when given | Free text. |
| `event_id` | string | Always | Internal id. |

```json title="POST to your endpoint"
{
  "id": "evt_3f8a12c9d4e70b61a5928e34",
  "type": "appointment.rescheduled",
  "created_at": "2026-10-14T05:14:52Z",
  "tenant_id": "ten_01JA4M8Q2V7K9X3D5N6P1R0TZB",
  "data": {
    "appointment_id": "apt_01JF9Q6S0U4W8Y2A6C0E4G8JKL",
    "public_ref": "AP-9F3E2D1C",
    "schedule_id": "sch_01JC2E6G0J4L8N2Q6S0U4W8YAB",
    "status": "booked",
    "start_at": "2026-10-16T05:30:00+00:00",
    "end_at": "2026-10-16T06:30:00+00:00",
    "timezone": "Asia/Kolkata",
    "service_name": "AC service visit",
    "channel": "whatsapp",
    "agent_id": "agt_01JA5B2R7K3M9P4Q8W1T6Y0ZCD",
    "previous_appointment_id": "apt_01JF8K2M6P0R4T8W2Y6A0C4EFG",
    "previous_start_at": "2026-10-15T04:30:00+00:00",
    "reason": "Customer asked to move it to Friday",
    "event_id": "evt_01JF9Q7T1V5X9Z3B7D1F5H9KLM"
  }
}
```

::: danger A reschedule does not move the appointment you stored
Rescheduling books the new time as a **new appointment** and retires the old one. So `appointment_id` on `appointment.rescheduled` is an id your system has never seen, and the id you saved at booking time is in `previous_appointment_id`.

A handler that upserts on `appointment_id` alone leaves the original in your calendar and adds a second one beside it. Key the update off `previous_appointment_id` when it is present:

```javascript
case "appointment.rescheduled": {
  const previous = event.data.previous_appointment_id;
  if (previous) await removeBooking(previous);
  await createBooking(event.data.appointment_id, event.data.start_at);
  break;
}
```

No `appointment.booked` is sent for the replacement. The reschedule event is the only one.
:::

::: info The attendee is not in the payload
No name, phone number or email. If you need to reach the person, the appointment is in the console calendar with the attendee on it, and `public_ref` is the reference to search for.
:::

---

## The `ping` test event

The **Test** button on an endpoint in the console sends this, through the real delivery path with a real signature:

```json title="A test event"
{
  "id": "evt_6c1f9a2e7b4d08f3a5e1c9b2",
  "type": "ping",
  "created_at": "2026-10-14T05:49:12Z",
  "tenant_id": "ten_01JA4M8Q2V7K9X3D5N6P1R0TZB",
  "data": { "message": "Amomic-AI webhook test" }
}
```

- **It is a normal envelope.** It has a fresh `id` on every test, a `created_at` and your `tenant_id`, so a strict model that requires those fields accepts it. It has no `data.event_id`.
- **It is not subscribable.** You can't tick it or untick it. An endpoint subscribed to exactly `call.ended` still receives a `ping` when someone presses **Test**. Your router needs a default branch that returns `200` for an unknown `type`.
- **The result is in Deliveries.** The console only says `Test webhook queued. Open deliveries to see the response.` Your server's answer appears in **Recent deliveries**. See [Send a test event](/webhooks/overview#send-a-test-event).

---

## What does not exist

| You might expect | Reality |
|---|---|
| The text in `message.received` | Not sent. Read it from [`GET /conversations/{id}/messages`](/api/conversations); see [`message.received`](#message-received). |
| A reason in a failed `message.status` | Not sent. |
| `conversation.ended` firing | Listed but [not sent](#conversation-ended). Read conversation `status` instead. |
| `call.recording_ready` | Does not exist. Read `recording_uri` on [`GET /calls/{call_id}`](/api/calls). |
| A transcript in `call.ended` or `call.outcome` | No. `call.outcome` carries the summary and the answers. |
| `message.received` for web chat or calls | No. WhatsApp only. Use `conversation.started`. |
| An event for a message an agent sent | No. `message.status` reports WhatsApp's delivery states for every message from your number, and `message.echo` covers replies typed in the WhatsApp Business app. |
| A "call answered" event | `call.started` is sent when a call connects to your agent; there is nothing between that and `call.ended`. |
| Ticket comment or note events | No. `ticket.created`, `ticket.updated` and `ticket.replied` are the three ticket events. |
| `contact.created` or `contact.updated` | No. `lead.created` fires on web chat lead capture; [`POST /contacts`](/api/contacts) emits nothing. |
| Agent, deployment, knowledge or template events | No. Configuration changes do not send webhooks. |
| API key or webhook lifecycle events | No. |
| A per-recipient campaign event | No. Read [`GET /campaigns/{id}/recipients`](/api/campaigns). |
| A campaign paused or cancelled event | No, whether it was paused in the console or with `POST /campaigns/{id}/pause` or `/cancel`. Read [`GET /campaigns/{id}`](/api/campaigns). |
| A payment reminder sent event | No. |
| `appointment.completed`, `appointment.no_show` or a confirmation event | No. Those changes are recorded on the appointment but send nothing. |
| An appointment reminder event | No. |
| An event for busy time synced from Google Calendar | No. |
| Batched deliveries | No. One event, one `POST`, one signature. |

## Routing safely

The `Amomic-Event-Type` header carries the same value as `type`, so you can route before parsing — after you have verified the signature.

```javascript
// After verification, and after the event id has passed your dedupe check.
switch (event.type) {
  case "message.received":
    // No text in the payload: queue a job that reads it from the conversation.
    await jobs.enqueue("read-inbound", {
      from: event.data.from,
      messageId: event.data.message_id,
      after: event.created_at,
      ad: event.data.referral ?? null,
    });
    break;

  case "message.status":
    await recordDelivery(event.data.message_id, event.data.status, event.created_at);
    break;

  case "call.outcome":
    await closeCallRecord(event.data.call_id, {
      disposition: event.data.disposition,
      summary: event.data.summary ?? null,
      crmRecordId: event.data.metadata?.crm_record_id ?? null,
    });
    break;

  case "lead.created":
    await upsertCrmLead({
      name: event.data.name ?? null,
      email: event.data.email ?? null,
      phone: event.data.phone ?? null,
    });
    break;

  default:
    // `ping`, and anything added to the list later.
    // Acknowledge, do not throw — a 500 here starts the retry ladder.
    break;
}
```

- **Every read is defensive.** No field access assumes presence.
- **The default branch acknowledges.** An unknown `type` is a `200`.
- **Ordering comes from `created_at`**, not from arrival order.

## Related

::: cards

::: card Getting events
[The envelope and headers](/webhooks/overview) — what wraps every payload on this page.
:::

::: card Verifying signatures
[Prove it came from Amomic-AI](/webhooks/signatures) — the HMAC format, the tolerance window, the raw-bytes rule.
:::

::: card Retries & failures
[When your endpoint has a bad day](/webhooks/reliability) — the retry ladder, auto-disable, Replay.
:::

::: card Order updates recipe
[A full loop](/recipes/order-updates) — send, `message.status`, `message.received`, and reading the reply.
:::

:::
