Goal: Own the conversion pipeline and push to Google Ads in real-time
Benefits:
┌─────────────────┐ ┌─────────────────┐ ┌──────────────────┐
│ 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 │
└──────────────────┘
# 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
# 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
# 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
# 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
# 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
# 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
# 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
# config/routes.rb
namespace :webhooks do
post "crisp", to: "crisp#create"
post "whatconverts", to: "whatconverts#create"
end
# 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
# Add to .kamal/secrets
# Google Ads Conversion Upload
export GOOGLE_ADS_CONVERSION_ACTION_ID="123456789" # Get from Google Ads
https://admin.quarryrents.com/webhooks/whatconvertsAlready configured at /webhooks/crisp, just needs the code update.
# 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"}'
google_ads_status distribution