According to their website, SoundCloud is “the world’s leading social sound platform where anyone can create sounds and share them everywhere”. For artists, it’s a channel for distributing previews of their tracks, and for people like me it’s a nice way to do some API tinkering. To each their own, I guess!

I saw a number of requests in the Google+ Google Tag Manager community about a SoundCloud integration, so I decided to look into it to see if I could just build one.

SoundCloud has something called the Widget API, which listens for window.postMessage calls from within the embedded SoundCloud iframes. The benefit of this versus, for example, the YouTube API is that you don’t need to do anything to the iframe itself to make this work. All you need to do is load the Widget API, and then indicate which iframe(s) you want to listen to for interactions.

The setup

For this to work, you will need the following:

  • Custom HTML Tag which loads the Widget API, adds the listeners, and does the dataLayer.push() calls

  • Custom Event Trigger which fires your Tag when a SoundCloud event is registered

  • Data Layer Variables for Event Category, Event Action, and Event Label

  • Event Tag which fires when the Trigger is activated, and sends the event hit to Google Analytics

The most complex component is the Custom HTML Tag, so let’s start there.

The Custom HTML Tag

Here’s the full Tag code. Copy-paste it into a new Custom HTML Tag:

<!-- Load the SoundCloud API synchronously -->
<script src="https://w.soundcloud.com/player/api.js"></script>
<!-- Initiate the API integration -->
<script>
  (function() {
    try {
      // initWidget is called when a SoundCloud iframe is found on the page
      var initWidget = function(w) {
        var currentSound, act, pos, q1, q2, q3, go, lab;
        var cat = 'SoundCloud';
        var widget = SC.Widget(w);
        
        // Events.READY is dispatched when the widget has been loaded
        widget.bind(SC.Widget.Events.READY, function() {
          
          // Get the title of the currently playing sound
          widget.getCurrentSound(function(cs) { 
            lab = cs['title']; 
          });
          
          // Fire a dataLayer event when Events.PLAY is dispatched
          widget.bind(SC.Widget.Events.PLAY, function() { 
            act = 'Play'; 
            sendDl(cat, act, lab); 
          });
          
          // Fire a dataLayer event when Events.PAUSE is dispatched
          // The only exception is when the sound ends, and the auto-pause is not reported
          widget.bind(SC.Widget.Events.PAUSE, function(obj) { 
            pos = Math.round(obj['relativePosition'] * 100);
            if (pos !== 100) {
              act = 'Pause'; 
              sendDl(cat, act, lab); 
            }
          });
          
          // As the play progresses, send events at 25%, 50% and 75%
          widget.bind(SC.Widget.Events.PLAY_PROGRESS, function(obj) { 
            go = false;
            pos = Math.round(obj['relativePosition'] * 100);
            if (pos === 25 && !q1) {
              act = '25%';
              q1 = true;
              go = true;
            }
            if (pos === 50 && !q2) {
              act = '50%';
              q2 = true;
              go = true;
            }
            if (pos === 75 && !q3) {
              act = '75%';
              q3 = true;
              go = true;
            }
            if (go) {
              sendDl(cat, act, lab);
            }
          });
          
          // When the sound finishes, send an event at 100%
          widget.bind(SC.Widget.Events.FINISH, function() { 
            act = '100%'; 
            q1 = q2 = q3 = false; 
            sendDl(cat, act, lab); 
          });
        });
      };
      
      // Generic method for pushing the dataLayer event
      // Use a Custom Event Trigger with "scEvent" as the event name
      // Remember to create Data Layer Variables for eventCategory, eventAction, and eventLabel
      var sendDl = function(cat, act, lab) {
        window.dataLayer.push({
          'event' : 'scEvent',
          'eventCategory' : cat,
          'eventAction' : act,
          'eventLabel' : lab
        });
      };

      // For each SoundCloud iFrame, initiate the API integration
      var i,len;
      var iframes = document.querySelectorAll('iframe[src*="api.soundcloud.com"]');
      for (i = 0, len = iframes.length; i < len; i += 1) {
        initWidget(iframes[i]);
      }
    } catch(e) { console.log('Error with SoundCloud API: ' + e.message); }
  })();
</script>

Make sure this Custom HTML Tag fires upon a Page View Trigger, where the Trigger Type is DOM Ready. If it doesn’t work, try changing the Trigger Type to Window Loaded. It’s possible a race condition emerges, where the Custom HTML Tag is fired before your SoundCloud widgets are loaded, and shifting the Trigger to fire on Window Loaded should remedy that.

Let’s chop it up into pieces so we’ll understand what’s happening. This time, we’ll start from the end!

// For each SoundCloud iFrame, initiate the API integration
var i,len;
var iframes = document.querySelectorAll('iframe[src*="api.soundcloud.com"]');
for (i = 0, len = iframes.length; i < len; i += 1) {
  initWidget(iframes[i]);
}

The code above goes through all the iframes on the page. If it encounters an iframe that loads an embedded SoundCloud Widget, it calls the initWidget method, using the iframe object as a parameter. This is as simple as it gets. The cool thing is that each iframe gets its own bindings, so you can run the script with multiple SoundCloud widgets on the page!

var initWidget = function(w) {
  var currentSound, act, pos, q1, q2, q3, go, lab;
  var cat = 'SoundCloud';
  var widget = SC.Widget(w);
        
  // Events.READY is dispatched when the widget has been loaded
  widget.bind(SC.Widget.Events.READY, function() {
          
    // Get the title of the currently playing sound
    widget.getCurrentSound(function(cs) { 
      lab = cs['title']; 
    });          

The initWidget function is called for all the SoundCloud iframes on the page. First, it declares some utility variables. Next, it uses the Widget API SC.Widget constructor to create a new widget object the API uses for the bindings.

On the following lines, the SC.Widget.Events.READY event is bound to the widget object. This event is fired when the embedded SoundCloud object has loaded and is ready to be interacted with. All the listeners are put into this function callback, as we don’t want to start listening for events before the embedded file has loaded, right?

The first thing we do is get the title of the sound, and for that we need to use the asynchronous getCurrentSound function, whose callback returns the sound object. Then, we access this object’s title key and store it in a variable. Now we have all the static variables defined, and we can create our four listeners.

// Fire a dataLayer event when Events.PLAY is dispatched
widget.bind(SC.Widget.Events.PLAY, function() { 
  act = 'Play'; 
  sendDl(cat, act, lab); 
});

The first listener is bound to the SC.Widget.Events.PLAY event, which, surprisingly, is dispatched when a “Play” event is recorded in the widget. Once that happens, we set the act variable to “Play”, and invoke the sendDl (see below) method, which does the dataLayer.push(). Parameters are the cat (“SoundCloud”), act (“Play”), and lab (Sound title) variables.

// Fire a dataLayer event when Events.PAUSE is dispatched
// The only exception is when the sound ends, and the auto-pause is not reported
widget.bind(SC.Widget.Events.PAUSE, function(obj) { 
  pos = Math.round(obj['relativePosition'] * 100);
  if (pos !== 100) {
    act = 'Pause'; 
    sendDl(cat, act, lab); 
  }
});

The next event we’ll bind is SC.Widget.Events.PAUSE, which is dispatched when a “Pause” event is recorded in the widget. It’s practically the same as the “Play” event, but we need to add one extra check there. SoundCloud auto-pauses the sound when it’s completed, so GA would receive a number of “Pause” events that were not initiated by the user. That’s why we have the check on the first line of the callback, where we basically see if the “Pause” event occurred when the position of the sound is at 100%. This would indicate that it’s an auto-pause, and we won’t invoke sendDl in that case.

// As the play progresses, send events at 25%, 50% and 75%
widget.bind(SC.Widget.Events.PLAY_PROGRESS, function(obj) { 
  go = false;
  pos = Math.round(obj['relativePosition'] * 100);
  if (pos === 25 && !q1) {
    act = '25%';
    q1 = true;
    go = true;
  }
  if (pos === 50 && !q2) {
    act = '50%';
    q2 = true;
    go = true;
  }
  if (pos === 75 && !q3) {
    act = '75%';
    q3 = true;
    go = true;
  }
  if (go) {
    sendDl(cat, act, lab);
  }
});

The next binding is for the SC.Widget.Events.PLAY_PROGRESS. This event is dispatched every few milliseconds, and the object it returns has the relative position of the sound at the time of the event. This relative position is actually a percentage of how far the user has listened to the track. So, because I’ve chosen to send an event at 25%, 50%, 75% and 100%, I need to check if the relative position is at these milestones. I also use a couple of booleans, q1 q2 q3 go, which prevent the same milestone from being sent multiple times. The go variable ensures that the GA Event is only fired when the milestones are reached, and not for every single iteration of the PLAY_PROGRESS event.

// When the sound finishes, send an event at 100%
widget.bind(SC.Widget.Events.FINISH, function() { 
  act = '100%'; 
  q1 = q2 = q3 = false; 
  sendDl(cat, act, lab); 
});

Finally, when the sound finishes, we send the 100% event, and we also reset the utility variables. If we don’t reset them, repeated listenings would not be recorded.

// Generic method for pushing the dataLayer event
// Use a Custom Event Trigger with "scEvent" as the event name
// Remember to create Data Layer Variables for eventCategory, eventAction, and eventLabel
var sendDl = function(cat, act, lab) {
  window.dataLayer.push({
    'event' : 'scEvent',
    'eventCategory' : cat,
    'eventAction' : act,
    'eventLabel' : lab
  });
};

And here’s the sendDl method. It just takes the parameters, and pushes them into a dataLayer object.

The Trigger

To activate GTM Tags when a SoundCloud event is registered, create a new Custom Event Trigger that looks like this:

It’s a simple one. This Trigger will fire your Tags when the sendDl method we built above is invoked.

The Data Layer Variables

Next, make sure you have three Data Layer Variables. One for eventCategory, one for eventAction, and one for eventLabel. They’d look something like this:

These are pretty generic, so you might find them useful elsewhere as well.

The Event Tag

Finally, you need an Event Tag to carry this information to Google Analytics. Set it up as you would any other Event Tag, and make sure it fires with the Trigger you created before (Event - scEvent). Then, add the three Variables you created to their respective fields:

And that’s it! Now your site should be ready to collect hits from SoundCloud widgets.

Summary and caveats

First, some caveats.

Every now and then I noticed an annoying race condition, where the widget had loaded before the GTM Custom HTML Tag had time to complete. This means that the SC.Widget.Events.READY was dispatched too early for the listener to catch it. This race condition could be fixed by having a timeout of a second or something, which then does the bindings anyway. I didn’t write it into this solution, but it should be pretty trivial to implement once you understand how the Widget API works.

Some other things I noticed were quota errors from the API. There’s nothing you can do about these, though I believe you can subscribe to some Premium account where these errors don’t crop up. As far as I could tell, however, they had no impact on this tracking solution, and the events were sent nevertheless.

Anyway, this is a pretty simple solution for tracking SoundCloud widgets on your site. I’ve tried to mirror the excellent YouTube tracking guide by Cardinal Path.

The Widget API has a number of other interfaces you can tap into if you want to make the solution even better. Let me know if you encountered any problems, or if you have ideas how to improve this solution!