Google Ads Conversion Upload in .NET

Google Ads Conversion Upload in .NET

Conversion tracking is a powerful marketing tool that allows tracking customers' valuable actions after they enter a website from a Google Ad. It helps recognise the best driver for customer actions amongst different ads or campaigns.

There are different ways conversions can be tracked. Google list them in their documentation at Different ways to track conversions. This post focuses on uploading what Google calls "offline conversions" in a .NET application. This can also be done by uploading a spreadsheet/CSV file, and for many use cases, that might be enough. More information can be found at Import conversions from ad clicks into Google Ads.

Even though the documentation for the API is quite comprehensive, some of the documents are a little outdated, and not everything is explained clearly. That's especially true if you are not a Google Ads expert.

OAuth 2.0 credentials

Uploading conversions to Google Ads API requires jumping through several hoops, some smaller than others. The first one is not difficult. It involves creating a project in the Google API Console and generating OAuth 2 Client ID and Client Secret, which is very well described at Configure a Google API Console Project for the Google Ads API.

Once the Client ID and Client Secret are ready, a Refresh Token can be generated. The procedure is described in detail at Generate OAuth2 credentials for a single account. In the guide, the token is retrieved by running a project from the Google Ads API .NET GitHub project. One thing to note here: the guide asks you to add http://127.0.0.1/authorize to the Authorized redirect URIs list in your Google Cloud Console project. If you follow those instructions, you will probably get the 400: redirect_uri_mismatch error.

To fix this, add a trailing slash to the URI:

The Refresh Token should appear in the console window along with the Client ID and Client Secret:

Copy the following content into your App.config file.

<add key = 'OAuth2Mode' value = 'APPLICATION' />
<add key = 'OAuth2ClientId' value = '000000000000-aa00aa00aa00aa00aa00aa00aa00aa00.apps.googleusercontent.com' />
<add key = 'OAuth2ClientSecret' value = 'AAAAAA-a0aaaa00a0aa0AAa0AAaAaa000Aa' />
<add key = 'OAuth2RefreshToken' value = '0//0a0aaaaaAAAaAAaAAAAAAAAaAAaA-A0AaAaAAAaaa0A0aaaAAaAAAaAA0A0aAaaaaA0AA0Aa-_AaAAAa-aAAaA0A0000AAA00a0a' />

But the tool might sometimes output an empty Refresh Token:

Copy the following content into your App.config file.

<add key = 'OAuth2Mode' value = 'APPLICATION' />
<add key = 'OAuth2ClientId' value = '000000000000-aa00aa00aa00aa00aa00aa00aa00aa00.apps.googleusercontent.com' />
<add key = 'OAuth2ClientSecret' value = 'AAAAAA-a0aaaa00a0aa0AAa0AAaAaa000Aa' />
<add key = 'OAuth2RefreshToken' value = '' />

When that happens, a Refresh Token has probably been generated for given credentials in the past. The easiest fix is creating a new set of Client ID and Client Secret and generating the Refresh Token for them. It doesn't seem possible to invalidate the old token and request a fresh one.

When I was working on this a couple of weeks before writing this post, the method described in the link above was not working at all. GitHub shows that the project for generating the Refresh Token had been updated recently, and I was able to run it without any major problems now. If the method is broken again for some unknown reason, I have found another way that is described at Using OAuthTokenGenerator. Of course, the little guide is outdated now as the OAuthTokenGenerator.exe is now a console application, but it still does its job well. The important thing is to answer yes when it prompts Authenticate for AdWords API?.

Developer Token

There is one more credential required - a Developer Token. Obtaining this one can be the longest part of the process as they are approved by Google on a case-by-case basis. In order to request it, a Google Ads manager account is required - keep in mind that this is not the standard account that was used to create the Google Cloud Console project. The procedure is well described at Obtain Your Developer Token. The token will be generated instantly, but before it is usable on a production Google Ads account, the request must be reviewed and granted by Google. Before that, the Developer Token is usable with a test Google Ads account, but as explained at the end of this article, that's not very helpful.

Code

While waiting on the Developer Token, most of the code can be written. First, add Google.Ads.GoogleAds NuGet package to the project. The next thing to do is to configure the client package. Google has an extensive document describing all the different options that can be set to meet the application's needs. It can be found at Configuration. If your project is in .NET Core, the Configuring using settings.json section will probably be the most important. Unfortunately, the documentation does not mention that the GoogleAdsApi node must be at the root of the appsettings.json. Otherwise, no values will be loaded from the file. This means that configuration looking like this:

{
  ...
  "ConversionTracking": {
    "GoogleAdsCustomerId": 1234567890,
    "GoogleAdsApi": {
      "DeveloperToken": "aAaaAA0aaaaAaa0aaAaA0A",
      "OAuth2ClientId": "000000000000-aa00aa00aa00aa00aa00aa00aa00aa00.apps.googleusercontent.com",
      "OAuth2ClientSecret": "AAAAAA-a0aaaa00a0aa0AAa0AAaAaa000Aa",
      "OAuth2RefreshToken": "0//0a0aaaaaAAAaAAaAAAAAAAAaAAaA-A0AaAaAAAaaa0A0aaaAAaAAAaAA0A0aAaaaaA0AA0Aa-_AaAAAa-aAAaA0A0000AAA00a0a"
    }
  },
  ...
}

will not work. The behaviour can be circumvented by loading the values one-by-one in a handy extension method:

public class ConversionTrackingConfiguration
{
    public long? GoogleAdsCustomerId { get; set; }
    
    public GoogleAdsConfig GoogleAdsConfig { get; set; }
}

public static class Extensions
{
    public static IServiceCollection AddConversionConfiguration(this IServiceCollection services, IConfiguration configuration)
    {
        var configSection = configuration.GetSection("ConversionTracking");
        var trackingConfiguration = configSection.Get<ConversionTrackingConfiguration>();

        var googleAdsConfiguration = configSection.GetSection("GoogleAdsApi");
        var googleAdsConfig = new GoogleAdsConfig
        {
            DeveloperToken = googleAdsConfiguration.GetValue<string>("DeveloperToken"),
            OAuth2ClientId = googleAdsConfiguration.GetValue<string>("OAuth2ClientId"),
            OAuth2ClientSecret = googleAdsConfiguration.GetValue<string>("OAuth2ClientSecret"),
            OAuth2RefreshToken = googleAdsConfiguration.GetValue<string>("OAuth2RefreshToken"),
            OAuth2Mode = OAuth2Flow.APPLICATION
        };
        trackingConfiguration.GoogleAdsConfig = googleAdsConfig;
        services.AddSingleton(trackingConfiguration);
        
        return services;
    }
}

Which can later be used in the ConfigureServices method of the Startup class:

services.AddConversionConfiguration(Configuration);

Later it can be injected into the service responsible for uploading the conversions:

public class ConversionTrackingService : IConversionTrackingService
{
    private readonly ConversionUploadServiceClient _conversionUploadServiceClient;
    
    private readonly ILogger<ConversionTrackingService> _logger;

    private readonly long? _googleAdsCustomerId;

    public ConversionTrackingService(
        ConversionTrackingConfiguration configuration,
        ILogger<ConversionTrackingService> logger)
    {
        _logger = logger;

        var googleAdsClient = new GoogleAdsClient(configuration.GoogleAdsConfig);
        _conversionUploadServiceClient = googleAdsClient.GetService(Google.Ads.GoogleAds.Services.V10.ConversionUploadService);
        _googleAdsCustomerId = configuration.GoogleAdsCustomerId;
    }
 
    public async Task TrackGoogleClickId(string trackingCode, double conversionValue, DateTime conversionDateTime, ConversionType conversionType)
    {
        if (!_googleAdsCustomerId.HasValue)
        {
            return;
        }
        
        try
        {
            var conversionActionId = GetGoogleConversionActionId(conversionType);
            var clickConversion = new ClickConversion
            {
                Gclid = trackingCode,
                ConversionValue = conversionValue,
                ConversionDateTime = conversionDateTime.ToString("yyyy-MM-dd HH:mm:sszzz"),
                ConversionAction = ResourceNames.ConversionAction(_googleAdsCustomerId.Value, conversionActionId),
                CurrencyCode = "EUR"
            };
        
            var uploadResult = await _conversionUploadServiceClient.UploadClickConversionsAsync(
                new UploadClickConversionsRequest
                {
                    CustomerId = _googleAdsCustomerId.Value.ToString(),
                    Conversions = { clickConversion },
                    PartialFailure = true,
                    ValidateOnly = false
                });
            
            if (uploadResult.PartialFailureError != null)
            {
                foreach (var error in uploadResult.PartialFailure.Errors)
                {
                    _logger.LogError("Error during click conversion upload: {Error}, gclid: {Gclid}", error, trackingCode);
                }
            }
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to upload click conversion, gclid: {Gclid}", trackingCode);
        }
    }

    private long GetGoogleConversionActionId(ConversionType conversionType) =>
        conversionType switch
        {
            ConversionType.Register => 123456789,
            ConversionType.Purchase => 987654321,
            _ => throw new ArgumentOutOfRangeException(nameof(conversionType), $"Not expected conversionType value: {conversionType}"),
        };
}

There are three things to note here:

  • _googleAdsCustomerId parameter was not mentioned before - it is the ID of the Google Ads Customer to which the conversions should be uploaded. It can be found here after logging in to the Google Ads dashboard (remember to remove the hyphens though):
  • conversionActionId variable is set to a constant number associated with the action that should be tracked. To find it, go to the Tools and Settings menu and choose Conversions:

Then, select the wanted conversion, and the ID will appear in the URL under the ctId parameter:

If you prefer to retrieve the IDs using code, see the Retrieving Conversion Actions IDs programmatically section below.

  • PartialFailureError - skipping checking this property on the uploadResult is definitely not recommended! The upload itself may succeed, but if there are any errors while parsing the conversions, those will appear in this property.

It is a bit of work to get all the pieces in order, but you will become your marketing team's favourite developer!

Problems

Apart from the problems that were described earlier, there are two more issues to be mentioned:

Testing

The solution cannot be thoroughly tested without production data. You can test the upload mechanism itself but will need to wait for a real upload to happen to spot any real issues. The problem is that you cannot get real GCLIDs with the test account, and when using a made-up one, you will get an error like this:

{
    "errorCode": {
        "conversionUploadError": "UNPARSEABLE_GCLID"
    },
    "message": "This Google Click ID could not be decoded.",
    "trigger": {
        "stringValue": "test"
    },
    "location": {
        "fieldPathElements": [
            {
                "fieldName": "conversions",
                "index": 0
            },
            {
                "fieldName": "gclid"
            }
        ]
    }
}

According to this Google Groups thread, generating dummy GCLIDs might be possible in the future.

TOO_RECENT_EVENT

This was the most surprising error during the whole time of developing this feature. You might get an error like this:

{
    "errorCode": {
        "conversionUploadError": "TOO_RECENT_EVENT"
    },
    "message": "The click associated with the given identifier or iOS URL parameter occurred less than 6 hours ago, please retry after 6 hours have passed.",
    "trigger": {
        "stringValue": "CjwKCAjw6dmSBhBkEiwA_W-EoAxykvsOY8i48oEbr3U147yZsYfUk4Zu_ZjwCKcZOQcp9aYwidIrOxoC-tkQAvD_BwE"
    },
    "location": {
        "fieldPathElements": [
            {
                "fieldName": "conversions",
                "index": 0
            },
            {
                "fieldName": "gclid"
            }
        ]
    }
}

The error message explained well what went wrong, but it was still a bit baffling. It seems I was not the only one surprised by that. To overcome that, you can use a tool like Hangfire to delay sending the conversion by 6 hours - the important thing is to have the  ConversionDateTime property on the ClickConversion instance set to the time when the conversion actually happened. You can do a deep dive into all the things that can go wrong at ConversionUploadError.

Unhandled Exception: System.IO.FileNotFoundException:  Error loading native library. Not found in any of the possible locations...

If your application is running on .NET 6+ and your environment is an M1-powered Mac you might get this very unpleasant error:

Unhandled Exception: System.IO.FileNotFoundException:
  Error loading native library. Not found in any of the possible locations: [...]
   at Grpc.Core.Internal.UnmanagedLibrary.FirstValidLibraryPath(String[] libraryPathAlternatives)
   at Grpc.Core.Internal.UnmanagedLibrary..ctor(String[] libraryPathAlternatives)
   at Grpc.Core.Internal.NativeExtension.Load()
   at Grpc.Core.Internal.NativeExtension..ctor()
   at Grpc.Core.Internal.NativeExtension.Get()
   at Grpc.Core.GrpcEnvironment.GrpcNativeInit()
   at Grpc.Core.GrpcEnvironment..ctor()
   ...

A possible fix for this is mentioned in Google's documentation at Why aren't the gRPC native libraries being found?. Unfortunately, it does not work for ARM-based Macs. That's because the Google.Ads.GoogleAds package depends on a legacy Grpc implementation which does not include the required native library for the OSX64-ARM64 platform.

Thankfully, the .NET community is awesome and the problem can be easily solved by explicitly referencing the Grpc package and the Contrib.Grpc.Core.M1 package that contains the native library, which is at the heart of the exception.

Head to the author's blog to read more about it:

Legacy C# gRPC package +  M1
I recently upgraded to a new MacBook with the  M1 CPU in it. In one of the projects I’m working on @ work we have a third party dependency that is still using the legacy package of gRPC and …

Retrieving Conversion Actions' IDs programatically

public async Task<IReadOnlyCollection<ConversionAction>> GetConversionActions()
{
    var service = _googleAdsClient.GetService(Google.Ads.GoogleAds.Services.V10.GoogleAdsService);
    var query = "SELECT conversion_action.id, conversion_action.name FROM conversion_action";
    IReadOnlyCollection<ConversionAction> result = null;
    await service.SearchStreamAsync(_googleAdsCustomerId.Value.ToString(), query, response =>
    {
        result = response.Results.Select(r => r.ConversionAction).ToArray();
    });

    return result ?? Array.Empty<ConversionAction>();
}

Cover photo by Anthony Tuil on Unsplash

Show Comments