feat: Capture CTWA referral metadata for WhatsApp conversations (#14681)

Adds support for capturing Click-to-WhatsApp ad referral metadata from
incoming WhatsApp messages.

This stores Meta Cloud API `referral` payloads on the incoming message
`content_attributes` and normalizes Twilio `Referral*` callback fields
into the same shape. The UI display is intentionally deferred until
Instagram, Messenger, and TikTok referral payloads are captured as well,
so we can design one cross-channel referral surface instead of a
WhatsApp-only sidebar block.

Why message-level storage

Meta sends CTWA referral details as part of the inbound message payload,
not as a stable conversation-level webhook. The referral represents the
exact ad click that produced that specific customer message, and later
messages in the same conversation may not carry the same context.
Storing the normalized referral on the message preserves the original
webhook semantics, avoids adding conversation-level attribution that
could become stale or ambiguous, and keeps support for both Cloud API
and Twilio payloads aligned behind `content_attributes.referral`.
Fixes
https://linear.app/chatwoot/issue/CW-7090/surface-meta-ctwa-referral-on-incoming-whatsapp-messages-cloud-api
Closes https://github.com/chatwoot/chatwoot/issues/13995,
https://github.com/chatwoot/chatwoot/issues/12560,
https://github.com/chatwoot/chatwoot/issues/13006


Related community PRs
- https://github.com/chatwoot/chatwoot/pull/13130
- https://github.com/chatwoot/chatwoot/pull/14180
- https://github.com/chatwoot/chatwoot/pull/14121

Related follow-ups
-
https://linear.app/chatwoot/issue/CW-7301/capture-instagram-ad-referral-metadata-on-incoming-messages
-
https://linear.app/chatwoot/issue/CW-7302/capture-messenger-ad-referral-metadata-on-facebook-page-conversations
-
https://linear.app/chatwoot/issue/CW-7303/capture-tiktok-ad-referral-metadata-from-im-referral-msg-events

How to test
1. Send or replay a WhatsApp Cloud API inbound message that contains a
`messages[0].referral` payload from a Click-to-WhatsApp ad.
2. Confirm the generated incoming message stores the payload under
`content_attributes.referral`.
3. Repeat with a Twilio WhatsApp callback containing `Referral*` fields
and confirm the stored message has the same normalized
`content_attributes.referral` structure.

---------

Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
This commit is contained in:
Muhsin Keloth
2026-06-16 09:58:29 +04:00
committed by GitHub
co-authored by Muhsin
parent a3d05ef55d
commit de137e8297
8 changed files with 204 additions and 9 deletions
+11 -1
View File
@@ -35,7 +35,17 @@ class Twilio::CallbackController < ApplicationController
:ExternalUserId,
:ParentExternalUserId,
:ProfileUsername,
:Username
:Username,
:ReferralBody,
:ReferralHeadline,
:ReferralSourceId,
:ReferralSourceType,
:ReferralSourceUrl,
:ReferralMediaId,
:ReferralMediaContentType,
:ReferralMediaUrl,
:ReferralNumMedia,
:ReferralCtwaClid
)
end
end
@@ -1,6 +1,7 @@
class Twilio::IncomingMessageService
include ::FileTypeHelper
include ::Twilio::WhatsappIdentifierHelper
include ::Twilio::ReferralParamsHelper
pattr_initialize [:params!]
@@ -15,7 +16,8 @@ class Twilio::IncomingMessageService
inbox_id: @inbox.id,
message_type: :incoming,
sender: @contact,
source_id: params[:SmsSid]
source_id: params[:SmsSid],
content_attributes: message_content_attributes
)
attach_files
attach_location if location_message?
@@ -0,0 +1,28 @@
module Twilio::ReferralParamsHelper
REFERRAL_PARAM_MAPPING = {
source_id: :ReferralSourceId,
source_type: :ReferralSourceType,
source_url: :ReferralSourceUrl,
headline: :ReferralHeadline,
body: :ReferralBody,
media_id: :ReferralMediaId,
media_content_type: :ReferralMediaContentType,
media_url: :ReferralMediaUrl,
num_media: :ReferralNumMedia,
ctwa_clid: :ReferralCtwaClid
}.freeze
def message_content_attributes
referral_attributes.present? ? { referral: referral_attributes } : {}
end
def referral_attributes
return {} unless twilio_channel.whatsapp?
return {} if params[:ReferralSourceId].blank?
REFERRAL_PARAM_MAPPING.each_with_object({}) do |(attribute, param_key), result|
value = params[param_key]
result[attribute] = value if value.present?
end
end
end
@@ -91,7 +91,7 @@ class Whatsapp::IncomingMessageBaseService
def create_contact_messages(message)
message['contacts'].each do |contact|
# Pass source_id from parent message since contact objects don't have :id
create_message(contact, source_id: message[:id])
create_message(contact, source_id: message[:id], content_attributes_source: message)
attach_contact(contact)
@message.save!
end
@@ -158,10 +158,7 @@ class Whatsapp::IncomingMessageBaseService
)
end
def create_message(message, source_id: nil)
content_attrs = outgoing_echo ? { external_echo: true } : {}
content_attrs[:in_reply_to_external_id] = @in_reply_to_external_id if @in_reply_to_external_id.present?
def create_message(message, source_id: nil, content_attributes_source: message)
@message = @conversation.messages.build(
content: message_content(message),
account_id: @inbox.account_id,
@@ -171,10 +168,18 @@ class Whatsapp::IncomingMessageBaseService
status: outgoing_echo ? :delivered : :sent,
sender: outgoing_echo ? nil : @contact,
source_id: (source_id || message[:id]).to_s,
content_attributes: content_attrs
content_attributes: message_content_attributes(content_attributes_source)
)
end
def message_content_attributes(message)
content_attrs = outgoing_echo ? { external_echo: true } : {}
content_attrs[:in_reply_to_external_id] = @in_reply_to_external_id if @in_reply_to_external_id.present?
referral_content_attrs = referral_attributes(message)
content_attrs[:referral] = referral_content_attrs if referral_content_attrs.present?
content_attrs
end
def attach_contact(contact)
phones = contact[:phones]
phones = [{ phone: 'Phone number is not available' }] if phones.blank?
@@ -71,6 +71,12 @@ module Whatsapp::IncomingMessageServiceHelpers
@in_reply_to_external_id = message['context']&.[]('id')
end
def referral_attributes(message)
return {} if outgoing_echo
message[:referral]&.to_h&.deep_stringify_keys || {}
end
def find_message_by_source_id(source_id)
return unless source_id
@@ -13,7 +13,15 @@ RSpec.describe 'Twilio::CallbacksController', type: :request do
'SmsSid' => 'SM123',
'ExternalUserId' => 'IN.2081978709342942',
'ParentExternalUserId' => 'IN.ENT.9081726354',
'ProfileUsername' => 'muhsin'
'ProfileUsername' => 'muhsin',
'ReferralCtwaClid' => 'AfjyUDlaIoiweZDnlzmDTEaG',
'ReferralSourceId' => '120237244350960485',
'ReferralSourceUrl' => 'https://fb.me/4tBfhWhjr',
'ReferralSourceType' => 'ad',
'ReferralHeadline' => 'German citizenship lawyer',
'ReferralBody' => 'Fast-track your German citizenship',
'ReferralMediaId' => '',
'ReferralNumMedia' => '0'
}
end
@@ -511,6 +511,60 @@ describe Twilio::IncomingMessageService do
end
end
describe 'When the incoming WhatsApp message has CTWA referral parameters' do
let!(:whatsapp_twilio_channel) do
create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
inbox: create(:inbox, account: account, greeting_enabled: false))
end
it 'stores normalized referral attributes on the message' do
params = {
SmsSid: 'SMxx',
From: 'whatsapp:+491741763110',
AccountSid: 'ACxxx',
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
Body: 'Hallo! Kann ich hierzu mehr Informationen erhalten?',
ReferralCtwaClid: 'AfjyUDlaIoiweZDnlzmDTEaG',
ReferralSourceId: '120237244350960485',
ReferralSourceUrl: 'https://fb.me/4tBfhWhjr',
ReferralSourceType: 'ad',
ReferralHeadline: 'German citizenship lawyer',
ReferralBody: 'Fast-track your German citizenship',
ReferralMediaId: '',
ReferralNumMedia: '0'
}
described_class.new(params: params).perform
message = whatsapp_twilio_channel.inbox.messages.last
expect(message.content_attributes['referral']).to eq(
'ctwa_clid' => 'AfjyUDlaIoiweZDnlzmDTEaG',
'source_id' => '120237244350960485',
'source_url' => 'https://fb.me/4tBfhWhjr',
'source_type' => 'ad',
'headline' => 'German citizenship lawyer',
'body' => 'Fast-track your German citizenship',
'num_media' => '0'
)
end
it 'does not add referral attributes when ReferralSourceId is absent' do
params = {
SmsSid: 'SMxx',
From: 'whatsapp:+491741763110',
AccountSid: 'ACxxx',
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
Body: 'Regular WhatsApp message',
ReferralCtwaClid: 'AfjyUDlaIoiweZDnlzmDTEaG'
}
described_class.new(params: params).perform
message = whatsapp_twilio_channel.inbox.messages.last
expect(message.content_attributes).not_to have_key('referral')
end
end
describe 'When the incoming number is a Brazilian number in new format with 9 included' do
let!(:whatsapp_twilio_channel) do
create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
@@ -234,6 +234,88 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
end
end
context 'when message contains referral data' do
let(:referral_params) do
{
phone_number: whatsapp_channel.phone_number,
object: 'whatsapp_business_account',
entry: [{
changes: [{
value: {
contacts: [{ profile: { name: 'Mom' }, wa_id: '255718573302', user_id: 'TZ.1040042605869930' }],
messages: [{
referral: {
source_url: 'https://fb.me/3TYpooaRT',
source_id: '52558118838064',
source_type: 'ad',
body: 'washa data tu',
headline: 'Diana Digital',
media_type: 'video',
video_url: 'https://www.facebook.com/reel/1438165771395493/',
thumbnail_url: 'https://scontent.xx.fbcdn.net/sample.jpg',
ctwa_clid: 'AfhcQdP2E4A8wWpeb1FqUzUi',
welcome_message: {
text: 'Hi! Please let us know how we can help you.'
}
},
from: '255718573302',
from_user_id: 'TZ.1040042605869930',
id: 'wamid.CTWA_REFERRAL_MESSAGE',
timestamp: '1780649766',
text: { body: 'Hello nielekeze' },
type: 'text'
}]
}
}]
}]
}.with_indifferent_access
end
it 'stores the referral payload in message content attributes' do
described_class.new(inbox: whatsapp_channel.inbox, params: referral_params).perform
message = whatsapp_channel.inbox.messages.last
expect(message.content).to eq('Hello nielekeze')
expect(message.content_attributes['referral']).to include(
'source_url' => 'https://fb.me/3TYpooaRT',
'source_id' => '52558118838064',
'source_type' => 'ad',
'body' => 'washa data tu',
'headline' => 'Diana Digital',
'media_type' => 'video',
'video_url' => 'https://www.facebook.com/reel/1438165771395493/',
'thumbnail_url' => 'https://scontent.xx.fbcdn.net/sample.jpg',
'ctwa_clid' => 'AfhcQdP2E4A8wWpeb1FqUzUi',
'welcome_message' => { 'text' => 'Hi! Please let us know how we can help you.' }
)
end
it 'preserves the referral payload when the message contains contacts' do
contacts_referral_params = referral_params.deep_dup
parent_message = contacts_referral_params.dig(:entry, 0, :changes, 0, :value, :messages, 0)
parent_message[:type] = 'contacts'
parent_message.delete(:text)
parent_message[:contacts] = [{
name: {
formatted_name: 'Diana Digital',
first_name: 'Diana',
last_name: 'Digital'
},
phones: [{ phone: '+255718573302' }]
}]
described_class.new(inbox: whatsapp_channel.inbox, params: contacts_referral_params).perform
message = whatsapp_channel.inbox.messages.last
expect(message.content).to eq('Diana Digital')
expect(message.content_attributes['referral']).to include(
'source_id' => '52558118838064',
'headline' => 'Diana Digital',
'ctwa_clid' => 'AfhcQdP2E4A8wWpeb1FqUzUi'
)
end
end
context 'when message is a reply (has context)' do
let(:reply_params) do
{