DEV Community

Kent Spencer
Kent Spencer

Posted on • Originally published at drewspen.blogspot.com on

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 Type Name Purpose
Tag (Custom HTML) IPAPI Get Location Calls the ipapi.co API, guards against double-runs, pushes data to dataLayer
Tag (Custom Template) IPAPI Cookie Writer Writes geo values to first-party session cookies using the sandboxed Cookie Writer template
Tag (GA4 Event) IPAPI Send Event Fires a geoIPAPI GA4 event with all geo dimensions as event parameters
Tag (Google Tag) Google Analytics Configuration Standard GA4 config tag wired to environment-aware Stream IDs
Trigger IPAPI Has Not Already Ran Initialization trigger — fires only when the _ipapi_ar cookie is absent or ≠ 1
Trigger geoIPAPI Custom Event Listens for the geoIPAPI event pushed by the fetch callback
Custom Template Cookie Writer Sandboxed JS template by drewspen — writes multiple cookies from a table param
Custom Template Get Root Domain Utility template to resolve the root domain for cookie scoping
Custom Template Timestamp Provides millisecond timestamps for session/user ID enrichment
Custom Template If Else If – Advanced Lookup Table Environment-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>
Enter fullscreen mode Exit fullscreen mode

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

Parameter Type Description
rootDomain Text Cookie domain scope, e.g. .example.com. Populated by the Root Domain URL variable.
cookieDuration Select sessionCookie (browser-session lifetime) or persistentCookie (N days)
persistentCookieExpires Text (number) Days until expiry. Only active when cookieDuration is persistentCookie.
targetedCookies Table Rows 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();
Enter fullscreen mode Exit fullscreen mode

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

Cookie Name Cookie 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 Parameter GTM Variable ipapi.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

Top comments (0)