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:
| Key | Present when |
|---|---|
file, file_uniq_id | any media type (stripped for text, location, system) |
extra | location only, and only on WhatsApp (see §4 defect list) |
attachments, cc, bcc, reply_to | room.channel == "email" only |
campaign_context | only when the register_campaign_type_to_caa flag is ON (global and per-org) — currently OFF |
file shape — small/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:
| Key | Signal |
|---|---|
channel | wa_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. |
status | unassigned / assigned / resolved / campaign (broadcast-created, no agent yet) |
type | Models::CustomerServiceRoom · CommentServiceRoom (never fires CAA) · DirectSendRoom (outbound-initiated) |
is_unresponded, agent_ids: [] | nobody has replied yet |
session_at, last_message_at | 24h WhatsApp session window |
tags, division_id | routing context |
call_permission_request | [{message_id, status}] for wa_cloud calling |
account_uniq_id | customer phone number |
ext_user_id / ext_username / ext_parent_user_id | channel-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 videoline is commented out atmessage_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.
voiceis 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, … } }
filenameis the real provider filename here (from Meta'sdata.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
sha256andanimated— neither 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
⚠️
extrais 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 thehandle_order_messageflag — when OFF the whole thing degrades totext: "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
| Type | What actually happens |
|---|---|
contact | Meta 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. |
form | WA Flow submits arrive as type: "text" — see the special case below. |
referral | CAA 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. |
system | No inbound path creates one; Meta system events are downgraded to text. |
call_permission_request | Outbound (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_responseis 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 triggercampaign_contextuses.
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 readsbuttons[0].idto 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 == trueis the cheapest campaign-reply detector available today — no feature flag needed.
4. Reference table — for the AI service mapping
type | Content field | AI-usable today? | What's missing |
|---|---|---|---|
text | text | ✅ full | — |
image | file.url + text caption | ✅ (OCR path exists) | — |
video | file.url + caption | ⚠️ URL only | no transcript |
audio | file.url | ⚠️ URL only | no transcript |
voice | file.url | ⚠️ URL only | no transcript |
document | file.url + filename | ⚠️ URL only | no text extraction |
sticker | file.url | ⚠️ URL only | no sha256, no animated |
location | extra{lat,lng,name,address} | ✅ WA only | broken on telegram/line/fb |
story | url | ⚠️ image only | no permalink/caption |
order | text + buttons[0].id | 🔴 total only | productItems[], quantities, prices |
product | text placeholder | 🔴 name only | SKU, price, stock, description |
invoice | text placeholder | 🔴 number only | amount, status, URL |
voucher | text | 🔴 | code, discount, validity |
calls | text | 🔴 | — |
contact | rewritten to text | 🔴 | entire contact card |
form | form_response{} | ✅ structured | arrives as type:"text" |
referral | — | 🔴 not delivered | entire 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:
Repositories::AiService::SendContext— pushes an arbitrary{role, message}turn into the AI thread. Already called on every inbound message. Fastest prototype path.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
| # | Change | Repo | Size |
|---|---|---|---|
| 1 | Builder 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-core | S — data is already fetched and rendered in the panel |
| 2 | Enable 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-core | XS — config |
| 3 | Lift the referral CAA suppression (or attach referral to the originating text message). Needs a product decision, not just code. | hub-core | M |
| 4 | Pass message_type to the AI + relax the non-text pre-filter. Today type is only used to reject before the model is consulted. | chatbot | S — squad-owned, no DSAI dep |
| 5 | Extract sticker sha256/animated; real contacts handler | hub-core | S each |
Items 1–3 are Chat-squad dependencies. Item 4 is entirely ours and is the highest leverage per unit of effort.