Deployed apps resolve a user's city / region / country - from their IP, or from lat/lon coordinates - through a first-party service endpoint. No third-party geocoder, no account, no API key.

Reach for this before pulling in BigDataCloud, Nominatim, ipapi, or any other external geocoder: this service is free, rate-limit-friendly, and keeps user coordinates off third parties.

This is the app service (deployed apps call it over HTTPS). The agent-side get_location tool is a separate thing - see location.

Endpoints

POST https://a.gipity.ai/api/<PROJECT_GUID>/services/location/ip POST https://a.gipity.ai/api/<PROJECT_GUID>/services/location/geocode

Both take an X-App-Token header - the same auth pattern app-llm, app-image, and app-tts use.

Response shape

Both endpoints return the same unified object under data:

{
  source: 'ip' | 'geocode',
  city: string | null,
  region: string | null,
  country: string | null,
  timezone: string | null,
  lat: number | null,
  lon: number | null,
}

Fields that can't be resolved are null. Private IPs (10.*, 192.168.*, 172.16-31.*, 127.0.0.1) return an error - there's no useful answer for those.

Whose IP gets resolved?

An IP lookup resolves whoever's IP it's given - and only the browser automatically knows the user's IP:

Where Call Resolved IP
Browser (frontend) /services/location/ip with {} The user's IP ✓
Function (your backend) location.ip() with no IP The server's IP - never what you want ✗
Function (your backend) location.ip({ ip: ctx.headers['x-forwarded-for']?.split(',')[0].trim() }) The user's IP ✓

If you just want "what city is the user from?" just have the browser call it (Pattern A) and pass the city into your function in the body. One call, no plumbing.

If you need a server-side lookup (to log or gate on it), call the injected location capability from your function and forward the user's IP from ctx.headers['x-forwarded-for']. See Pattern B.

Capability boundary: "current location" exists only where there's a browser. A workflow step, a scheduled (cron) run, and a gipity test call all invoke your function with no caller IPctx.headers['x-forwarded-for'] is absent, and location.ip() with no public { ip } throws. A workflow therefore cannot resolve the user's current location on its own; design the feature so the browser captures the location (Pattern A or navigator.geolocation) and stores it, and the workflow reads the stored position. Decide this before writing the workflow — it changes the data flow, not just a call site.

Verifying location headlessly? When you load an app through gipity page inspect / page eval, a frontend /location/ip lookup resolves the sandbox's egress IP, not your own — it typically reports somewhere like Boardman, Oregon (the cloud region the headless browser runs in), not the user's real city. That's expected: the only IP the browser knows in that context is the sandbox's. To verify real-user geolocation, load the app in a normal browser, or pass a known { ip } explicitly.

Pattern A - Frontend JS (deployed web app)

// 1. Get an app token - absolute URL to the API server, not relative
const tokenRes = await fetch('https://a.gipity.ai/api/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ app: '<PROJECT_GUID>' }),
});
const { data: { token } } = await tokenRes.json();

// 2. Call the location service
const geoRes = await fetch(
  `https://a.gipity.ai/api/<PROJECT_GUID>/services/location/ip`,
  {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', 'X-App-Token': token },
    body: JSON.stringify({}),  // empty body = caller's IP; or { ip: '8.8.8.8' }
  },
);
const { data } = await geoRes.json();
// data = { source, city, region, country, timezone, lat, lon }

Reverse-geocode: POST /services/location/geocode with { lat, lon } - useful after a browser navigator.geolocation fix to turn coords into a city name.

Continuous tracking on mobile (run/ride/delivery/field apps)

Any phone-first app that tracks movement over time must survive a screen lock. Defaults fail: a locked screen throttles/suspends JS timers and the geolocation watch, wrecking distance/time.

  1. Watch, don't poll. Use navigator.geolocation.watchPosition(onFix, onErr, { enableHighAccuracy: true, maximumAge: 0 }) for live paths - one watch, not repeated getCurrentPosition. clearWatch(id) on stop.
  2. Hold a Screen Wake Lock so the screen never locks. Feature-detect, re-acquire on visibilitychange (the lock auto-releases when the tab hides), release on stop:
let lock = null;
async function acquireLock() {
  if ('wakeLock' in navigator) lock = await navigator.wakeLock.request('screen');
}
document.addEventListener('visibilitychange', () => {
  if (lock !== null && document.visibilityState === 'visible') acquireLock();
});
// start: await acquireLock();  stop: lock?.release(); lock = null;
  1. Derive elapsed/distance from fix timestamps, not timer ticks. Backgrounded tabs throttle setInterval/setTimeout, so compute elapsed from Date.now() deltas and distance from each fix's coords + position.timestamp - never from counting ticks.

Handle the no-GPS path - use Gipity.geo.track(), don't hand-roll it

The trap that bites every tracker app: navigator.geolocation exists in every desktop browser, so a bare if (!navigator.geolocation) guard passes and the app "starts tracking" with no GPS behind it. A desktop returns only a coarse Wi-Fi/IP estimate (or nothing), your accuracy filter silently drops it, nothing plots, and the user stares at whatever the map defaulted to - which is why you must never seed the map with a hardcoded real-world location (a default center like [37.77, -122.41] reads as "you are here" and looks like a mislocation). Open on a neutral, zoomed-out view until you have a real fix.

The client SDK ships a headless helper that gets the gating right so you don't re-derive it. It gates on the first fix: locating until a usable fix arrives, then tracking; a first fix coarser than gpsAccuracyM (no real GPS - the desktop case), a permission denial, a timeout, or an unsupported device each end in one terminal onUnavailable({ reason, message }) with ready-to-show copy, and the watch is stopped for you. It owns no UI and no distance math - render the states and filter fixes in your app.

const handle = Gipity.geo.track({
  gpsAccuracyM: 100,        // first fix must be at least this accurate to count as GPS (default 100)
  timeoutMs: 18000,         // give up waiting for the first fix after this (default 18s)
  onState: (s) => showOverlay(s === 'locating'),          // 'locating' | 'tracking'
  onFix:   (f) => addPoint(f.lat, f.lng),                 // { lat, lng, accuracyM, speedKmh, heading, t }
  onUnavailable: (info) => showMessage(info.message),     // info.reason: 'no-gps'|'denied'|'timeout'|'unavailable'|'unsupported'
});
// stop on finish/unmount: handle.stop();

Gipity.geo.supported() returns whether the API is even present. Hand-roll watchPosition only if you need something the helper doesn't cover - and if you do, still apply the three rules above (gate on the first fix, treat a coarse-only fix as no-GPS, never fake a default location).

Pattern B - Serverless function (your own backend function)

location is injected as a capability on the function's second argument - location.ip({ ip }) and location.geocode({ lat, lon }). The call runs server-to-server in-process: no token, no fetch, no a.gipity.ai (which the sandbox blocks anyway), no project GUID to stash. Because the server can't see the user's IP, pass it explicitly from ctx.headers['x-forwarded-for']:

export default async function myFn(ctx, { location }) {
  const userIp = ctx.headers['x-forwarded-for']?.split(',')[0].trim();
  const geo = await location.ip({ ip: userIp });   // omit { ip } and you'd resolve the SERVER's IP
  return { city: geo.city, country: geo.country };
}

Required gipity.yaml permissions (see deploy):

- name: myFn
  auth: public
  services: ['location']   # required - without it, location.* throws. No fetch_domains needed.

Reverse-geocode from a function the same way: await location.geocode({ lat, lon }). location is not credit-billed; other app services (llm/image/…) bill the app owner - see app-development.

Common mistakes to avoid

Related skills