Conversion Tracking Implementation Plan

Goal: Own the conversion pipeline and push to Google Ads in real-time

Benefits:


Architecture

┌─────────────────┐     ┌─────────────────┐     ┌──────────────────┐
│  WhatConverts   │────►│    Webhook      │────►│                  │
│  (Phone Calls)  │     │  /webhooks/     │     │   Conversion     │
└─────────────────┘     │  whatconverts   │     │   Model          │
                        └─────────────────┘     │                  │
┌─────────────────┐     ┌─────────────────┐     │  - gclid         │
│  Crisp Chat     │────►│    Webhook      │────►│  - source        │
│                 │     │  /webhooks/     │     │  - contact_info  │
└─────────────────┘     │  crisp          │     │  - attribution   │
                        └─────────────────┘     │  - value         │
                                                └────────┬─────────┘
                                                         │
                                                         │ Real-time
                                                         ▼
                                                ┌──────────────────┐
                                                │  Google Ads      │
                                                │  Conversion API  │
                                                │                  │
                                                │  - Upload gclid  │
                                                │  - Send value    │
                                                │  - Custom vars   │
                                                └──────────────────┘

Phase 1: Data Model

Migration

# db/migrate/XXXXXX_create_conversions.rb
class CreateConversions < ActiveRecord::Migration[8.0]
  def change
    create_table :conversions do |t|
      # Source identification
      t.string :source, null: false              # 'whatconverts', 'crisp', 'web_form'
      t.string :source_id                        # External ID from source system
      t.string :lead_type                        # 'phone_call', 'chat', 'form'

      # Google Ads attribution (critical for upload)
      t.string :gclid                            # Google Click ID
      t.string :gbraid                           # Google App campaign click ID
      t.string :wbraid                           # Google Web-to-App click ID
      t.string :msclkid                          # Microsoft Ads click ID (future)

      # UTM attribution
      t.string :utm_source
      t.string :utm_medium
      t.string :utm_campaign
      t.string :utm_content
      t.string :utm_term

      # Contact information
      t.string :contact_name
      t.string :contact_email
      t.string :contact_phone
      t.string :contact_company

      # Conversion details
      t.text :message                            # Chat transcript or call notes
      t.string :landing_page
      t.string :referrer
      t.string :ip_address
      t.string :user_agent

      # Business enrichment
      t.decimal :conversion_value, precision: 10, scale: 2
      t.string :product_type                     # 'dumpster', 'mobile_office', 'storage'
      t.string :product_size                     # '20yd', '40ft', etc.
      t.references :order, foreign_key: true     # Link to actual order
      t.references :quote, foreign_key: true     # Link to quote
      t.references :contact, foreign_key: true   # Link to contact record
      t.references :company, foreign_key: true   # Link to company record

      # Google Ads push tracking
      t.datetime :google_ads_pushed_at
      t.string :google_ads_status                # 'pending', 'pushed', 'failed', 'skipped'
      t.jsonb :google_ads_response, default: {}
      t.integer :google_ads_push_attempts, default: 0
      t.text :google_ads_error

      # Raw webhook data for debugging
      t.jsonb :raw_webhook_data, default: {}

      t.timestamps
    end

    add_index :conversions, :gclid
    add_index :conversions, :source
    add_index :conversions, :source_id
    add_index :conversions, :lead_type
    add_index :conversions, :google_ads_status
    add_index :conversions, :created_at
    add_index :conversions, [:source, :source_id], unique: true
  end
end

Model

# app/models/conversion.rb
class Conversion < ApplicationRecord
  # Associations
  belongs_to :order, optional: true
  belongs_to :quote, optional: true
  belongs_to :contact, optional: true
  belongs_to :company, optional: true

  # Enums
  enum :source, {
    whatconverts: 'whatconverts',
    crisp: 'crisp',
    web_form: 'web_form',
    manual: 'manual'
  }, prefix: true

  enum :lead_type, {
    phone_call: 'phone_call',
    chat: 'chat',
    form_submission: 'form_submission',
    email: 'email'
  }, prefix: true

  enum :google_ads_status, {
    pending: 'pending',
    pushed: 'pushed',
    failed: 'failed',
    skipped: 'skipped'  # No gclid, can't attribute
  }, prefix: :google_ads

  # Validations
  validates :source, presence: true
  validates :source_id, uniqueness: { scope: :source }, allow_nil: true

  # Scopes
  scope :with_gclid, -> { where.not(gclid: [nil, '']) }
  scope :pending_google_push, -> { google_ads_pending.with_gclid }
  scope :recent, -> { order(created_at: :desc) }
  scope :today, -> { where(created_at: Time.current.all_day) }
  scope :this_week, -> { where(created_at: Time.current.all_week) }
  scope :this_month, -> { where(created_at: Time.current.all_month) }

  # Callbacks
  after_create_commit :push_to_google_ads_async
  after_create_commit :attempt_contact_match

  # Check if this conversion can be pushed to Google Ads
  def pushable_to_google?
    gclid.present? && google_ads_pending?
  end

  # Mark as pushed
  def mark_pushed!(response = {})
    update!(
      google_ads_status: :pushed,
      google_ads_pushed_at: Time.current,
      google_ads_response: response
    )
  end

  # Mark as failed
  def mark_failed!(error)
    increment!(:google_ads_push_attempts)
    update!(
      google_ads_status: :failed,
      google_ads_error: error
    )
  end

  # Skip if no gclid
  def mark_skipped!(reason = 'no_gclid')
    update!(
      google_ads_status: :skipped,
      google_ads_error: reason
    )
  end

  private

  def push_to_google_ads_async
    return mark_skipped! unless gclid.present?

    PushConversionToGoogleAdsJob.perform_later(id)
  end

  def attempt_contact_match
    MatchConversionToContactJob.perform_later(id)
  end
end

Phase 2: Webhooks

WhatConverts Webhook Controller

# app/controllers/webhooks/whatconverts_controller.rb
class Webhooks::WhatconvertsController < ApplicationController
  skip_before_action :require_authentication
  skip_before_action :verify_authenticity_token

  # POST /webhooks/whatconverts
  def create
    Rails.logger.info("WhatConverts webhook received")

    # WhatConverts sends different event types
    lead_data = extract_lead_data(params)

    conversion = Conversion.create!(
      source: :whatconverts,
      source_id: lead_data[:lead_id],
      lead_type: map_lead_type(lead_data[:lead_type]),

      # Attribution
      gclid: lead_data[:gclid],
      utm_source: lead_data[:source],
      utm_medium: lead_data[:medium],
      utm_campaign: lead_data[:campaign],
      utm_content: lead_data[:content],
      utm_term: lead_data[:keyword],

      # Contact
      contact_name: lead_data[:contact_name],
      contact_email: lead_data[:contact_email],
      contact_phone: lead_data[:contact_phone],
      contact_company: lead_data[:company_name],

      # Details
      landing_page: lead_data[:landing_page],
      ip_address: lead_data[:ip_address],
      message: lead_data[:additional_info],

      # Raw data for debugging
      raw_webhook_data: params.to_unsafe_h
    )

    Rails.logger.info("Conversion created: #{conversion.id} (gclid: #{conversion.gclid || 'none'})")

    head :ok
  rescue ActiveRecord::RecordNotUnique
    # Duplicate webhook, ignore
    Rails.logger.info("Duplicate WhatConverts webhook ignored: #{params[:lead_id]}")
    head :ok
  rescue StandardError => e
    Rails.logger.error("WhatConverts webhook error: #{e.message}")
    Rails.logger.error(e.backtrace.first(5).join("\n"))
    head :ok # Return 200 to avoid retries
  end

  private

  def extract_lead_data(params)
    {
      lead_id: params[:lead_id] || params[:id],
      lead_type: params[:lead_type],

      # Attribution - WhatConverts field names
      gclid: params[:gclid] || params[:click_id],
      source: params[:lead_source] || params[:source],
      medium: params[:lead_medium] || params[:medium],
      campaign: params[:lead_campaign] || params[:campaign],
      content: params[:lead_content] || params[:content],
      keyword: params[:lead_keyword] || params[:keyword],

      # Contact
      contact_name: params[:contact_name] || params[:name],
      contact_email: params[:contact_email_address] || params[:email],
      contact_phone: params[:contact_phone_number] || params[:phone],
      company_name: params[:company_name] || params[:company],

      # Details
      landing_page: params[:landing_url] || params[:landing_page],
      ip_address: params[:ip_address],
      additional_info: params[:additional_fields]&.to_json || params[:notes]
    }
  end

  def map_lead_type(whatconverts_type)
    case whatconverts_type&.downcase
    when 'phone call', 'phone', 'call'
      :phone_call
    when 'chat'
      :chat
    when 'web form', 'form'
      :form_submission
    else
      :phone_call # Default
    end
  end
end

Update Crisp Webhook Controller

# app/controllers/webhooks/crisp_controller.rb
# Update process_message_event to create Conversion record

def process_message_event(data)
  return unless data && data["origin"] == "chat"

  session_id = data.dig("session_id")
  session_data = data.dig("session", "data") || {}

  # Extract visitor info
  visitor_email = data.dig("user", "email")
  visitor_nickname = data.dig("user", "nickname")
  message_content = data.dig("content")

  # Create conversion record (this triggers Google Ads push)
  conversion = Conversion.create!(
    source: :crisp,
    source_id: session_id,
    lead_type: :chat,

    # Attribution from session data
    gclid: session_data["gclid"],
    utm_source: session_data["source"],
    utm_medium: session_data["medium"],
    utm_campaign: session_data["campaign"],
    utm_content: session_data["content"],
    utm_term: session_data["keyword"],

    # Contact
    contact_name: visitor_nickname,
    contact_email: visitor_email,

    # Details
    message: message_content,
    landing_page: session_data["landing_page"],

    # Raw data
    raw_webhook_data: data
  )

  Rails.logger.info("Crisp conversion created: #{conversion.id} (gclid: #{conversion.gclid || 'none'})")

  # Still send to WhatConverts for their tracking (optional, can remove later)
  # WhatConvertsService.new.create_lead(lead_data)

rescue ActiveRecord::RecordNotUnique
  Rails.logger.info("Duplicate Crisp webhook ignored: #{session_id}")
rescue StandardError => e
  Rails.logger.error("Error processing Crisp message: #{e.message}")
end

Phase 3: Google Ads Conversion Upload Service

# app/services/google_ads_conversion_upload_service.rb
class GoogleAdsConversionUploadService
  class UploadError < StandardError; end

  def initialize
    @oauth_credential = OauthCredential.for_provider_and_user(
      provider: "google_ads",
      user_id: "default"
    )

    raise UploadError, "No OAuth credentials for Google Ads" unless @oauth_credential&.refresh_token.present?
  end

  # Upload a single conversion
  def upload_conversion(conversion)
    return { success: false, error: "No gclid" } unless conversion.gclid.present?

    click_conversion = build_click_conversion(conversion)

    response = client.service.conversion_upload.upload_click_conversions(
      customer_id: customer_id,
      conversions: [click_conversion],
      partial_failure: true
    )

    handle_response(response, conversion)
  rescue Google::Ads::GoogleAds::Errors::GoogleAdsError => e
    error_msg = extract_error_message(e)
    Rails.logger.error("Google Ads conversion upload failed: #{error_msg}")
    { success: false, error: error_msg }
  rescue StandardError => e
    Rails.logger.error("Conversion upload error: #{e.message}")
    { success: false, error: e.message }
  end

  # Batch upload multiple conversions
  def upload_conversions(conversions)
    return [] if conversions.empty?

    click_conversions = conversions.map { |c| build_click_conversion(c) }

    response = client.service.conversion_upload.upload_click_conversions(
      customer_id: customer_id,
      conversions: click_conversions,
      partial_failure: true
    )

    process_batch_response(response, conversions)
  end

  private

  def client
    @client ||= Google::Ads::GoogleAds::GoogleAdsClient.new do |config|
      config.client_id = ENV.fetch("GOOGLE_OAUTH_CLIENT_ID")
      config.client_secret = ENV.fetch("GOOGLE_OAUTH_CLIENT_SECRET")
      config.developer_token = ENV.fetch("GOOGLE_ADS_DEVELOPER_TOKEN")
      config.refresh_token = @oauth_credential.refresh_token
      if ENV["GOOGLE_ADS_LOGIN_CUSTOMER_ID"].present?
        config.login_customer_id = ENV["GOOGLE_ADS_LOGIN_CUSTOMER_ID"].delete("-")
      end
    end
  end

  def customer_id
    @customer_id ||= ENV.fetch("GOOGLE_ADS_CUSTOMER_ID").delete("-")
  end

  def conversion_action_resource
    # This needs to be created in Google Ads first
    # Format: customers/{customer_id}/conversionActions/{conversion_action_id}
    @conversion_action_resource ||= ENV.fetch(
      "GOOGLE_ADS_CONVERSION_ACTION",
      "customers/#{customer_id}/conversionActions/#{conversion_action_id}"
    )
  end

  def conversion_action_id
    # Get this from Google Ads > Goals > Conversions > click on your conversion > ID in URL
    ENV.fetch("GOOGLE_ADS_CONVERSION_ACTION_ID")
  end

  def build_click_conversion(conversion)
    client.resource.click_conversion do |cc|
      cc.conversion_action = conversion_action_resource
      cc.gclid = conversion.gclid
      cc.conversion_date_time = format_datetime(conversion.created_at)

      # Add conversion value if available
      if conversion.conversion_value.present?
        cc.conversion_value = conversion.conversion_value.to_f
        cc.currency_code = "USD"
      end

      # Add order ID for deduplication
      if conversion.order_id.present?
        cc.order_id = "order_#{conversion.order_id}"
      elsif conversion.source_id.present?
        cc.order_id = "#{conversion.source}_#{conversion.source_id}"
      end

      # Custom variables (if you've set these up in Google Ads)
      # cc.custom_variables << build_custom_variable("product_type", conversion.product_type)
    end
  end

  def format_datetime(time)
    # Google Ads requires format: yyyy-mm-dd hh:mm:ss+|-hh:mm
    time.strftime("%Y-%m-%d %H:%M:%S%:z")
  end

  def handle_response(response, conversion)
    if response.partial_failure_error.nil?
      Rails.logger.info("Conversion #{conversion.id} uploaded successfully")
      { success: true, response: response.to_h }
    else
      error_msg = response.partial_failure_error.message
      Rails.logger.error("Partial failure uploading conversion #{conversion.id}: #{error_msg}")
      { success: false, error: error_msg }
    end
  end

  def process_batch_response(response, conversions)
    results = []

    conversions.each_with_index do |conversion, index|
      if response.partial_failure_error.nil?
        results << { conversion_id: conversion.id, success: true }
      else
        # Check if this specific conversion failed
        error = extract_error_for_index(response.partial_failure_error, index)
        results << { conversion_id: conversion.id, success: error.nil?, error: error }
      end
    end

    results
  end

  def extract_error_message(exception)
    if exception.respond_to?(:failure) && exception.failure.respond_to?(:errors)
      exception.failure.errors.map(&:message).join("; ")
    else
      exception.message
    end
  end

  def extract_error_for_index(partial_failure_error, index)
    # Parse partial failure error details
    # This is simplified - actual implementation may need more detail
    partial_failure_error.details.find { |d| d.index == index }&.message
  end
end

Phase 4: Background Job for Real-time Push

# app/jobs/push_conversion_to_google_ads_job.rb
class PushConversionToGoogleAdsJob < ApplicationJob
  queue_as :default

  # Retry with exponential backoff
  retry_on StandardError, wait: :polynomially_longer, attempts: 3

  def perform(conversion_id)
    conversion = Conversion.find(conversion_id)

    return if conversion.google_ads_pushed?
    return conversion.mark_skipped!("no_gclid") unless conversion.gclid.present?

    service = GoogleAdsConversionUploadService.new
    result = service.upload_conversion(conversion)

    if result[:success]
      conversion.mark_pushed!(result[:response])
      Rails.logger.info("Conversion #{conversion_id} pushed to Google Ads")
    else
      conversion.mark_failed!(result[:error])
      Rails.logger.error("Failed to push conversion #{conversion_id}: #{result[:error]}")
    end
  rescue GoogleAdsConversionUploadService::UploadError => e
    conversion.mark_failed!(e.message)
    raise # Re-raise for job retry
  end
end

Phase 5: Contact/Order Matching (Enrichment)

# app/jobs/match_conversion_to_contact_job.rb
class MatchConversionToContactJob < ApplicationJob
  queue_as :low

  def perform(conversion_id)
    conversion = Conversion.find(conversion_id)

    # Try to find matching contact
    contact = find_matching_contact(conversion)

    if contact
      conversion.update!(
        contact: contact,
        company: contact.company
      )

      # If contact has recent orders, link and add value
      recent_order = contact.orders.where(created_at: 30.days.ago..).order(created_at: :desc).first
      if recent_order
        conversion.update!(
          order: recent_order,
          conversion_value: recent_order.total,
          product_type: determine_product_type(recent_order)
        )

        # Re-push with value if already pushed
        if conversion.google_ads_pushed?
          RepushConversionWithValueJob.perform_later(conversion_id)
        end
      end
    end
  end

  private

  def find_matching_contact(conversion)
    return nil unless conversion.contact_email.present? || conversion.contact_phone.present?

    Contact.find_by(email: conversion.contact_email) ||
      Contact.find_by(phone: normalize_phone(conversion.contact_phone))
  end

  def normalize_phone(phone)
    phone&.gsub(/\D/, '')&.last(10)
  end

  def determine_product_type(order)
    # Based on your order structure
    case order.rental_type
    when /dumpster/i then 'dumpster'
    when /office/i then 'mobile_office'
    when /container|storage/i then 'storage'
    end
  end
end

Phase 6: Routes

# config/routes.rb
namespace :webhooks do
  post "crisp", to: "crisp#create"
  post "whatconverts", to: "whatconverts#create"
end

Phase 7: Admin Dashboard

# app/controllers/admin/conversions_controller.rb
class Admin::ConversionsController < AdminController
  def index
    @conversions = Conversion.recent.includes(:contact, :company, :order)

    # Filters
    @conversions = @conversions.where(source: params[:source]) if params[:source].present?
    @conversions = @conversions.where(google_ads_status: params[:status]) if params[:status].present?
    @conversions = @conversions.where(created_at: date_range) if params[:date_range].present?

    @conversions = @conversions.page(params[:page]).per(50)

    # Stats
    @stats = {
      today: Conversion.today.count,
      this_week: Conversion.this_week.count,
      this_month: Conversion.this_month.count,
      pending_push: Conversion.google_ads_pending.count,
      failed_push: Conversion.google_ads_failed.count,
      with_gclid: Conversion.with_gclid.this_month.count,
      attribution_rate: calculate_attribution_rate
    }
  end

  def show
    @conversion = Conversion.find(params[:id])
  end

  def retry_push
    @conversion = Conversion.find(params[:id])
    @conversion.update!(google_ads_status: :pending)
    PushConversionToGoogleAdsJob.perform_later(@conversion.id)

    redirect_to admin_conversion_path(@conversion), notice: "Retry queued"
  end

  private

  def calculate_attribution_rate
    total = Conversion.this_month.count
    return 0 if total.zero?

    (Conversion.with_gclid.this_month.count.to_f / total * 100).round(1)
  end
end

Environment Variables Needed

# Add to .kamal/secrets

# Google Ads Conversion Upload
export GOOGLE_ADS_CONVERSION_ACTION_ID="123456789"  # Get from Google Ads

Setup Steps

1. Create Conversion Action in Google Ads

  1. Go to Google Ads > Goals > Conversions
  2. Click "+ New conversion action"
  3. Select "Import" > "Other data sources" > "Track conversions from clicks"
  4. Name it "Quarry Leads (API)"
  5. Set value = "Use different values for each conversion"
  6. Save and note the Conversion Action ID from the URL

2. Configure WhatConverts Webhook

  1. Go to WhatConverts > Settings > Integrations > Webhooks
  2. Add webhook URL: https://admin.quarryrents.com/webhooks/whatconverts
  3. Select events: New Lead
  4. Test the webhook

3. Update Crisp Webhook

Already configured at /webhooks/crisp, just needs the code update.

4. Deploy and Test

# Run migrations
kamal app exec "bin/rails db:migrate"

# Test webhook endpoints
curl -X POST https://admin.quarryrents.com/webhooks/whatconverts \
  -H "Content-Type: application/json" \
  -d '{"lead_id": "test123", "lead_type": "Phone Call", "gclid": "test_gclid"}'

Monitoring

Daily Checks

  1. Conversion count - Compare WhatConverts leads vs Conversion records
  2. Push success rate - Check google_ads_status distribution
  3. Attribution rate - % of conversions with gclid

Alerts to Set Up

  1. No conversions in 24 hours - Something's wrong
  2. Push failure rate > 10% - API issue
  3. Attribution rate drop > 20% - Tracking script issue

Migration Path

Week 1: Run in Parallel

Week 2: Validate

Week 3: Cut Over


Future Enhancements

  1. Enhanced Conversions - Send hashed email/phone for better matching
  2. Conversion Value Optimization - Track actual order revenue, enable value-based bidding
  3. Offline Conversion Import - For leads that convert to orders days/weeks later
  4. Microsoft Ads - Same pattern, different API
  5. Facebook CAPI - Extend to Facebook conversion tracking