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.
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.
// Wrong — assumes the key is thereconst words = event.data.summary.split(" ");// Rightconst 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. |
{"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"}}
{"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:
- Find the conversation. Walk
GET /conversations(newest activity first) and take the firstwhatsappconversation whosecustomer.phoneequalsfrom. If you also subscribe toconversation.started, you can keep your own map of number toconversation_idand skip the walk. - Read
GET /conversations/{id}/messagesto the last page and take the newestuserturn.
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. |
{"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"}}
{"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. |
{"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.
{"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. |
{"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. |
{"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"}}
{"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 oncall.outcome. - No recording. There is no
recording_urihere and no recording-ready event. If the call was recorded,recording_uriis onGET /calls/{call_id}. - No
status. Onlyoutcome. ReadstatusfromGET /calls/{call_id}if you need it. - No
campaign_idand nometadata. Themetadatayou sent withPOST /callscomes back oncall.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. |
{"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. |
{"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.
{"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. |
{"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. |
{"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. |
{"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. |
{"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. |
{"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:
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:
{"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
idon every test, acreated_atand yourtenant_id, so a strict model that requires those fields accepts it. It has nodata.event_id. - It is not subscribable. You can't tick it or untick it. An endpoint subscribed to exactly
call.endedstill receives apingwhen someone presses Test. Your router needs a default branch that returns200for an unknowntype. - 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.
// 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
typeis a200. - Ordering comes from
created_at, not from arrival order.
Related #
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.