Rutba CRM

Google Ads

Tag Google Ads clicks with their campaign, send daily ad costs, and upload the sales that came from Google clicks as offline conversions.

Connect Google Ads in two parts. First, one account setting adds the campaign name to every ad click, so visits, enquiries and store orders carry it. Second, a small Google Ads script sends each campaign's daily cost to Rutba CRM, and uploads the sales that came from a Google click back to Google. Campaign returns then shows, for each campaign, what it cost and what it brought back, and Google's bidding learns from real buyers. What the uploads mean for your ads is explained in Advertising.

Before you start

  • Who can do it: an owner or administrator of the company, who also manages the Google Ads account.
  • Plan: available when your plan includes Campaign returns and API access.
  • Orders that carry campaigns: turn on Order Attribution in WooCommerce (see WooCommerce). Orders and sales are credited to a campaign only from the order's own attribution. The tracking snippet adds the Visitors figure, but does not credit orders.
  • Currency: the Google Ads account's currency must be USD, EUR, GBP, TRY, SAR or AED. For a return to be calculated, it must also be the currency of your sales.

The easy way: Connect with Google

When it is available for your platform, the Google Ads card on the Advertising page shows Connect with Google. It replaces every step below.

  1. Click Connect with Google and sign in with the Google account that owns your ads. Google asks you to allow access to your Google Ads: click Allow.
  2. Under Which Google Ads account?, choose the account. Accounts you reach through a manager account are marked through a manager account.
  3. Under Where should sales be counted?, click Make them for me: Rutba CRM creates the conversion actions CRM purchase and CRM lead in your account. Or pick actions you already have.

That is all. Rutba CRM then tags every campaign with its name, receives each campaign's cost every night, and sends your sales with a Google click every hour. The card shows the account, when costs last arrived and when sales were last sent. If Google refuses a sale, for example because the click is too old, it waits with Google's reason and is tried again.

If you withdraw the access in your Google account, the card shows Stopped and the owners and administrators get an alert; click Connect again. Disconnect removes the access at Google too; costs and sales already received stay. While you are connected this way, the script below receives no sales, so nothing is sent twice.

If Google does not let Rutba CRM tag the account, the card shows the final URL suffix to paste yourself, with a Copy button.

Or use the script

The steps below set up the Google Ads script instead. Use them when Connect with Google is not offered, or if you prefer the script.

Step 1: create an API key for the script

  1. In the sidebar open Contacts, then click API keys.
  2. Click Create key, name it "Google Ads", and choose Read and write as Access.
  3. Copy the key now: it is shown only once.

The key acts as you, with your role, so create it while signed in as an owner or administrator. Keep it only in the script: anyone who has it can write ad costs for your company and read the sales waiting for Google. More about keys is in API keys.

Step 2: tag every click with its campaign

  1. In Google Ads open Admin, then Account settings, then Tracking.
  2. Leave Auto-tagging on.
  3. In Final URL suffix, enter:
utm_source=google&utm_medium=cpc&utm_campaign={_campaign}
  1. Save.

{_campaign} is a custom parameter that the script in step 3 gives every campaign: a clean version of the campaign's name. Until the script has run once, it is empty.

Step 3: add the script

  1. In Google Ads open Tools, then Bulk actions, then Scripts, and click + to add a new script.
  2. Delete the sample code and paste the script below.
  3. Replace PASTE_THE_WRITE_API_KEY_HERE with the key from step 1. Type or paste it yourself; never send it in a chat or email.
  4. Click Authorize and allow access to your Google Ads account.
  5. Click Run. When it finishes, open the logs.
  6. Set the script's frequency to Daily.

If you installed an earlier version of the script, replace its code with this one: only this version uploads sales. Keep your key in it.

/**
 * Rutba CRM — Google Ads script.
 *
 * Put it in Google Ads: Tools > Bulk actions > Scripts > + > paste, authorise, then schedule it "Daily".
 * It does three things, and changes nothing else in the account:
 *   1. Gives every campaign a custom parameter "campaign" with a clean name, so the account's final URL
 *      suffix  utm_source=google&utm_medium=cpc&utm_campaign={_campaign}  tags every visit and order.
 *   2. Sends each campaign's cost for the last 30 days to the CRM, so campaign returns show cost and sales
 *      side by side. Sending a day again replaces it; nothing is counted twice.
 *   3. Uploads the sales that came from a Google click as offline conversions, when the CRM's Advertising page
 *      has "Send sales to Google Ads" on, so Google's bidding learns from real buyers.
 *
 * Fill in the two values below. The key is an API key with write access, created by an owner or admin in the
 * CRM under API keys. Keep it only here: anyone with it can write ad spend for your company.
 */
var CRM_URL = 'https://rutbacrm.com/api/v1/ad-spend';
var API_KEY = 'PASTE_THE_WRITE_API_KEY_HERE';
var DAYS = 'LAST_30_DAYS';

function clean(name) {
  return String(name).toLowerCase().replace(/[^a-z0-9؀-ۿ]+/g, '-').replace(/^-+|-+$/g, '').substring(0, 100) || 'campaign';
}

function tag(iterator) {
  var changed = 0;
  while (iterator.hasNext()) {
    var campaign = iterator.next();
    var params = campaign.urls().getCustomParameters() || {};
    var value = clean(campaign.getName());
    if (params.campaign !== value) {
      params.campaign = value;
      campaign.urls().setCustomParameters(params);
      changed++;
    }
  }
  return changed;
}

function main() {
  sendCosts();
  uploadSales();
}

// Sales and leads that came from a Google click, uploaded as offline conversions so smart bidding learns from them.
// Nothing happens until "Send sales to Google Ads" is on in the CRM's Advertising page.
function uploadSales() {
  var base = CRM_URL.replace(/\/ad-spend$/, '');
  var answer = UrlFetchApp.fetch(base + '/ad-conversions', { method: 'get', muteHttpExceptions: true, headers: { Authorization: 'Bearer ' + API_KEY, Accept: 'application/json' } });
  if (answer.getResponseCode() !== 200) {
    Logger.log('Sales: CRM answered ' + answer.getResponseCode() + ': ' + answer.getContentText());
    return;
  }
  var sales = JSON.parse(answer.getContentText()).data || [];
  if (sales.length === 0) {
    Logger.log('Sales: nothing new to upload.');
    return;
  }
  var upload = AdsApp.bulkUploads().newCsvUpload(['Google Click ID', 'Conversion Name', 'Conversion Time', 'Conversion Value', 'Conversion Currency'], { moneyInMicros: false, timeZone: 'Etc/GMT' });
  upload.forOfflineConversions();
  sales.forEach(function (sale) {
    upload.append({ 'Google Click ID': sale.gclid, 'Conversion Name': sale.name, 'Conversion Time': sale.time, 'Conversion Value': sale.value || '', 'Conversion Currency': sale.currency || '' });
  });
  upload.apply();
  var done = UrlFetchApp.fetch(base + '/ad-conversions/confirm', {
    method: 'post', contentType: 'application/json', muteHttpExceptions: true,
    headers: { Authorization: 'Bearer ' + API_KEY }, payload: JSON.stringify({ ids: sales.map(function (sale) { return sale.id; }) })
  });
  Logger.log('Sales: uploaded ' + sales.length + ' to Google. CRM answered ' + done.getResponseCode() + ': ' + done.getContentText());
}

function sendCosts() {
  var currency = AdsApp.currentAccount().getCurrencyCode();
  var tagged = tag(AdsApp.campaigns().withCondition('campaign.status != REMOVED').get());
  if (AdsApp.performanceMaxCampaigns) {
    tagged += tag(AdsApp.performanceMaxCampaigns().withCondition('campaign.status != REMOVED').get());
  }
  if (AdsApp.shoppingCampaigns) {
    tagged += tag(AdsApp.shoppingCampaigns().withCondition('campaign.status != REMOVED').get());
  }

  var rows = [];
  var report = AdsApp.search('SELECT campaign.name, segments.date, metrics.cost_micros FROM campaign WHERE segments.date DURING ' + DAYS + ' AND metrics.cost_micros > 0');
  while (report.hasNext()) {
    var row = report.next();
    rows.push({ campaign: clean(row.campaign.name), source: 'google', spent_on: row.segments.date, amount: (Number(row.metrics.costMicros) / 1000000).toFixed(2), currency: currency });
  }
  if (rows.length === 0) {
    Logger.log('Tagged ' + tagged + ' campaigns. No cost to send.');
    return;
  }
  Logger.log('Tagged ' + tagged + ' campaigns. Sending ' + rows.length + ' rows of daily cost.');
  for (var i = 0; i < rows.length; i += 1000) {
    var response = UrlFetchApp.fetch(CRM_URL, {
      method: 'post', contentType: 'application/json', muteHttpExceptions: true,
      headers: { Authorization: 'Bearer ' + API_KEY }, payload: JSON.stringify({ rows: rows.slice(i, i + 1000) })
    });
    Logger.log('CRM answered ' + response.getResponseCode() + ': ' + response.getContentText());
  }
}

What the script does

  • Names campaigns cleanly. Every campaign that is not removed, including Performance Max and Shopping, gets the custom parameter campaign: its name in lower case, with spaces and symbols turned into hyphens, up to 100 characters. "Autumn Sale 2026" becomes autumn-sale-2026. Arabic letters are kept.
  • Sends cost. For the last 30 days, it sends each campaign's cost for each day with a cost. Sending the same day again replaces it, so nothing is counted twice and corrections in Google Ads come through.
  • Uploads sales. It asks Rutba CRM for the sales and leads that came from a Google click and have not been uploaded, uploads them to Google as offline conversions, then confirms them so they are not offered again. Nothing is uploaded until Send sales to Google Ads is on; see the next section.

Step 4: check it works

  1. In the script's logs, look for CRM answered 200, followed by how many rows were saved and how many were unchanged. An account with many campaigns sends its costs in several parts of up to 1000 rows, each with its own line.
  2. In Rutba CRM, open Reports, then Campaign returns. Each campaign shows its Cost next to Orders, Sales, Return and Cost per order.
  3. Open your site from one of your ads, or with ?utm_campaign= and a campaign's clean name, and check that the visit or order carries that campaign.

Upload sales as offline conversions

The same script can tell Google which clicks became sales, so smart bidding spends your budget on searches that bring buyers.

  1. In Google Ads open Goals, then Conversions, then New conversion action, then Import, then CRM or other files, and choose conversions from clicks. Name it, for example "CRM purchase".
  2. In Rutba CRM, open Advertising in the sidebar. On the Google Ads card, write that exact name in Conversion name for sales, tick Send sales to Google Ads and select Save. A second conversion action for leads, in Conversion name for leads, is optional.
  3. Make sure your site has the tracking snippet, so the Google click is recorded when a visitor arrives from an ad.
  4. Use the script on this page, scheduled Daily.

Each daily run:

  1. Fetches up to 500 waiting sales from https://rutbacrm.com/api/v1/ad-conversions, oldest first: the Google click identifier (gclid), the conversion name, the time in UTC, and for a sale its value and currency.
  2. Uploads them to Google as one offline conversion file, and logs Sales: uploaded N to Google.
  3. Confirms them at https://rutbacrm.com/api/v1/ad-conversions/confirm, so each sale is uploaded once. The log line ends with CRM answered 200 and the number confirmed.

The conversion name in Rutba CRM must match the conversion action in Google Ads character for character. Only sales whose Google click was at most 90 days earlier are offered, and only clicks with a gclid. The Google Ads card on the Advertising page shows Uploaded: N · Waiting for the script: N and the date of the last upload. The endpoints are described in Ad conversions API.

How returns are calculated

Figure Meaning
Visitors Visitors who came first from the campaign, when tracking is on
Orders and Sales Store orders with a sale status, by order date
New customers Customers whose first order was in the period
Cost The costs recorded for the campaign in the period
Return Sales divided by cost, such as 3.2×. It is revenue, not profit
Cost per order Cost divided by orders
  • Campaigns are matched by name, ignoring capital letters and spaces at the ends. That is why the script and the suffix use the same clean name.
  • Each currency is a separate row; there is no conversion. If the cost is in one currency and the sales in another, Return shows "—" rather than zero.
  • Orders without a campaign appear under No campaign, so the totals stay honest.
  • Choose 30, 90 or 365 days at the top of the page.

Other ways to add costs

  • By hand: on Campaign returns, owners and administrators can use Add campaign cost, writing the campaign exactly as in utm_campaign. Void takes a wrong entry out of the totals and off Recent costs. It is kept on record, not deleted.
  • From another tool: send POST https://rutbacrm.com/api/v1/ad-spend with the header Authorization: Bearer YOUR_API_KEY and a JSON body {"rows": [...]} of 1 to 1000 rows. Each row has campaign (up to 120 characters), an optional source, spent_on as YYYY-MM-DD (not in the future, no older than 399 days), amount with at most two decimals, and currency. A row for the same campaign, day, source and currency replaces the earlier one, and an amount of 0 removes it. The answer gives the number of rows saved and unchanged. If any row is invalid, nothing is saved. See REST API.

Troubleshooting

In the script's logs What to do
CRM answered 401 The key is wrong or was revoked. Create a new one and paste it again
CRM answered 403 The key is read only, its creator is not an owner or administrator, a custom role blocks API access or Campaign returns for its creator, or the plan does not include Campaign returns or API access
CRM answered 422 A row was refused, usually because the account's currency is not supported
No cost to send. No campaign had any cost in the last 30 days. Sales are still uploaded in the same run
Sales: nothing new to upload. No sale from a Google click is waiting, or Send sales to Google Ads is off
Sales: CRM answered 403 The key's creator is not an owner or administrator (Only owners and administrators send results to ad platforms.), a custom role blocks Campaign returns for them, or the plan does not include Campaign returns
No line starting with Sales: The script is an older version. Replace it with the one on this page
Costs show but sales are under No campaign Check the Final URL suffix, and that WooCommerce Order Attribution is on. The tracking snippet does not credit orders to campaigns

Last updated 2026-11-08