Skip to content

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.

Manage webhooks

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 covers the envelope and headers these fields arrive inside; Verifying signatures covers proving they came from Amomic-AI.

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

Plus one that is not on the list and that your endpoint receives anyway: ping.

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.

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.

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

Node.js
// 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.


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.
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"
}
}
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"
}
}

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 (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 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 has the code.

When nothing is sent

A WhatsApp number set to Off with Don't keep these conversations 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, 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 number — one worked from the app and through the platform at the same time. On a standalone number it never fires.

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}.
deployment_id string When known Opaque; safe to log.
event_id string Always Internal id.
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"
}
}
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"
}
}

`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.

An echo opens no window

A colleague replying on their phone does not open the 24-hour 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 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.
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"
}
}

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 and WhatsApp troubleshooting cover them.

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}.
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.

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 and call.ended.


conversation.ended #

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}, or run the scheduled walk in 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}.
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.
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"
}
}

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. 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.
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"
}
}
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"
}
}

Not in this payload: summary, recording, status, your metadata

  • No summary. The reading of the call arrives separately on 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}.
  • 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. 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.
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.
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"
}
}

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, 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}.
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.
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"
}
}

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} 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}. Same fields as ticket.created, showing the ticket as it is now.

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"
}
}

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.
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 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.

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.
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 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.
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"
}
}

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.

`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 when the campaign completes.


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

The three 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.
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"
}
}

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, which also reports gateway today. If your integration records payments through the API, don't use source to tell those apart from link payments.

`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.

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.
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"
}
}

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:

Node.js
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.

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:

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.

What does not exist #

You might expect Reality
The text in message.received Not sent. Read it from GET /conversations/{id}/messages; see message.received.
A reason in a failed message.status Not sent.
conversation.ended firing Listed but not sent. Read conversation status instead.
call.recording_ready Does not exist. Read recording_uri on GET /calls/{call_id}.
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 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.
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}.
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.

Node.js
// 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.

Getting events

The envelope and headers — what wraps every payload on this page.

Verifying signatures

Prove it came from Amomic-AI — the HMAC format, the tolerance window, the raw-bytes rule.

Retries & failures

When your endpoint has a bad day — the retry ladder, auto-disable, Replay.

Order updates recipe

A full loop — send, message.status, message.received, and reading the reply.