Location & geofencing
Location adds context to everything else: show the Riyadh offer to people in Riyadh, measure visits to a branch or a partner mall, and greet travellers at the airport. ContentFlow knows location three ways, with different precision and different consent. This guide is explicit about what each one does today.
- ContentFlow knows location three ways: IP location (no permission needed), device location (OS permission and analytics consent), and geofence enter, exit and dwell events.
getIPGeolocation()gives a coarse country and city with no prompt, andstartLocationTracking(...)uploads device fixes in batches.- Geofences are defined in the dashboard; from SDK 2.3.0 the app watches them with
startGeofencing, the server also checks uploaded location, and each stay is recorded once. - The most common trap: geofences fire only for users who granted location consent in addition to analytics consent; without it the fence list is empty and nothing fires.
- Fences registered only in app code (
registerGeofence) count only when the dashboard has a fence with the same id.
Business use cases
City and region targeting
Segments on currentLocation.city or country from IP, for regional offers, branch news and language defaults.
Branch and mall visits
On-device geofences around branches, ATMs or partner malls produce enter, exit and dwell events for the location dashboard.
Airport and border moments
A fence at the airport marks travel moments, such as offering a travel card or foreign-exchange rates.
Hajj and Umrah
Fences around Makkah and Madinah measure pilgrimage-season presence for planning and reporting.
Three kinds of location
| IP location | Device location | Geofence events | |
|---|---|---|---|
| Precision | Country, region, city | Latitude and longitude, with accuracy | Inside or outside a fence |
| Needs OS permission | No | Yes | Yes |
| Needs analytics consent | Stored on the profile only with consent | Yes | Yes |
| Where it goes | Profile currentLocation; every analytics event | Location history; latest fix on the profile | Analytics (GEOFENCE_ENTER, _EXIT, _DWELL) |
| Use it for | Segments and analytics by region | Radius segments, visit analysis | Visit and dwell analytics |
What works today
| Capability | Status |
|---|---|
| IP country, region and city on profiles and events | Available |
Segments on currentLocation.country, countryCode, region, city | Available everywhere, including in-app content |
On-device geofences: dashboard fences downloaded by the app (GET /sdk/v1/geofences), monitored by the OS, each transition recorded once together with server evaluation | Available on the server; SDK support from 2.3.0 (Swift, Kotlin, React Native, Flutter). See On-device geofences |
Fences defined only in app code (registerGeofence) | Limited: local callbacks only. A reported transition counts only when the dashboard has a fence with the same id |
| Geofence activity dashboard and fence management | Available in Analytics → Location |
| Storing device location batches | Available from every SDK's location tracker |
| Server geofences: fences defined in the dashboard, evaluated on the server against uploaded device location → enter, exit, dwell analytics | Available only for users who granted location consent; see Server geofences |
geo_radius segments | Limited: campaigns and journeys only, and only when a device fix is stored; not for in-app content |
| Fences defined in the dashboard reaching the app | Available through GET /sdk/v1/geofences, nearest first and capped; see On-device geofences |
| Server fence actions: track an event, send a campaign, call a webhook | Available; see Geofence actions |
| Journeys that start on entering, leaving or staying in a geofence | Available: event trigger GEOFENCE_ENTER, _EXIT or _DWELL, optionally for one fence (journey triggers) |
| Fence action: show a block | Not yet: stored, not executed |
IP location
On every identify, ContentFlow looks up the request's IP address and writes currentLocation (country, country code, region code, city) onto the consented profile. Separately, ContentFlow stamps country, region, city and approximate coordinates onto analytics events, refreshing each device's lookup every 30 minutes. Both lookups run on ContentFlow's servers against a local copy of MaxMind's GeoLite2 data: the IP address is never sent to a third-party geolocation service. The app can ask directly:
// 1. Ask for the IP location. No permission prompt.
const geo = await client.getIPGeolocation(); // null if the request failed
// { ip, country, countryName, region, regionName, city, postalCode?,
// lat, lng, accuracyRadius, timezone, utcOffset, isPrivate }
// 2. Unknown location (private IP, address not in the database): 200 with empty
// strings and zeros, isPrivate: true for a private IP. Check geo.country first.
if (geo?.country) showRegionalOffer(geo.country, geo.city);import { useEffect } from 'react';
import { useCF } from '@contentflow/sdk/react';
function RegionalOffer() {
const cf = useCF();
useEffect(() => {
// 1. Ask for the IP location. No permission prompt.
cf.getIPGeolocation().then((geo) => { // null if the request failed
// 2. Unknown location (private IP, address not in the database): 200 with empty
// strings and zeros, isPrivate: true for a private IP. Check geo.country first.
if (geo?.country) showRegionalOffer(geo.country, geo.city);
}).catch(() => {}); // rejects only when the SDK key is rejected
}, [cf]);
return null;
}import { useEffect } from 'react';
import { useCF } from '@contentflow/sdk/react-native';
function RegionalOffer() {
const cf = useCF();
useEffect(() => {
// 1. Ask for the IP location. No permission prompt and no geolocation module:
// the server resolves the request's IP address.
cf.getIPGeolocation().then((geo) => { // null if the request failed
// 2. Unknown location (private IP, address not in the database): 200 with empty
// strings and zeros, isPrivate: true for a private IP. Check geo.country first.
if (geo?.country) showRegionalOffer(geo.country, geo.city);
}).catch(() => {}); // rejects only when the SDK key is rejected
}, [cf]);
return null;
}// 1. Ask for the IP location. No permission prompt.
let geo = await client.getIPGeolocation() // nil if the request failed
// 2. Unknown location (private IP, address not in the database): empty
// strings and zeros. Check geo.country first.
if let geo, !geo.country.isEmpty { showRegionalOffer(geo.country, geo.city) }// 1. Ask for the IP location, from a coroutine. No permission prompt.
// IP geolocation lives on client.extensions; it throws ContentFlowError on failure.
val geo = client.extensions.getIPGeolocation()
// 2. Unknown location (private IP, address not in the database): empty
// strings and zeros, isPrivate = true for a private IP. Check geo.country first.
if (geo.country.isNotEmpty()) showRegionalOffer(geo.country, geo.city)final cf = ContentFlow.instance;
// 1. Ask for the IP location. No permission prompt.
final geo = await cf.getIPGeolocation(); // null if the request failed
// 2. Unknown location (private IP, address not in the database): empty
// strings and zeros. Check geo.country first.
if (geo != null && geo.country.isNotEmpty) showRegionalOffer(geo.country, geo.city);IP locations are coarse: accuracyRadius is the likely error in kilometres, and utcOffset is the time zone's current offset in hours. ISP, connection type and proxy or VPN detection aren't available, so the SDK fields isp, org, asn, connectionType, isProxy and isHosting are never set.
This product includes GeoLite2 data created by MaxMind, available from https://www.maxmind.com.
Device location
The SDK requests OS permission only when you call it, and uploads only with analytics consent and while the workspace's location feature switch is on.
// 1. One fix. In a browser this shows the permission prompt.
const here = await client.requestLocation(); // null when no fix is available
// 2. Continuous tracking: filtered, batched, uploaded.
client.startLocationTracking({
mode: 'balanced', // 'high_accuracy' uses GPS; others save battery
intervalMs: 30000,
distanceFilterMeters: 50, // ignore moves shorter than this
minAccuracyMeters: 100, // drop fixes worse than this
batchSize: 10, maxBatchAgeMs: 60000,
});
// 3. Stop, and send the points still batched.
await client.stopLocationTracking();import { useEffect } from 'react';
import { useCF } from '@contentflow/sdk/react';
// The web provider uses the browser's navigator.geolocation: nothing to pass.
function LocationTracking() {
const cf = useCF();
// 1. One fix. This shows the browser's permission prompt, so ask from a tap.
const locate = async () => {
const here = await cf.requestLocation(); // null when no fix is available
};
useEffect(() => {
// 2. Continuous tracking: filtered, batched, uploaded.
cf.startLocationTracking({
mode: 'balanced', // 'high_accuracy' uses GPS; others save battery
intervalMs: 30000,
distanceFilterMeters: 50, // ignore moves shorter than this
minAccuracyMeters: 100, // drop fixes worse than this
batchSize: 10, maxBatchAgeMs: 60000,
});
// 3. Stop on unmount, and send the points still batched.
return () => { void cf.stopLocationTracking(); };
}, [cf]);
return <button onClick={locate}>Use my location</button>;
}import { useEffect } from 'react';
import { Button } from 'react-native';
import Geolocation from '@react-native-community/geolocation';
import { CFProvider, useCF } from '@contentflow/sdk/react-native';
// React Native has no built-in location API: pass a navigator.geolocation-shaped module.
// Without one, device location is unavailable. The OS prompt only appears when you
// call requestLocation or startLocationTracking.
<CFProvider config={{ publicKey: CONTENTFLOW_SDK_KEY }} geolocation={Geolocation}>
<App />
</CFProvider>
function LocationTracking() {
const cf = useCF();
// 1. One fix. This shows the OS permission prompt, so ask from a tap.
const locate = async () => {
const here = await cf.requestLocation(); // null when no fix is available
};
useEffect(() => {
// 2. Continuous tracking: filtered, batched, uploaded.
cf.startLocationTracking({
mode: 'balanced', // 'high_accuracy' uses GPS; others save battery
intervalMs: 30000,
distanceFilterMeters: 50, // ignore moves shorter than this
minAccuracyMeters: 100, // drop fixes worse than this
batchSize: 10, maxBatchAgeMs: 60000,
});
// 3. Stop on unmount, and send the points still batched.
return () => { void cf.stopLocationTracking(); };
}, [cf]);
return <Button title="Use my location" onPress={locate} />;
}import ContentFlowLocation // the opt-in location product
// Once: install the CoreLocation provider. The SDK never prompts on its own.
await client.enableDeviceLocation()
await client.requestLocationPermission() // always: true for background access
// 1. One fix. Throws LocationError without permission or when no fix arrives in time.
let here = try await client.requestLocation()
// 2. Continuous tracking: filtered, batched, uploaded.
try await client.startLocationTracking(LocationTrackingConfig(
mode: .balanced, // .highAccuracy uses GPS; others save battery
intervalMs: 30000,
distanceFilterMeters: 50, // ignore moves shorter than this
minAccuracyMeters: 100, // drop fixes worse than this (nil with approximate location)
batchSize: 10, maxBatchAgeMs: 60000
))
// 3. Stop, and send the points still batched.
await client.stopLocationTracking()import click.contentflow.location.enableDeviceLocation // the opt-in :location module
// Once: install the Android provider. The SDK never prompts: request
// ACCESS_FINE_LOCATION and/or ACCESS_COARSE_LOCATION from your Activity first.
client.enableDeviceLocation(context)
// 1. One fix, from a coroutine. Throws LocationException without permission or on timeout.
val here = client.requestLocation()
// 2. Continuous tracking: filtered, batched, uploaded.
client.startLocationTracking(LocationTrackingConfig(
mode = LocationMode.Balanced, // LocationMode.HighAccuracy uses GPS; others save battery
intervalMs = 30_000,
distanceFilterMeters = 50.0, // ignore moves shorter than this
minAccuracyMeters = 100.0, // drop fixes worse than this
batchSize = 10, maxBatchAgeMs = 60_000,
))
// 3. Stop, and send the points still batched.
client.stopLocationTracking()final cf = ContentFlow.instance;
// Location uses the geolocator plugin. Ask for permission from your own UI:
await cf.location.requestPermission();
// 1. One fix.
final here = await cf.requestLocation(); // null when no fix is available
// 2. Continuous tracking: filtered, batched, uploaded.
await cf.startLocationTracking(const CFLocationTrackingConfig(
mode: CFLocationMode.balanced, // CFLocationMode.high uses GPS; others save battery
intervalMs: 30000,
distanceFilterMeters: 50, // ignore moves shorter than this
minAccuracyMeters: 100, // drop fixes worse than this
batchSize: 10, maxBatchAgeMs: 60000,
));
// 3. Stop, and send the points still batched.
await cf.stopLocationTracking();Fixes are filtered (accuracy, distance, mocked locations), batched, and sent to POST /location; the server keeps up to 100 points per batch, drops near-duplicates (under 10 s and 5 m apart), and limits each device to 60 batches a minute. Revoking consent, erasure or disposing the client discards unsent points.
Geofences
Fences are defined in the dashboard (circles up to 100 km, or polygons) with targets and actions. They are seen two ways, and both feed one record per stay:
- On the device (On-device geofences): the app downloads the fences meant for it and lets the operating system watch them, which is fast and needs no continuous location upload.
- On the server (Server geofences): uploaded device location is checked against the same fences.
Each enter, exit or dwell is recorded once, whichever side sees it first, sent to analytics as GEOFENCE_ENTER, GEOFENCE_EXIT or GEOFENCE_DWELL with eventData.source device or server, shown in Analytics → Location without user ids or coordinates, and runs the fence's actions (Geofence actions).
An app can still register its own fences in code for local callbacks. Their transitions count only when the dashboard has a fence with the same id; others are ignored.
// 1. Register a fence. It is checked against each fix while location tracking runs.
client.registerGeofence({
fence: { type: 'circle', id: 'branch-olaya', lat: 24.7137, lng: 46.6754, radiusMeters: 150 },
triggers: { onEnter: true, onExit: true, onDwell: true, dwellTimeMs: 120000 },
onEnter: () => showBranchWelcome(), // optional local callback
});
// Polygons: { type: 'polygon', id, points: [[lat, lng], ...] }.
// 2. Remove it.
client.unregisterGeofence('branch-olaya');import { useEffect } from 'react';
import { useCF } from '@contentflow/sdk/react';
function BranchFence() {
const cf = useCF();
useEffect(() => {
// 1. Register a fence. It is checked against each fix while location tracking runs.
cf.registerGeofence({
fence: { type: 'circle', id: 'branch-olaya', lat: 24.7137, lng: 46.6754, radiusMeters: 150 },
triggers: { onEnter: true, onExit: true, onDwell: true, dwellTimeMs: 120000 },
onEnter: () => showBranchWelcome(), // optional local callback
});
// Polygons: { type: 'polygon', id, points: [[lat, lng], ...] }.
// 2. Remove it on unmount.
return () => cf.unregisterGeofence('branch-olaya');
}, [cf]);
return null;
}import { useEffect } from 'react';
import { useCF } from '@contentflow/sdk/react-native';
function BranchFence() {
const cf = useCF();
useEffect(() => {
// 1. Register a fence. It is checked against each fix while location tracking runs,
// which needs CFProvider's geolocation prop (see Device location).
cf.registerGeofence({
fence: { type: 'circle', id: 'branch-olaya', lat: 24.7137, lng: 46.6754, radiusMeters: 150 },
triggers: { onEnter: true, onExit: true, onDwell: true, dwellTimeMs: 120000 },
onEnter: () => showBranchWelcome(), // optional local callback
});
// Polygons: { type: 'polygon', id, points: [[lat, lng], ...] }.
// 2. Remove it on unmount.
return () => cf.unregisterGeofence('branch-olaya');
}, [cf]);
return null;
}// 1. Register a fence. It is checked against each fix while location tracking runs.
// The Swift SDK has no local callback: enter, exit and dwell are sent to ContentFlow.
await client.registerGeofence(GeofenceConfig(
fence: .circle(id: "branch-olaya", lat: 24.7137, lng: 46.6754, radiusMeters: 150),
onEnter: true, onExit: true, onDwell: true, dwellTimeMs: 120000
))
// Polygons: .polygon(id:points:) with points [[lat, lng], ...].
// 2. Remove it.
await client.unregisterGeofence(id: "branch-olaya")// 1. Register a fence, from a coroutine. It is checked against each fix while location
// tracking runs. No local callback: enter, exit and dwell are sent to ContentFlow.
client.registerGeofence(
fence = GeofenceDefinition.Circle(id = "branch-olaya", lat = 24.7137, lng = 46.6754, radiusMeters = 150.0),
onEnter = true, onExit = true, onDwell = true, dwellTimeMs = 120_000,
)
// Polygons: GeofenceDefinition.Polygon(id, points = listOf(lat to lng, ...)).
// 2. Remove it.
client.unregisterGeofence("branch-olaya")final cf = ContentFlow.instance;
// 1. Register a fence. It is checked against each fix while location tracking runs.
// No local callback: enter, exit and dwell are sent to ContentFlow.
cf.registerGeofence(
const CFGeofence.circle(id: 'branch-olaya', lat: 24.7137, lng: 46.6754, radius: 150),
onEnter: true, onExit: true, onDwell: true, dwellTimeMs: 120000,
);
// Polygons: CFGeofence.polygon(id:, lat:, lng:, points: [[lat, lng], ...]).
// 2. Remove it.
cf.unregisterGeofence('branch-olaya');On-device geofences
From SDK 2.3.0 the app downloads the fences it should watch with GET /sdk/v1/geofences and registers them with the operating system (iOS region monitoring, Android GeofencingClient, Expo location geofencing, or in Flutter a monitor you write over the geofencing plugin you use). The web SDK uses server geofences only: browsers have no geofencing. Once started, the SDK fetches, watches and reports as described below. It never asks for OS permission, so your app does that first.
// Browsers have no OS geofencing: on the web, fences are checked on the server only.
// In another JavaScript runtime, pass a GeofenceRegionMonitor that talks to the OS.
// 1. Ask for location permission ("Always" / background) in your own UI first.
// 2. Start. Never throws for a missing permission: it stays idle and retries.
await client.startGeofencing({ monitor });
// 3. Stop, and remove every region the SDK registered.
await client.stopGeofencing({ monitor });Not available in React on the web: browsers have no OS geofencing, so fences are checked on the server from uploaded location. Start location tracking (see Device location) and the server records enter, exit and dwell. startGeofencing without an OS monitor only logs a warning.
import * as Location from 'expo-location';
import * as TaskManager from 'expo-task-manager';
import { Platform } from 'react-native';
import { defineContentFlowGeofenceTask, startExpoGeofencing, stopExpoGeofencing } from '@contentflow/sdk/expo';
// At the app entry, outside any component:
defineContentFlowGeofenceTask(TaskManager);
// 1. Ask for location permission (foreground, then background) in your own UI first.
// 2. Start.
const result = await startExpoGeofencing(client, Location, { platform: Platform.OS });
// result.status: 'started', 'task_not_defined' or 'failed'
// 3. Stop, and remove every region the SDK registered.
await stopExpoGeofencing(client, Location);import ContentFlowLocation
// Early at launch (application(_:didFinishLaunchingWithOptions:)): iOS relaunches the
// app in the background for a crossing, and the SDK must be listening again.
await client.enableDeviceLocation()
// 1. Ask for "Always" location permission in your own UI first.
await client.requestLocationPermission(always: true)
// 2. Start. Never throws: client.geofencingStatus is .stopped, .idle or .monitoring.
await client.startGeofencing()
// 3. Stop, and remove every region the SDK registered.
await client.stopGeofencing()import click.contentflow.geofencing.startGeofencing // the opt-in :geofencing module
import click.contentflow.geofencing.stopGeofencing
// 1. Ask for ACCESS_FINE_LOCATION, then ACCESS_BACKGROUND_LOCATION (Android 10+),
// in your own UI first.
// 2. Start on every launch (for example in Application.onCreate), from a coroutine:
// Android may start the process just to deliver a transition.
client.startGeofencing(context)
// 3. Stop, and remove every region the SDK registered.
client.stopGeofencing(context)final cf = ContentFlow.instance;
// The Flutter SDK is pure Dart: `monitor` is a CFGeofenceMonitor you implement
// over the geofencing plugin you use.
// 1. Ask for "Always" location permission in your own UI first.
// 2. Start.
await cf.startGeofencing(monitor);
// 3. Stop, and remove every region the SDK registered.
await cf.stopGeofencing();Geofencing runs only while analytics consent and location consent are granted and the workspace's location switch is on. When any of them turns off, the SDK removes its regions, and it resumes when they are back on.
GET /sdk/v1/geofences?lat=24.71&lng=46.68&max=20
X-CF-Key: <your SDK key>
X-CF-Device: 3f6c1a2e-8a4b-4f0e-9c1d-2b7e5d9a1c44
If-None-Match: W/"g1-…" (optional: 304 when nothing changed)
200 ETag: W/"g1-5d41…"
{ "success": true, "data": {
"fences": [
{ "id": "branch-olaya",
"region": { "lat": 24.7137, "lng": 46.6754, "radiusMeters": 150 },
"exact": "circle",
"triggers": { "enter": true, "exit": true, "dwell": true, "dwellMs": 120000 } } ],
"maxRegions": 20, "refreshAfterSec": 3600, "truncated": false, "version": "g1-5d41…" } }- Which fences: active fences of your workspace that target this user or their segments (the same targeting as server geofences), only for a user who granted location consent in addition to analytics consent. Otherwise the list is empty. It is also empty while the workspace's
locationfeature switch is off. - How many: nearest first when you send the device's coarse position (
lat,lng), otherwise by fence priority, and at mostmax(1 to 100, default 20). iOS watches at most 20 regions per app and Android 100, so leave room for any regions your app watches itself.truncated: truemeans more fences meant for this device were left out by the cap;falsemeans you have them all, so there is no need to ask for one more than you can watch. The position is rounded to about 100 m, used only to order the list, and never stored. - Polygons are sent as the smallest circle that contains them, with
exact: "polygon". The device watches the circle; when it reports an enter or a dwell, the server checks the reported position against the polygon itself. A position outside the polygon is recorded as unconfirmed and fires nothing. If the device then walks into the polygon without leaving the circle, only uploaded location (server geofences) can see it. - Refreshing: fetch again after
refreshAfterSec, after the device has moved far enough that other fences are now nearest, and when consent changes. Send theETagback asIf-None-Match; an unchanged list answers304with no body. The ETag covers the list for the position in that request (andtruncated), so after moving, a304means the nearest set for the new position is the one you already have. On a502or503, keep watching the fences you have and retry later.
The app reports each transition in POST /sdk/v1/location, in geofenceEvents[], at most 100 per upload (and at most 100 locations):
{ "deviceId": "3f6c1a2e-…",
"geofenceEvents": [
{ "fenceId": "branch-olaya", "event": "enter", "timestamp": "2026-10-02T09:00:00Z",
"lat": 24.7136, "lng": 46.6753, "accuracy": 15, "source": "device" } ] }- Report enter and exit for every fence you watch, even when its
triggers.enterortriggers.exitis false: the flags say which transitions run actions, but the server needs both to know when a stay starts and ends. Reportdwellwhentriggers.dwellis true, afterdwellMsinside. - One record per stay. A device report goes through the same per-device state as server evaluation, so a stay fires each of enter, exit and dwell once, whether the app or uploaded location shows it first. The second sighting changes nothing. The same consent, targeting, cooldown and daily limit apply, and cooldown and daily limits are shared between the two.
- Time: a report stamped more than 5 minutes in the future or more than 24 hours ago is ignored. Reports are applied in time order, and one older than the newest position already applied for that fence changes nothing.
- Ignored: reports for a fence this workspace doesn't have, or that doesn't target this user, and reports without a position. The upload is still answered
202; what was queued is not what fired.
Server geofences
When device location reaches the server (Device location), each batch is checked against the workspace's active dashboard fences, point by point in time order. An enter, exit or dwell is recorded and sent to analytics as GEOFENCE_ENTER, GEOFENCE_EXIT or GEOFENCE_DWELL with eventData.source: 'server', so it appears in Analytics → Location next to the on-device events. A dwell fires once per stay, on the first point at least the dwell time after entry.
Server geofences fire only for users who granted location consent (setChannelConsent({ locationTracking: true }) or requestConsent({ location: true })), in addition to analytics consent. Analytics consent alone lets the location be stored, but no fence is evaluated for it. A PDPL opt-out stops evaluation too. To see why a fence isn't firing, GET /api/v1/geofences/consent-skips?days=7 returns, per day and per fence, how many location points were skipped for lack of location consent (counts only, no people).
- Targeting: a fence with no target users and no target segments applies to everyone; otherwise the user must be one of the target users or in one of the target segments.
- Overlapping fences: every fence that contains the point fires, in priority order (lower number first).
- Limits are counted per trigger type.
cooldownMsandmaxTriggersPerDayapply separately to enter, exit and dwell, for each user.maxTriggersPerDay: 3allows up to 3 enters and 3 exits per user per day. The day is the Asia/Riyadh calendar day, so it turns over at 21:00 UTC. There is no combined limit across types. - Size: at most 5,000 active fences per workspace, 500 vertices per polygon, and 250,000 polygon vertices in total across a workspace's active fences. The total counts every ring of every polygon (including each ring's closing point); circles count 0. A save that would take the total past 250,000 is refused with
GEOFENCE_VERTEX_BUDGET_EXCEEDED, which gives the current total, the requested total and the limit. Changes that don't add vertices, such as renaming, deactivating or simplifying a polygon, are always allowed. Each point is tested only against the fences near it, so a large fence list doesn't slow evaluation down. - Timing: points more than 5 minutes in the future (by the server clock) are dropped and counted as
futureDropped. Points more than 24 hours old are kept in the location history but aren't checked against fences; this is the same window as device-reported geofence events, and these points are counted ingeofence_points_skipped_total{reason="too_old"}. Evaluation runs just after the batch is stored, and it can't slow down or fail the upload. - Many overlapping fences: each point is checked against at most 50 nearby fences. When more overlap, the lowest priority values win, then the nearest. Fences the device is already inside are always checked, so an exit is never missed. Skipped checks are counted in
geofence_candidates_truncated_total. A very large batch is applied in consecutive steps with the same result; extra steps are counted ingeofence_transition_chunks_total. - With on-device geofences: a stay the app has already reported fires nothing more here, and the other way round (On-device geofences).
Geofence sequence
- The SDK downloads the nearest fences meant for this user.
If this step is skipped: Without location consent, or with the location switch off, the list is empty and nothing is watched.
- The SDK registers them with the operating system.
- The OS wakes the app on the transition, even in the background.
- Sent with the device id, the time and the position.
If this step is skipped: Without analytics consent the SDK doesn't send, and the server answers 202 LOCATION_CONSENT_REQUIRED. If the location feature switch is off, it's acknowledged and dropped (feature_disabled).
- Applied to the same per-device state as server evaluation: fires once per stay, after consent, targeting, cooldown and the daily limit; polygons are confirmed here.
- ContentFlow records one analytics event per transition, and runs the fence's actions.
- Entries, exits and dwells per fence over time, read through the management API.
Consent and privacy
- Consent: device location and geofence events require analytics consent. The profile also records a separate location-tracking consent you can segment on. Server geofences require both: they fire only for users who granted location consent, in addition to analytics consent. Without it no geofence action runs either: no tracked event, campaign, webhook or journey. A campaign action also needs consent for each channel it sends on.
- Precision: coordinates are stored as sent (no rounding). Send the precision you need: coarse fixes and larger fences reduce sensitivity. The position sent to
GET /sdk/v1/geofencesis rounded to about 100 m, used only to order the list, and not stored; the access log records that request's path without its query. The list names no fence: each entry has an id, a region and its triggers. - Retention: location history 90 days; geofence records 30 days; fences can expire on a date.
- Erasure: deleting an identity removes its location history, geofence events and analytics events. An admin can also delete a person's location history on its own.
- IP lookups run on a local copy of MaxMind's GeoLite2 database inside ContentFlow; the IP address is never sent to a third party.
GPS jitter
GPS fixes wander, so a device standing near a fence edge can look as if it keeps crossing it. Server evaluation applies three rules so that a visit gives one enter and one exit. Each rule can be tuned per fence in jitter, and every field is optional.
- Inaccurate fixes are ignored. A fix whose
accuracyis worse than the fence's threshold has no effect on that fence: it can't enter, exit or confirm anything. The threshold is set at three levels, and the most specific one wins:- Fence:
jitter.maxAccuracyM(1 to 100,000 m), used as is. - Workspace: otherwise the threshold is
max(floor, fence size). The fence size is the circle's radius, or for a polygon half the shorter side of its bounding box. The floor is the workspace'saccuracyFloorM(0 to 1,000 m; 0 means no floor), which an admin sets withPUT /api/v1/geofences/settings{ "accuracyFloorM": 30 }, reads withGET, and resets to the default withnull. - Platform default: without a workspace setting, the floor is 50 m.
geofence_inaccurate_fixes_total. - Fence:
- Exit margin. A device that is inside exits only when a fix is more than
max(exitMarginM, exitMarginAccuracyFactor × accuracy)outside the fence. The defaults are 25 m and 1×, so a fix with 40 m accuracy must be more than 40 m outside. Fixes inside that margin keep the device inside, and dwell keeps counting. Ranges:exitMarginM0 to 5,000 m,exitMarginAccuracyFactor0 to 5. - Enter confirmation. An enter fires on the second consecutive inside fix by default (
enterConfirmPoints: 2), or on the first inside fix that is at leastenterConfirmMs(default 30,000) after the first one, whichever comes first. A fix outside the fence, or in the exit margin, starts the count again. The enter is timed at the confirming fix, and dwell and the exit's duration are measured from it. SetenterConfirmPoints: 1to enter on the first inside fix. Ranges:enterConfirmPoints1 to 10,enterConfirmMs0 to 1,800,000 (0 turns the time rule off).
PATCH /api/v1/geofences/branch-olaya
{ "jitter": { "exitMarginM": 40, "enterConfirmPoints": 3, "enterConfirmMs": 60000 } }
// "jitter": null goes back to the defaultsA fix sent without accuracy is treated as exact, and is counted in geofence_points_without_accuracy_total. These rules apply to server evaluation only. The pending state is kept with the same per-device state as enter, exit and dwell, so the rules hold when several servers handle one device.
Geofence actions
A trigger that passes every check described in Server geofences (location consent, targeting, cooldown, daily limit), whether the app reported it or the server evaluated it, runs the fence's actions. A trigger fires its actions once, even when several servers handle the same upload.
| Action | What happens |
|---|---|
trackEvent + eventData | An analytics event with that name, its eventData plus fenceId, fenceName, trigger and source: 'server'. Never coordinates: eventData can't contain location keys and is at most 2 KB. SIGN_UP, SIGN_IN and the GEOFENCE_* names are reserved. |
campaignId + campaignChannels | That campaign is sent to that user on the channels you picked for the fence. See Campaign action channels below. |
webhook | A signed HTTPS POST to your URL (below). Retried for about 45 minutes if your server is down. |
| Journeys | No fence action needed: a journey with an event trigger on GEOFENCE_ENTER, GEOFENCE_EXIT or GEOFENCE_DWELL, optionally narrowed to one geofence, enrolls the user. A person enters a journey once, so a stay reported by both the server and the app enrolls once. |
blockKey | Not available yet: stored, not executed. |
Campaign action channels. Channels are chosen at two levels, and neither has an "all" option:
- The campaign defines the channels it has: its main channel and any extra channels, each with its own content.
- The fence picks which of those to send on, in
actions.campaignChannels, for example["push", "sms"].
The rules for campaignChannels:
- It is required whenever
campaignIdis set, and must contain at least one channel. Nothing is selected by default. - Allowed values are
push,sms,whatsappandemail. In-app and banner content is delivered when the app syncs, not sent, so it can't be selected. - Saving a fence with a channel the campaign doesn't have returns a 400 that names the campaign's channels.
- In Analytics → Location, edit the fence, pick the campaign, then tick its channels.
When the fence triggers, only channels that are both selected on the fence and still on the campaign are sent. Each channel is handled on its own:
- A channel removed from the campaign since the fence was saved is skipped as
channel_not_in_campaign. - Each channel checks its own consent: an SMS opt-out blocks SMS but not push. A PDPL opt-out blocks every channel.
- Each channel needs its own contact: a registered device for push (every device the user has), a phone number for SMS and WhatsApp, an email address for email.
- The campaign's frequency cap is counted per channel.
- One channel failing doesn't stop the others.
The campaign itself must be live, which means it was approved, not archived, and inside its schedule. Each channel is sent at most once per trigger. The per-channel outcome is shown in the fence's Server actions column.
Webhooks. The URL must be https://, on port 443 or 1024 to 65535, with no user name or password, and on a public host. Private, loopback, link-local and cloud metadata addresses are refused when the fence is saved, and again on every send after the name is resolved. Redirects are not followed, each attempt times out after 5 seconds, and up to 5 attempts are made with backoff (30 s, 2 min, 10 min, 30 min). Any 2xx is success. A 3xx, or a 4xx other than 408 and 429, is final.
Create the workspace's signing secret once, as an admin, with POST /api/v1/geofences/webhook-secret. The answer is the only time you see it, and posting again rotates it. The body has ids only, no personal data:
POST https://your-server.example.com/geofence
X-CF-Event: geofence.enter
X-CF-Delivery: 3f6c… (the same on every retry: use it to drop duplicates)
X-CF-Timestamp: 1790000000
X-CF-Signature: sha256=<hex HMAC-SHA256(secret, "<X-CF-Timestamp>.<raw body>")>
{"id":"3f6c…","type":"geofence.enter","createdAt":"2026-10-02T09:00:00.000Z","tenantId":"…",
"data":{"fenceId":"branch-olaya","trigger":"enter","at":"2026-10-02T09:00:00.000Z","env":"live",
"userId":"…","deviceId":"…"}}Verify every request before trusting it (Node.js):
const crypto = require('crypto');
function verifyContentFlowWebhook(rawBody, headers, secret) {
const ts = Number(headers['x-cf-timestamp']);
if (!Number.isInteger(ts) || Math.abs(Date.now() / 1000 - ts) > 300) return false; // replay window
const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex');
const given = String(headers['x-cf-signature'] || '');
return given.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}
// Use the raw request body exactly as received, not re-serialised JSON.userId is present only for a signed-in user, and deviceId is always present. Deliveries are at least once, so keep X-CF-Delivery to drop repeats. Per fence, Analytics → Location shows which actions are set, how many server triggers fired in the last 7 days, and how the campaign and webhook actions went.
When it doesn't work
| Situation | Response |
|---|---|
| No device id | 400 deviceId required |
More than 100 geofenceEvents or more than 100 locations in one upload | 400 TOO_MANY_GEOFENCE_EVENTS / TOO_MANY_LOCATIONS; nothing is queued, so split the batch and send again |
| Upload body over 1 MiB | 413 PAYLOAD_TOO_LARGE |
| Device never identified | 202 success: false, DEVICE_NOT_IDENTIFIED |
| No analytics consent | 202 LOCATION_CONSENT_REQUIRED |
| A device sends more than 60 location requests in a minute | 429; slow down and honour Retry-After |
| User id doesn't match the device | 403 USER_MISMATCH |
| Location switched off for the workspace | 202 feature_disabled; the SDK pauses tracking |
| Without OS permission (Swift) | permissionNotRequested / permissionDenied thrown by startLocationTracking |
| Fence management | 400 validation (including more than 500 polygon vertices), 400 GEOFENCE_LIMIT_REACHED past 5,000 active fences, 400 GEOFENCE_VERTEX_BUDGET_EXCEEDED past 250,000 polygon vertices in total, 409 duplicate fence id, 404 |
GET /geofences without X-CF-Device, or a bad lat, lng or max | 400 |
GET /geofences returns no fences | The user has no location consent, no fence targets them, or the workspace switched location off |
| Device state or the fence list temporarily unavailable | 503 / 502 with retryable: true; keep the fences you have |
| Server geofence never fires | Check the user's location consent; GET /api/v1/geofences/consent-skips counts the points skipped without it. For a small fence, check the fixes' accuracy against its threshold (GPS jitter) |
Next
- Segments: target people by
currentLocationcity, region or country. - SDK guide: on-device geofences: permissions, region limits and reporting rules on every SDK.