diff --git a/enterprise/app/controllers/enterprise/api/v1/accounts_settings.rb b/enterprise/app/controllers/enterprise/api/v1/accounts_settings.rb
index bdcbfc1d3..0ef648567 100644
--- a/enterprise/app/controllers/enterprise/api/v1/accounts_settings.rb
+++ b/enterprise/app/controllers/enterprise/api/v1/accounts_settings.rb
@@ -1,6 +1,20 @@
module Enterprise::Api::V1::AccountsSettings
+ def create
+ super
+ record_marketing_attribution
+ end
+
private
+ def record_marketing_attribution
+ return if current_user.present?
+ return if @account.blank?
+
+ Internal::Accounts::MarketingAttributionService.new(account: @account, cookies: cookies).perform
+ rescue StandardError => e
+ ChatwootExceptionTracker.new(e).capture_exception
+ end
+
def permitted_settings_attributes
super + [{ conversation_required_attributes: [] }]
end
diff --git a/enterprise/app/services/internal/accounts/internal_attributes_service.rb b/enterprise/app/services/internal/accounts/internal_attributes_service.rb
index d119d6345..593cea799 100644
--- a/enterprise/app/services/internal/accounts/internal_attributes_service.rb
+++ b/enterprise/app/services/internal/accounts/internal_attributes_service.rb
@@ -4,7 +4,7 @@ class Internal::Accounts::InternalAttributesService
# List of keys that can be managed through this service
# TODO: Add account_notes field in future
# This field can be used to store notes about account on Chatwoot cloud
- VALID_KEYS = %w[manually_managed_features].freeze
+ VALID_KEYS = %w[manually_managed_features marketing_attribution].freeze
def initialize(account)
@account = account
diff --git a/enterprise/app/services/internal/accounts/marketing_attribution_service.rb b/enterprise/app/services/internal/accounts/marketing_attribution_service.rb
new file mode 100644
index 000000000..08bb93823
--- /dev/null
+++ b/enterprise/app/services/internal/accounts/marketing_attribution_service.rb
@@ -0,0 +1,82 @@
+# frozen_string_literal: true
+
+require 'base64'
+
+class Internal::Accounts::MarketingAttributionService
+ FIRST_TOUCH_COOKIE = 'cw_first_touch_attribution'
+ LAST_TOUCH_COOKIE = 'cw_last_touch_attribution'
+ FIELD_MAX_LENGTH = 500
+ ALLOWED_FIELDS = %w[
+ utm_source
+ utm_medium
+ utm_campaign
+ utm_term
+ utm_content
+ utm_id
+ gclid
+ gbraid
+ wbraid
+ dclid
+ fbclid
+ msclkid
+ ttclid
+ li_fat_id
+ twclid
+ rdt_cid
+ referrer
+ referrer_path
+ landing_page
+ source
+ source_type
+ captured_at
+ ].freeze
+
+ pattr_initialize [:account!, :cookies!]
+
+ def perform
+ return unless ChatwootApp.chatwoot_cloud?
+
+ first_touch = attribution_cookie(FIRST_TOUCH_COOKIE)
+ last_touch = attribution_cookie(LAST_TOUCH_COOKIE)
+ return unless first_touch || last_touch
+
+ existing_attribution = internal_attributes_service.get('marketing_attribution') || {}
+ internal_attributes_service.set(
+ 'marketing_attribution',
+ {
+ 'first_touch' => first_touch || existing_attribution['first_touch'],
+ 'last_touch' => last_touch || existing_attribution['last_touch'],
+ 'captured_from' => 'cookie',
+ 'stored_at' => Time.current.iso8601
+ }.compact
+ )
+ end
+
+ private
+
+ def attribution_cookie(cookie_name)
+ return if cookies[cookie_name].blank?
+
+ parse_cookie(cookies[cookie_name].to_s)
+ end
+
+ def parse_cookie(cookie_value)
+ validate_payload(JSON.parse(Base64.urlsafe_decode64(cookie_value)))
+ rescue JSON::ParserError, ArgumentError
+ nil
+ end
+
+ def validate_payload(payload)
+ return unless payload.is_a?(Hash)
+
+ payload.slice(*ALLOWED_FIELDS).filter_map do |key, value|
+ next if value.blank? || value.is_a?(Array) || value.is_a?(Hash)
+
+ [key, value.to_s.first(FIELD_MAX_LENGTH)]
+ end.to_h.presence
+ end
+
+ def internal_attributes_service
+ @internal_attributes_service ||= Internal::Accounts::InternalAttributesService.new(account)
+ end
+end
diff --git a/spec/enterprise/controllers/api/v1/accounts_controller_spec.rb b/spec/enterprise/controllers/api/v1/accounts_controller_spec.rb
new file mode 100644
index 000000000..00e50c0e8
--- /dev/null
+++ b/spec/enterprise/controllers/api/v1/accounts_controller_spec.rb
@@ -0,0 +1,66 @@
+require 'rails_helper'
+require 'base64'
+
+RSpec.describe 'Enterprise Accounts API', type: :request do
+ describe 'POST /api/v1/accounts' do
+ let(:email) { Faker::Internet.email }
+ let(:user_full_name) { Faker::Name.name_with_middle }
+ let(:first_touch_cookie) { Base64.urlsafe_encode64({ source: 'reddit', source_type: 'paid_social' }.to_json, padding: false) }
+ let(:last_touch_cookie) { Base64.urlsafe_encode64({ source: 'github', source_type: 'referral' }.to_json, padding: false) }
+ let(:attribution_cookie_header) do
+ {
+ 'Cookie' => [
+ "#{Internal::Accounts::MarketingAttributionService::FIRST_TOUCH_COOKIE}=#{first_touch_cookie}",
+ "#{Internal::Accounts::MarketingAttributionService::LAST_TOUCH_COOKIE}=#{last_touch_cookie}"
+ ].join('; ')
+ }
+ end
+
+ before do
+ allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
+ end
+
+ it 'records marketing attribution for unauthenticated signup requests' do
+ account_builder = double
+ account = create(:account)
+ user = create(:user, email: email, account: account, name: user_full_name)
+
+ allow(AccountBuilder).to receive(:new).and_return(account_builder)
+ allow(account_builder).to receive(:perform).and_return([user, account])
+
+ with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
+ post api_v1_accounts_url,
+ params: {
+ account_name: 'test',
+ email: email,
+ user: nil,
+ locale: nil,
+ user_full_name: user_full_name,
+ password: 'Password1!'
+ },
+ headers: attribution_cookie_header,
+ as: :json
+ end
+
+ attribution = account.reload.internal_attributes['marketing_attribution']
+ expect(attribution['captured_from']).to eq('cookie')
+ expect(attribution['first_touch']).to include('source' => 'reddit', 'source_type' => 'paid_social')
+ expect(attribution['last_touch']).to include('source' => 'github', 'source_type' => 'referral')
+ end
+
+ it 'does not record marketing attribution for authenticated add-workspace requests' do
+ existing_user = create(:user, password: 'Password1!')
+
+ with_modified_env ENABLE_ACCOUNT_SIGNUP: 'true' do
+ post api_v1_accounts_url,
+ params: { account_name: 'Second Account', email: existing_user.email,
+ user_full_name: existing_user.name, password: 'Password1!' },
+ headers: existing_user.create_new_auth_token.merge(attribution_cookie_header),
+ as: :json
+ end
+
+ account = Account.find(response.parsed_body.dig('data', 'account_id'))
+ expect(account.internal_attributes).not_to include('marketing_attribution')
+ end
+ end
+end
diff --git a/spec/enterprise/services/internal/accounts/marketing_attribution_service_spec.rb b/spec/enterprise/services/internal/accounts/marketing_attribution_service_spec.rb
new file mode 100644
index 000000000..0046908dc
--- /dev/null
+++ b/spec/enterprise/services/internal/accounts/marketing_attribution_service_spec.rb
@@ -0,0 +1,162 @@
+# frozen_string_literal: true
+
+require 'rails_helper'
+require 'base64'
+
+RSpec.describe Internal::Accounts::MarketingAttributionService do
+ let(:account) { create(:account) }
+ let(:cookies) { {} }
+
+ before do
+ allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
+ end
+
+ it 'stores website attribution cookies on the account' do
+ cookies[described_class::FIRST_TOUCH_COOKIE] = encoded_cookie(
+ 'source' => 'reddit',
+ 'source_type' => 'paid_social',
+ 'referrer' => 'https://reddit.com',
+ 'referrer_path' => '/r/selfhosted/comments/123/chatwoot'
+ )
+ cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie(
+ 'source' => 'github',
+ 'source_type' => 'referral'
+ )
+
+ described_class.new(account: account, cookies: cookies).perform
+
+ attribution = account.reload.internal_attributes['marketing_attribution']
+ expect(attribution['captured_from']).to eq('cookie')
+ expect(attribution['first_touch']['source']).to eq('reddit')
+ expect(attribution['first_touch']['referrer_path']).to eq('/r/selfhosted/comments/123/chatwoot')
+ expect(attribution['last_touch']['source']).to eq('github')
+ end
+
+ it 'does not store attribution outside Chatwoot Cloud' do
+ allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
+ cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie('source' => 'reddit')
+
+ described_class.new(account: account, cookies: cookies).perform
+
+ expect(account.reload.internal_attributes).not_to include('marketing_attribution')
+ end
+
+ it 'decodes base64url cookie values and preserves plus signs' do
+ cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie(
+ 'source' => 'google',
+ 'utm_campaign' => 'C++ launch'
+ )
+
+ described_class.new(account: account, cookies: cookies).perform
+
+ attribution = account.reload.internal_attributes['marketing_attribution']
+ expect(attribution['last_touch']['utm_campaign']).to eq('C++ launch')
+ end
+
+ it 'preserves an existing touch when the matching cookie is absent' do
+ account.update!(
+ internal_attributes: {
+ 'marketing_attribution' => {
+ 'first_touch' => { 'source' => 'reddit' },
+ 'last_touch' => { 'source' => 'github' }
+ }
+ }
+ )
+ cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie('source' => 'google')
+
+ described_class.new(account: account, cookies: cookies).perform
+
+ attribution = account.reload.internal_attributes['marketing_attribution']
+ expect(attribution['first_touch']['source']).to eq('reddit')
+ expect(attribution['last_touch']['source']).to eq('google')
+ end
+
+ it 'preserves other internal attributes' do
+ account.update!(internal_attributes: { 'manually_managed_features' => ['inbound_emails'] })
+ cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie('source' => 'google')
+
+ described_class.new(account: account, cookies: cookies).perform
+
+ expect(account.reload.internal_attributes['manually_managed_features']).to eq(['inbound_emails'])
+ end
+
+ it 'ignores parsed cookies that are not populated attribution objects' do
+ account.update!(
+ internal_attributes: {
+ 'marketing_attribution' => {
+ 'first_touch' => { 'source' => 'reddit' },
+ 'last_touch' => { 'source' => 'github' }
+ }
+ }
+ )
+ cookies[described_class::FIRST_TOUCH_COOKIE] = {}.to_json
+ cookies[described_class::LAST_TOUCH_COOKIE] = [].to_json
+
+ described_class.new(account: account, cookies: cookies).perform
+
+ attribution = account.reload.internal_attributes['marketing_attribution']
+ expect(attribution['first_touch']['source']).to eq('reddit')
+ expect(attribution['last_touch']['source']).to eq('github')
+ end
+
+ it 'stores only allowlisted scalar attribution fields' do
+ cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie(
+ 'source' => 'google',
+ 'source_type' => 'paid_search',
+ 'utm_campaign' => 'spring',
+ 'unknown_field' => 'ignore me',
+ 'nested' => { 'value' => 'ignore me' },
+ 'array' => ['ignore me']
+ )
+
+ described_class.new(account: account, cookies: cookies).perform
+
+ attribution = account.reload.internal_attributes['marketing_attribution']
+ expect(attribution['last_touch']).to eq(
+ 'source' => 'google',
+ 'source_type' => 'paid_search',
+ 'utm_campaign' => 'spring'
+ )
+ end
+
+ it 'truncates oversized attribution values' do
+ cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie(
+ 'source' => 'google',
+ 'utm_campaign' => 'a' * 600
+ )
+
+ described_class.new(account: account, cookies: cookies).perform
+
+ attribution = account.reload.internal_attributes['marketing_attribution']
+ expect(attribution['last_touch']['utm_campaign'].length).to eq(described_class::FIELD_MAX_LENGTH)
+ end
+
+ it 'stores raw attribution values without escaping them' do
+ cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie(
+ 'source' => '',
+ 'utm_campaign' => 'launch & learn'
+ )
+
+ described_class.new(account: account, cookies: cookies).perform
+
+ attribution = account.reload.internal_attributes['marketing_attribution']
+ expect(attribution['last_touch']['source']).to eq('')
+ expect(attribution['last_touch']['utm_campaign']).to eq('launch & learn')
+ end
+
+ it 'caps raw attribution values' do
+ cookies[described_class::LAST_TOUCH_COOKIE] = encoded_cookie(
+ 'source' => 'google',
+ 'utm_campaign' => '&' * 600
+ )
+
+ described_class.new(account: account, cookies: cookies).perform
+
+ attribution = account.reload.internal_attributes['marketing_attribution']
+ expect(attribution['last_touch']['utm_campaign'].length).to eq(described_class::FIELD_MAX_LENGTH)
+ end
+
+ def encoded_cookie(payload)
+ Base64.urlsafe_encode64(payload.to_json, padding: false)
+ end
+end