{
  "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). The agent-side `get_location` tool is a separate thing - see [location](location.md).\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 does `/services/location/ip` see?\n\nWhoever made the HTTP request. This trips people up:\n\n| Who calls it | Body | Resolved IP |\n|---|---|---|\n| Browser (frontend) | `{}` | The user's IP ✓ |\n| Function (your backend) | `{}` | The **sandbox's IP** - never what you want ✗ |\n| Function (your backend) | `{ 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?\"** the simplest answer is: **have the browser call it** and pass the city into your function as part of the body. One call, no plumbing. See Pattern A.\n\n**If you need a server-side lookup** (e.g. to log or gate on it), forward the user's IP via the X-Forwarded-For header that the platform puts in `ctx.headers`. See Pattern B.\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## Pattern B - Serverless function (your own backend function)\n\nA function's only injected APIs are `{ db, fetch, secrets, env, console }`. There is NO `location` object - call the HTTPS endpoint via `fetch`. Since `ctx` doesn't include the project GUID, stash it in env vars (`gipity env set PROJECT_GUID ...`).\n\n```js\nexport default async function myFn(ctx, { fetch, env }) {\n  // 1. Get an app token for THIS project\n  const tokenRes = await fetch('https://a.gipity.ai/api/token', {\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify({ app: env.PROJECT_GUID }),\n  });\n  const { data: { token } } = await tokenRes.json();\n\n  // 2. Call the location service. To get the USER's city (not the sandbox's),\n  //    forward the original X-Forwarded-For - the platform puts it in ctx.headers.\n  const userIp = ctx.headers['x-forwarded-for']?.split(',')[0].trim();\n  const geoRes = await fetch(\n    `https://a.gipity.ai/api/${env.PROJECT_GUID}/services/location/ip`,\n    {\n      method: 'POST',\n      headers: { 'Content-Type': 'application/json', 'X-App-Token': token },\n      body: JSON.stringify({ ip: userIp }),\n    },\n  );\n  const geo = (await geoRes.json()).data;\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  fetch_domains: ['a.gipity.ai']   # REQUIRED - fetch is blocked without it\n  services: ['location']           # declarative; keep honest\n```\n\n## Common mistakes to avoid\n\n- **Do not write** `services.location.get(...)` or `{ db, location }` - `location` is not injected. Use `fetch`.\n- **Do not use relative URLs** from a deployed app (`/api/token`, `/services/location/ip`) - they hit the app CDN host, not the API. Always absolute: `https://a.gipity.ai/...`.\n- **Do not forget `fetch_domains: ['a.gipity.ai']`** in function permissions - the sandbox blocks undeclared-domain fetches.\n- **`ctx` does not contain the project GUID.** Use `env.PROJECT_GUID` (set via `gipity env set`) or a secret.\n- **Calling `/location/ip` from a function with an empty body** resolves the sandbox's IP, not the user's. Forward `x-forwarded-for`.\n\n## Related skills\n\n- [location](location.md) - the agent-side `get_location` tool and user-scoped CLI/REST routes\n- [app-development](app-development.md) - writing functions, `gipity.yaml`, and `fetch` permissions\n"
}
