IP Geolocation in GTM with ipapi.co

IP Geolocation in GTM with ipapi.co

Capture city, postal code, timezone, ISP, and UTC offset on every page load — no server-side code required.

Published · Tags: GTM, Google Analytics 4, Geolocation, ipapi, JavaScript, Cookies

Ever wished you could segment your GA4 reports by city, postal code, or timezone — without a backend? This recipe wires up ipapi.co's free geolocation API directly inside Google Tag Manager. It runs once per session, writes the results to both the dataLayer and first-party cookies, and forwards them to GA4 as a custom event — all in a single importable container JSON.

⬇ Download the GTM Container JSON from Google Drive

What's Inside the Container

The exported GTM recipe (ipapi-geolocation-recipe.json) contains four folders of assets:

Asset TypeNamePurpose
Tag (Custom HTML)IPAPI Get LocationCalls the ipapi.co API, guards against double-runs, pushes data to dataLayer
Tag (Custom Template)IPAPI Cookie WriterWrites geo values to first-party session cookies using the sandboxed Cookie Writer template
Tag (GA4 Event)IPAPI Send EventFires a geoIPAPI GA4 event with all geo dimensions as event parameters
Tag (Google Tag)Google Analytics ConfigurationStandard GA4 config tag wired to environment-aware Stream IDs
TriggerIPAPI Has Not Already RanInitialization trigger — fires only when the _ipapi_ar cookie is absent or ≠ 1
TriggergeoIPAPI Custom EventListens for the geoIPAPI event pushed by the fetch callback
Custom TemplateCookie WriterSandboxed JS template by drewspen — writes multiple cookies from a table param
Custom TemplateGet Root DomainUtility template to resolve the root domain for cookie scoping
Custom TemplateTimestampProvides millisecond timestamps for session/user ID enrichment
Custom TemplateIf Else If – Advanced Lookup TableEnvironment-aware regex lookup for GA4 Stream ID routing

Architecture Overview

Here's the flow from page load to GA4 event:

  1. Page initializes. The IPAPI Has Not Already Ran trigger fires on GTM initialization — but only when the _ipapi_ar cookie is absent or not equal to 1.
  2. Guard cookie is stamped. The IPAPI Get Location tag immediately sets _ipapi_ar=1 before the fetch even completes, preventing any race-condition double fires.
  3. ipapi.co is called. A fetch("https://ipapi.co/json/") returns a JSON payload with dozens of geolocation fields.
  4. dataLayer is populated. The callback pushes a geoIPAPI event with the following fields: geoPostal geoTimeZone geoUTCOffSet geoCallingCode geoASN geoCity geoISP.
  5. Downstream tags fire. The geoIPAPI Custom Event trigger activates both the Cookie Writer tag and the GA4 event tag.
  6. Cookies are written. The sandboxed Cookie Writer template sets _i_geoCity, _i_geoISP, _i_geoPostal, _i_geoTimeZone, and _i_geoUTCOffSet as session cookies scoped to your root domain.
  7. GA4 event is sent. The IPAPI Send Event tag forwards all five geo dimensions as custom event parameters on the geoIPAPI hit.

The Custom HTML Tag — IPAPI Get Location

This is the engine of the whole recipe. It's a self-invoking function that handles the guard logic and API call entirely in vanilla JavaScript — no external libraries needed.

<script type="text/javascript">
(function () {
  // Helper: read a cookie value by name
  function getCookie(name) {
    var match = document.cookie.match(new RegExp('(?:^|;\\s*)' + name + '=([^;]*)'));
    return match ? match[1] : undefined;
  }

  // Helper: set a cookie on the root domain (session cookie, no expiry)
  function setCookie(name, value) {
    var host = window.location.hostname;
    var rootDomain = host.indexOf('.') !== -1
      ? '.' + host.split('.').slice(-2).join('.')
      : host;
    document.cookie = name + '=' + value + '; path=/; domain=' + rootDomain;
  }

  var COOKIE_NAME = '_ipapi_ar';

  // If the guard cookie is already '1', bail immediately
  if (getCookie(COOKIE_NAME) === '1') { return; }

  // Stamp the guard cookie BEFORE the async fetch
  setCookie(COOKIE_NAME, '1');

  // Call ipapi.co
  fetch("https://ipapi.co/json/")
    .then(function (response) {
      if (!response.ok) {
        console.log('Request failed with status ' + response.status);
      }
      return response.json();
    })
    .then(function (data) {
      window.dataLayer = window.dataLayer || [];
      window.dataLayer.push({
        'event':          'geoIPAPI',
        'geoPostal':      data.postal,
        'geoTimeZone':    data.timezone,
        'geoUTCOffSet':   data.utc_offset,
        'geoCallingCode': data.country_calling_code,
        'geoASN':         data.asn,
        'geoCity':        data.city,
        'geoISP':         data.org
      });
    })
    .catch(function (error) { console.log(error); });
})();
</script>
Design choice: The guard cookie is written synchronously before the async fetch() resolves. This prevents a second page view (or a fast redirect) from firing a second API call while the first one is still in-flight.

The commented-out fields at the bottom of the original tag (geoIP, geoRegion, geoCountryCode, geoLatitude, geoLongitude, and more) show everything ipapi.co actually returns. You can uncomment whichever fields your use case needs.

The Cookie Writer — Sandboxed JS Custom Template

Once the geoIPAPI dataLayer event fires, the IPAPI Cookie Writer tag uses the Cookie Writer custom template to persist the values as first-party cookies. Unlike the Custom HTML tag, this template runs inside GTM's sandboxed JavaScript environment — meaning it uses GTM's built-in setCookie API rather than document.cookie, which keeps it compatible with GTM's permission model.

Template Parameters

ParameterTypeDescription
rootDomainTextCookie domain scope, e.g. .example.com. Populated by the Root Domain URL variable.
cookieDurationSelectsessionCookie (browser-session lifetime) or persistentCookie (N days)
persistentCookieExpiresText (number)Days until expiry. Only active when cookieDuration is persistentCookie.
targetedCookiesTableRows of cookieName / cookieValue. Values accept GTM variable references.

Sandboxed JavaScript (core logic)

const setCookie = require('setCookie');
const getTimestampMillis = require('getTimestampMillis');
const logToConsole = require('logToConsole');
const makeString = require('makeString');

const rootDomain          = data.rootDomain;
const cookieDuration      = data.cookieDuration;
const persistentCookieExpires = data.persistentCookieExpires;
const targetedCookies     = data.targetedCookies;

var cookieOptions = {
  domain  : rootDomain || 'auto',
  path    : '/',
  secure  : true,
  samesite: 'Lax'
};

if (cookieDuration === 'persistentCookie') {
  var days = (persistentCookieExpires - 0) || 0;
  if (days > 0) {
    cookieOptions['max-age'] = days * 24 * 60 * 60;
  }
}

if (targetedCookies && targetedCookies.length > 0) {
  var i = 0;
  while (i < targetedCookies.length) {
    var row    = targetedCookies[i];
    var cName  = makeString(row.cookieName  || '').trim();
    var cValue = makeString(row.cookieValue || '').trim();
    if (cName) {
      setCookie(cName, cValue, cookieOptions);
      logToConsole('GTM Cookie Writer: set cookie "' + cName + '" = "' + cValue + '"');
    }
    i = i + 1;
  }
}

data.gtmOnSuccess();

The tag in this recipe is configured with five cookie rows:

Cookie NameCookie Value (GTM Variable)
_i_geoCity{{geoCity dataLayer}}
_i_geoISP{{geoISP dataLayer}}
_i_geoPostal{{geoPostal dataLayer}}
_i_geoTimeZone{{geoTimeZone dataLayer}}
_i_geoUTCOffSet{{geoUTCOffSet dataLayer}}
Consent gating: The Cookie Writer tag requires both analytics_storage and functionality_storage consent before it fires. If your CMP hasn't granted these, the geo cookies will not be written — the GA4 event will still fire, but without the cookie persistence.

Triggers

IPAPI Has Not Already Ran (Initialization Trigger)

This trigger fires on GTM Initialization (the very first moment the container loads), but only when the IPAPI Has Already Ran cookie variable does not match ^1$. In other words, it fires only on the very first page view of the session. The variable IPAPI Has Already Ran is a first-party cookie variable reading _ipapi_ar.

geoIPAPI Custom Event Trigger

Fires on the geoIPAPI dataLayer event — the one pushed inside the fetch() callback. This is what activates the Cookie Writer and GA4 event tags downstream.

GA4 Event Parameters

The IPAPI Send Event tag fires a GA4 event named geoIPAPI with these custom parameters:

GA4 ParameterGTM Variableipapi.co Field
GeoCity{{geoCity dataLayer}}data.city
GeoISP{{geoISP dataLayer}}data.org
GeoPostalCode{{geoPostal dataLayer}}data.postal
GeoTimeZone{{geoTimeZone dataLayer}}data.timezone
GeoUTCOffSet{{geoUTCOffSet dataLayer}}data.utc_offset

How to Import

  1. Download the JSON file from the Google Drive link.
  2. In GTM, go to Admin → Import Container.
  3. Upload ipapi-geolocation-recipe.json.
  4. Choose Merge (not Overwrite) to preserve your existing tags.
  5. Update the Measurement Stream ID Development and Measurement Stream ID Production constant variables to your actual GA4 Stream IDs.
  6. Register GeoCity, GeoISP, GeoPostalCode, GeoTimeZone, and GeoUTCOffSet as custom dimensions in your GA4 property.
  7. Preview, test in GTM Preview mode, then publish.
Rate limits: ipapi.co's free plan allows 30,000 requests per month. Because the guard cookie prevents multiple calls per session, a site with 10,000 monthly sessions will stay well within that limit. For higher-traffic sites, consider upgrading to a paid plan or caching the result server-side.

What You Can Do With It

  • Build GA4 audience segments by city or postal code for geo-targeted campaigns
  • Use _i_geoTimeZone cookies to power server-side personalization without a login
  • Identify ISP/ASN patterns to distinguish corporate vs. residential traffic
  • Combine geoUTCOffSet with session timestamps to reconstruct local time of visit
  • Read _i_geoPostal cookies in other GTM tags or client-side personalization scripts

The full source — including the container JSON, all custom template code, and a breakdown of every variable — is available on GitHub. Questions or improvements? Open an issue.

⬇ Download GTM Container JSON

Comments

  1. Below is an alternate version of the script using the ipinfo.io REST API and JSONP in the event you get a blocked by CORS policy: No 'Access-Control-Allow-Origin' error.

    https://drive.google.com/file/d/1uowBhDInhYCJmQXPyfH4zf1wYRTlq-Vm/view?usp=drivesdk

    ReplyDelete

Post a Comment

Popular posts from this blog

Site Landing & Site Referrer Preservation

GTM Browser Viewport Measurement

UTM & URL Query String 2 Cookies