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.
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.
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:
- Page initializes. The IPAPI Has Not Already Ran trigger fires on GTM initialization — but only when the
_ipapi_arcookie is absent or not equal to1. - Guard cookie is stamped. The IPAPI Get Location tag immediately sets
_ipapi_ar=1before the fetch even completes, preventing any race-condition double fires. - ipapi.co is called. A
fetch("https://ipapi.co/json/")returns a JSON payload with dozens of geolocation fields. - dataLayer is populated. The callback pushes a
geoIPAPIevent with the following fields: geoPostal geoTimeZone geoUTCOffSet geoCallingCode geoASN geoCity geoISP. - Downstream tags fire. The geoIPAPI Custom Event trigger activates both the Cookie Writer tag and the GA4 event tag.
- Cookies are written. The sandboxed Cookie Writer template sets
_i_geoCity,_i_geoISP,_i_geoPostal,_i_geoTimeZone, and_i_geoUTCOffSetas session cookies scoped to your root domain. - GA4 event is sent. The IPAPI Send Event tag forwards all five geo dimensions as custom event parameters on the
geoIPAPIhit.
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>
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();
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}} |
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
- Download the JSON file from the Google Drive link.
- In GTM, go to Admin → Import Container.
- Upload
ipapi-geolocation-recipe.json. - Choose Merge (not Overwrite) to preserve your existing tags.
- Update the Measurement Stream ID Development and Measurement Stream ID Production constant variables to your actual GA4 Stream IDs.
- Register
GeoCity,GeoISP,GeoPostalCode,GeoTimeZone, andGeoUTCOffSetas custom dimensions in your GA4 property. - Preview, test in GTM Preview mode, then publish.
What You Can Do With It
- Build GA4 audience segments by city or postal code for geo-targeted campaigns
- Use
_i_geoTimeZonecookies to power server-side personalization without a login - Identify ISP/ASN patterns to distinguish corporate vs. residential traffic
- Combine
geoUTCOffSetwith session timestamps to reconstruct local time of visit - Read
_i_geoPostalcookies 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
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.
ReplyDeletehttps://drive.google.com/file/d/1uowBhDInhYCJmQXPyfH4zf1wYRTlq-Vm/view?usp=drivesdk