{
  "name": "app-location",
  "title": "App API - Location Service",
  "description": "Location service for deployed apps: IP geolocation and reverse-geocoding via the app-scoped service endpoint - first-party, no API key",
  "guid": "sk_plat_aloc",
  "category": "App services",
  "requiredTools": [],
  "content": "# App API - Location Service\n\nDeployed 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.\n\nReach 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.\n\n> This is the **app service** (deployed apps call it over HTTPS). To look up a location yourself while building, use `gipity location`.\n\n## Endpoints\n\n> `POST https://a.gipity.ai/api/<PROJECT_GUID>/services/location/ip`\n> `POST https://a.gipity.ai/api/<PROJECT_GUID>/services/location/geocode`\n\nBoth take an `X-App-Token` header - the same auth pattern `app-llm`, `app-image`, and `app-tts` use.\n\n- `/location/ip` - body `{}` (the caller's IP) or `{ ip: '8.8.8.8' }` (a specific IP)\n- `/location/geocode` - body `{ lat, lon }` to reverse-geocode coordinates into a place\n\n## Response shape\n\nBoth endpoints return the same unified object under `data`:\n\n```\n{\n  source: 'ip' | 'geocode',\n  city: string | null,\n  region: string | null,\n  country: string | null,\n  timezone: string | null,\n  lat: number | null,\n  lon: number | null,\n}\n```\n\nFields 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.\n\n## Whose IP gets resolved?\n\nAn IP lookup resolves whoever's IP it's given - and only the **browser** automatically knows the user's IP:\n\n| Where | Call | Resolved IP |\n|---|---|---|\n| Browser (frontend) | `/services/location/ip` with `{}` | The user's IP ✓ |\n| Function (your backend) | `location.ip()` with no IP | The **server's IP** - never what you want ✗ |\n| Function (your backend) | `location.ip({ ip: ctx.headers['x-forwarded-for']?.split(',')[0].trim() })` | The user's IP ✓ |\n\n**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.\n\n**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.\n\n> **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 IP** — `ctx.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.\n\n> **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.\n\n## Pattern A - Frontend JS (deployed web app)\n\n```js\n// 1. Get an app token - absolute URL to the API server, not relative\nconst tokenRes = await fetch('https://a.gipity.ai/api/token', {\n  method: 'POST',\n  headers: { 'Content-Type': 'application/json' },\n  body: JSON.stringify({ app: '<PROJECT_GUID>' }),\n});\nconst { data: { token } } = await tokenRes.json();\n\n// 2. Call the location service\nconst geoRes = await fetch(\n  `https://a.gipity.ai/api/<PROJECT_GUID>/services/location/ip`,\n  {\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json', 'X-App-Token': token },\n    body: JSON.stringify({}),  // empty body = caller's IP; or { ip: '8.8.8.8' }\n  },\n);\nconst { data } = await geoRes.json();\n// data = { source, city, region, country, timezone, lat, lon }\n```\n\nReverse-geocode: `POST /services/location/geocode` with `{ lat, lon }` - useful after a browser `navigator.geolocation` fix to turn coords into a city name.\n\n## Continuous tracking on mobile (run/ride/delivery/field apps)\n\nAny 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.\n\n1. **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.\n2. **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:\n\n```js\nlet lock = null;\nasync function acquireLock() {\n  if ('wakeLock' in navigator) lock = await navigator.wakeLock.request('screen');\n}\ndocument.addEventListener('visibilitychange', () => {\n  if (lock !== null && document.visibilityState === 'visible') acquireLock();\n});\n// start: await acquireLock();  stop: lock?.release(); lock = null;\n```\n\n3. **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.\n\n### Handle the no-GPS path - use `Gipity.geo.track()`, don't hand-roll it\n\nThe 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.\n\nThe 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.\n\n```js\nconst handle = Gipity.geo.track({\n  gpsAccuracyM: 100,        // first fix must be at least this accurate to count as GPS (default 100)\n  timeoutMs: 18000,         // give up waiting for the first fix after this (default 18s)\n  onState: (s) => showOverlay(s === 'locating'),          // 'locating' | 'tracking'\n  onFix:   (f) => addPoint(f.lat, f.lng),                 // { lat, lng, accuracyM, speedKmh, heading, t }\n  onUnavailable: (info) => showMessage(info.message),     // info.reason: 'no-gps'|'denied'|'timeout'|'unavailable'|'unsupported'\n});\n// stop on finish/unmount: handle.stop();\n```\n\n`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).\n\n## Pattern B - Serverless function (your own backend function)\n\n`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']`:\n\n```js\nexport default async function myFn(ctx, { location }) {\n  const userIp = ctx.headers['x-forwarded-for']?.split(',')[0].trim();\n  const geo = await location.ip({ ip: userIp });   // omit { ip } and you'd resolve the SERVER's IP\n  return { city: geo.city, country: geo.country };\n}\n```\n\nRequired `gipity.yaml` permissions (see [deploy](deploy.md)):\n\n```yaml\n- name: myFn\n  auth: public\n  services: ['location']   # required - without it, location.* throws. No fetch_domains needed.\n```\n\n> 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](app-development.md).\n\n## Common mistakes to avoid\n\n- **From a function, don't `fetch` `a.gipity.ai`** to reach location - the sandbox blocks it (`fetch_domains: ['a.gipity.ai']` won't help). Use the injected `location.ip()` / `location.geocode()` capability and declare `services: ['location']`.\n- **Do not use relative URLs** from the browser (`/api/token`, `/services/location/ip`) - they hit the app CDN host, not the API. Always absolute: `https://a.gipity.ai/...`.\n- **Calling `location.ip()` from a function with no `{ ip }`** resolves the server's IP, not the user's. Forward `ctx.headers['x-forwarded-for']`.\n- **Expecting a workflow/scheduled run to know \"where the user is now\"** - those contexts carry no caller IP and no browser, so there is nothing to resolve. Capture location browser-side, store it, and have the workflow read the stored value (see the capability-boundary note above).\n\n## Related skills\n\n- [app-development](app-development.md) - writing functions, `gipity.yaml`, and `fetch` permissions\n"
}
