Skip to main content

CAA Webhook Payload Catalogue — every type value Chat sends to the bot

Purpose: map each inbound message type to what the bot actually receives, so it can be forwarded to the AI service. Date: 2026-07-27 · Grounded against: hub-core (sender) + chatbot (receiver) Endpoint: POST {chatbot}/webhook/:webhook_code · always stamped "data_event": "custom_agent_allocation" Confluence: Qontak | Chatbot & AI | CAA Webhook Payload Catalogue


1. The envelope — identical on every type

These keys appear on every CAA payload regardless of type. Values below are from the real audio sample.

{
"id": "b640fafe-…", // message uuid
"type": "audio", // ← THE DISCRIMINATOR (18 values, §3)
"room_id": "0d666821-…",
"is_campaign": false, // ALWAYS false on inbound (marks outbound broadcast sends)
"sender_id": "9ae56132-…",
"sender_type": "Models::Contact", // always Contact — agent/bot msgs never fire CAA
"participant_id": "9b794865-…",
"organization_id": "c209d013-…",
"text": null, // content varies by type — see §3
"status": "created",
"external_id": "wamid.HBgNNjI4…", // provider message id
"local_id": null,
"created_at": "2026-07-27T08:30:46.000Z",
"buttons": [], // populated on template/interactive taps + order
"url": "", // populated on story + link
"form_response": {}, // populated only on WA Flow submit
"reply": null, // populated when customer quotes a message
"room": { … }, // see §2
"sender": { "name", "avatar", "qontak_customer_id" },
"participant_type": "customer",
"channel_integration": { "id", "target_channel" }, // + wa_id, account_name when target_channel == "wa"
"last_campaign": { "last_campaign_id", "last_campaign_name",
"last_sent_template_id", "last_sent_template_name",
"last_sent_at" }, // all null unless room was CREATED by a broadcast
"data_event": "custom_agent_allocation",
"webhook_event": "custom_agent_allocation"
}

Conditionally present:

KeyPresent when
file, file_uniq_idany media type (stripped for text, location, system)
extralocation only, and only on WhatsApp (see §4 defect list)
attachments, cc, bcc, reply_toroom.channel == "email" only
campaign_contextonly when the register_campaign_type_to_caa flag is ON (global and per-org) — currently OFF

file shapesmall/medium/large are only real URLs for images; null for audio/video/document (as in your sample):

"file": { "url": "...", "large": {"url": null}, "medium": {"url": null},
"small": {"url": null}, "filename": "1402153128454104.ogg", "size": 0 }

2. room object — carries the origin signals

Same on every type. The AI-relevant fields:

KeySignal
channelwa_cloud, wa, telegram, line, instagram, email, webchat, shopee, tokopedia, desty_*, twitter, app_chat, bot_preview
description";source_id=<ad_id>;ctwa_clid=<clid>;" when the room came from a Click-to-WhatsApp ad · "{broadcast_name}-{broadcast_id}" when created by a campaign · "" organic. The only ad signal on the wire.
statusunassigned / assigned / resolved / campaign (broadcast-created, no agent yet)
typeModels::CustomerServiceRoom · CommentServiceRoom (never fires CAA) · DirectSendRoom (outbound-initiated)
is_unresponded, agent_ids: []nobody has replied yet
session_at, last_message_at24h WhatsApp session window
tags, division_idrouting context
call_permission_request[{message_id, status}] for wa_cloud calling
account_uniq_idcustomer phone number
ext_user_id / ext_username / ext_parent_user_idchannel-native identity (IG/FB/Tokopedia)

3. Per-type payload — the delta from the envelope

✅ Types with usable content

text

{ "type": "text", "text": "halo saya mau tanya soal pesanan",
"buttons": [], "url": "", "form_response": {} }
// file + file_uniq_id STRIPPED

image

{ "type": "image", "text": "ini foto strukmya", // caption, or null
"file_uniq_id": "…",
"file": { "url": "https://cdn.qontak.com/uploads/message/file/…/img.jpg",
"large": {"url": "…"}, // real URLs — images only
"medium": {"url": "…"},
"small": {"url": "…"},
"filename": "img.jpg", "size": 84213 } }

video

{ "type": "video", "text": "<caption or null>",
"file": { "url": "…mp4", "large": {"url": null}, "filename": "…mp4", "size": 0 } }

The hide :text if video line is commented out at message_by_webhook.rb:97 — the caption does pass through.

audio / voice

{ "type": "audio", "text": null,
"file": { "url": "…ogg", "filename": "…ogg", "size": 0, "large/medium/small": {"url": null} } }

Exactly your sample. voice is byte-identical in shape — WhatsApp voice notes vs uploaded audio files.

document

{ "type": "document", "text": "<caption or null>",
"file": { "url": "…pdf", "filename": "Invoice-2026-07.pdf", "size": 0, … } }

filename is the real provider filename here (from Meta's data.filename), unlike other types where it's derived from the URL basename.

sticker

{ "type": "sticker", "text": null,
"file": { "url": "…webp", "filename": "…webp", "size": 0, … } }

⚠️ Meta sends sha256 and animatedneither is extracted. Only the URL survives.

location

{ "type": "location",
"text": "Jl. Casablanca Raya Kav. 88, Jakarta", // address, or name, or "No location Info"
"extra": { "latitude": -6.2242, "longitude": 106.8412,
"name": "Mekari HQ", "address": "Jl. Casablanca Raya Kav. 88" } }
// file + file_uniq_id STRIPPED

⚠️ extra is built only from the WhatsApp payload shape. On telegram / line / facebook it silently comes back absent.

story (Instagram)

{ "type": "story", "text": null,
"url": "https://cdn.qontak.com/uploads/…/story.jpg" } // re-hosted, not Meta's CDN

No permalink, no story id, no caption.


🔴 Types that arrive as empty shells

The rich object exists in messages.raw_message and renders fine in the Chat Panel — CAA just doesn't forward it.

order (WhatsApp catalog / Shopee / Desty)

{ "type": "order", "text": "Rp 150000",
"buttons": [ { "id": "<catalog_id>", "text": "View order", "type": "BUTTON" } ] }

Bot does NOT get: productItems[{product_retailer_id, quantity, item_price, currency}], itemNum, catalogId, createTime. Also gated by the handle_order_message flag — when OFF the whole thing degrades to text: "Order message not supported".

product

{ "type": "product", "text": "You receive a message for Kaos Polos Hitam product" }
// wa_cloud referred_product instead puts the customer's own text here

Bot does NOT get: item_id, retailer_id, price_info{currency, current_price, sale_price}, description, stock, availability, url.

invoice (Tokopedia)

{ "type": "invoice", "text": "You receive a message for INV/20260727/XX/123 invoice" }

Bot does NOT get: order_id, invoice_number, price, status, invoice_url, thumbnail.

voucher (desty channels only)

{ "type": "voucher", "text": "<parsed content text>" }

Bot does NOT get: voucher_code, discount_type, discount_amount, quota, validity window.

calls (wa_cloud calling)

{ "type": "calls", "text": "Incoming" }
// or "Declined due to insufficent voice balance"

The SDP/session object is explicitly stripped before persistence. Room-level state is available at room.call_permission_request.


⛔ Types that never arrive as that type

TypeWhat actually happens
contactMeta sends contacts (plural), which isn't in the enum → rewritten to {"type": "text", "text": "Currently contacts message is not supported"}. The full {name, phones[], emails[], org, addresses[]} is discarded.
formWA Flow submits arrive as type: "text" — see the special case below.
referralCAA is suppressed entirely for ads (!@is_referral). The bot never sees the CTWA ad, its headline, or its creative. Only room.description hints at it.
systemNo inbound path creates one; Meta system events are downgraded to text.
call_permission_requestOutbound (agent→customer) only.

🔷 Special cases that are type: "text" but carry structure

These are the ones easiest to miss — the type field lies.

WA Flow form submission — a second, separate CAA call

{ "type": "text", "text": "✅ Data submitted",
"form_response": {
"data": { "nama_lengkap": "Dimas", "email": "dimas@…",
"upload_ktp": [ { "id": "<uuid>", "url": "https://…" } ] },
"form_external_id": "<meta flow_id>",
"form_name": "Form Pendaftaran" } }

The customer's original inbound is deliberately CAA-skipped; this arrives as a separate second delivery. The form_response is set in memory only — it is not persisted on that message id.

Template quick-reply tap (campaign reply)

{ "type": "text", "text": "Ya, saya tertarik",
"buttons": [ { "type": "BUTTON", "text": "Ya, saya tertarik", "id": "…", "index": 0 } ] }

buttons[0].type == "BUTTON" ⇒ the customer tapped a button on a broadcast template. This is the trigger campaign_context uses.

Interactive button / list reply (bot flow)

{ "type": "text", "text": "Cek status pesanan",
"buttons": [ { "type": "BUTTON_REPLY", "id": "btn_cek_status", "title": "Cek status pesanan" } ] }

BUTTON_REPLY ⇒ reply to the bot's own interactive message. The chatbot reads buttons[0].id to resolve the next intent.

Call-permission reply

{ "type": "text", "text": "Allow" } // or "Don't Allow"

Quoted reply (any type)

{ "reply": { "id": "…", "type": "text", "text": "<the quoted message>",
"is_campaign": true, // ← TRUE ⇒ customer is replying to a broadcast
"sender": {…}, "status": "read", "created_at": "…", "url": "", "file": {…} } }

reply.is_campaign == true is the cheapest campaign-reply detector available today — no feature flag needed.


4. Reference table — for the AI service mapping

typeContent fieldAI-usable today?What's missing
texttext✅ full
imagefile.url + text caption✅ (OCR path exists)
videofile.url + caption⚠️ URL onlyno transcript
audiofile.url⚠️ URL onlyno transcript
voicefile.url⚠️ URL onlyno transcript
documentfile.url + filename⚠️ URL onlyno text extraction
stickerfile.url⚠️ URL onlyno sha256, no animated
locationextra{lat,lng,name,address}✅ WA onlybroken on telegram/line/fb
storyurl⚠️ image onlyno permalink/caption
ordertext + buttons[0].id🔴 total onlyproductItems[], quantities, prices
producttext placeholder🔴 name onlySKU, price, stock, description
invoicetext placeholder🔴 number onlyamount, status, URL
vouchertext🔴code, discount, validity
callstext🔴
contactrewritten to text🔴entire contact card
formform_response{}✅ structuredarrives as type:"text"
referral🔴 not deliveredentire ad context

Suggested normalized shape to hand the AI service

Nothing like this exists today — the AI Agent payload is 13 fields and does not include message_type. Proposal:

"message_context": {
"type": "order",
"channel": "wa_cloud",
"origin": "ad", // ad | campaign | organic — derive from room.description / status
"campaign": { "name": "…", "template": "…", "replied_button": "…" }, // from campaign_context
"ad": { "source_id": "…", "headline": "…", "source_url": "…" }, // needs referral fix
"content": { … } // the type-specific structured object
}

Two zero-contract-change injection points on the chatbot side:

  1. Repositories::AiService::SendContext — pushes an arbitrary {role, message} turn into the AI thread. Already called on every inbound message. Fastest prototype path.
  2. ai_settings — already a hash on the AI Agent payload (currently just {timezone}). Additive chatbot-side, but needs the AI service to read it.

5. Fix list, in dependency order

#ChangeRepoSize
1Builder parity — make Builders::MessageByWebhook reuse Builders::Message#attributes_extra. Closes order + product + invoice + voucher + desty-sticker + the multi-channel location bug in one change.hub-coreS — data is already fetched and rendered in the panel
2Enable register_campaign_type_to_caa — built and tested, just flagged off. Gives campaign_context.message_content_text = exactly what the customer is replying to.hub-coreXS — config
3Lift the referral CAA suppression (or attach referral to the originating text message). Needs a product decision, not just code.hub-coreM
4Pass message_type to the AI + relax the non-text pre-filter. Today type is only used to reject before the model is consulted.chatbotS — squad-owned, no DSAI dep
5Extract sticker sha256/animated; real contacts handlerhub-coreS each

Items 1–3 are Chat-squad dependencies. Item 4 is entirely ours and is the highest leverage per unit of effort.