Request
Tip: reply first, process later
Reply 2xx (for example 204) as soon as the request arrives, and do the database writes and other processing after replying. The app doesn't wait for a response before sending the next point, which can be as often as once per second. If your server replies slowly, unanswered requests pile up; once 5 are pending, the app keeps only the newest point and the ones in between are never sent.
- URL
- Must be https://.
- Method
- POST (default) or PUT, set per server.
- Headers
- Content-Type: application/json, plus the headers you set in the app. Content-Type, Content-Length, Host, Connection, and Transfer-Encoding are set by the app; headers you add with these names are ignored. The operating system also adds a User-Agent header on its own; its content is set by the system (see the privacy policy).
- Body
- A single line of JSON, not indented and not gzipped. About 750 bytes.
- When
- Once per captured point. With several servers (up to 5 checked), each point is sent to every one of them with identical content. At the fastest: once per second in time mode, and once per system location update in distance mode set to 0 m.
- Order
- The next request doesn't wait for the previous response, so requests may arrive out of order.
- Concurrency
- At most 5 requests per server can be waiting for a response. When all 5 are taken, the next point waits and is sent as soon as one frees up. Only the newest point is kept while waiting; a point that gets replaced is not sent and counts as a failure. The slower your server replies, the more points are dropped.
- Timeout
- 10 seconds.
- Success
- We recommend replying 204 with no body. Any HTTP 2xx counts as success, even with an empty body. Exception: if the response Content-Type is JSON (application/json or ending in +json, before any semicolon) but the body can't be parsed, it counts as a failure. The JSON content itself is not checked, and the body of a non-JSON response is not read.
- Response size
- Up to 1 MB is read. Anything larger is cut off and counts as a failure.
- Failure
- No retries and no offline queue, so your server never receives that point (it is still in the record on the phone and can be exported). Your server can tell how many points were lost from gaps in pt.id.
General Rules
- Structure
- The top level has six groups: dev, perm, bat, net, rule, and pt. Each group is flat, so nesting is at most two levels.
- Missing values
- Fields a platform doesn't have, and values that couldn't be obtained this time, are always null. The key is always present. -1, 0, or an empty string is never used instead.
- Time
- Epoch milliseconds, an integer in UTC.
- Enums
- Integers starting from 1; 0 is never used. New values are appended, and existing numbers never change.
- Decimals
- Rounded before sending: lat and lng to 7 places (about 1 cm), spd and spdAcc to 2, and other meters, degrees, and °C values to 1.
- Ordering
- Sort by pt.id. pt.time may equal the previous point's, and can even go backwards if the phone's clock changes.
- Platform
- Check dev.os. A key means the same thing on both platforms; the only difference is whether it has a value.
- New fields
- Later versions may add keys to any group. Skip keys you don't recognize instead of rejecting the whole request. Older app versions that haven't been updated don't send newer keys; treat them as null. The format version is the major and minor number of ver.
Fields without a platform note have values on both iOS and Android.
Receiver Examples
The key steps to receive a point: check the method and header, parse the JSON, store it, and reply with a 2xx. The examples use X-Api-Key: change-me as the token; replace it with the header you set in the app. For production, add HTTPS, a database, and error handling.
<?php$apiKey = 'change-me'; // POST by default, or PUT if set in the app$method = $_SERVER['REQUEST_METHOD'];if ($method !== 'POST' && $method !== 'PUT') { http_response_code(405); exit;} // The header you added in the app (any name and value)if (!hash_equals($apiKey, $_SERVER['HTTP_X_API_KEY'] ?? '')) { http_response_code(401); exit;} // The body is one line of JSON, so $_POST stays empty$body = file_get_contents('php://input');$payload = json_decode($body, true);if (!is_array($payload['dev'] ?? null) || !is_array($payload['pt'] ?? null)) { http_response_code(400); exit;} $dev = $payload['dev'];$pt = $payload['pt']; // pt.trig 4 is "Send test": pt.id is 0 and lat/lng may be nullif ($pt['trig'] !== 4) { file_put_contents('points.jsonl', $body . "\n", FILE_APPEND | LOCK_EX); error_log("{$dev['id']} {$payload['startAt']} #{$pt['id']} {$pt['lat']},{$pt['lng']}");} // Any 2xx is a success (10 s timeout, no retries)http_response_code(204);Test Without a Phone
The release app only sends to https://, so it can't reach your machine during local development. Use curl to send a sample to the local receiver examples (port 8080). The body is the iOS example.
curl -i -X POST http://localhost:8080/ \ -H 'Content-Type: application/json' \ -H 'X-Api-Key: change-me' \ -d '{"ver":"1.0.0","sentAt":1759631403120,"startAt":1759630000000,"dev":{"id":"3F2A9C1E-7B4D-4E8A-9C21-5D6E7F809A1B","os":"ios","osVer":"15.8.8","appVer":"1.0.0","build":"1","tz":"Asia/Taipei","locale":"zh-TW","mfr":"Apple","model":"iPhone9,2","isSim":false},"perm":{"loc":2,"precise":true,"noBatOpt":null},"bat":{"lvl":85,"state":1,"plug":null,"temp":null,"volt":null,"lowPower":false,"thermal":1},"net":{"type":2,"metered":true,"lowData":false,"gen":4},"rule":{"name":"Survey","mode":1,"interval":30,"distance":null,"heartbeat":null,"power":1},"pt":{"id":1024,"time":1759631398000,"trig":1,"lat":23.5678901,"lng":120.3045672,"acc":4.8,"alt":12.3,"ellAlt":31.5,"altAcc":3.0,"spd":1.2,"spdAcc":0.5,"crs":87.5,"crsAcc":12.0,"satUsed":null,"satView":null,"isSim":false,"isExt":false,"held":false,"suspect":false}}'Build It with AI
This spec, the four receiver examples and a test script are packaged as an Agent Skill. Once it's installed, ask your AI to "write a GeoCourier receiver" and it follows the spec, including the details that are easy to miss: Send Test, out-of-order requests, null values and replying 204. The skill and the spec always describe the latest format; download it again when the app changes the format.
Install with One Command
It unzips into two places: ~/.claude/skills for Claude Code, and ~/.agents/skills for Codex, GitHub Copilot and Cursor. Run it again to update.
curl -fsSL https://tracker.iwi.tw/skill/geocourier-receiver.zip -o /tmp/geocourier-receiver.zipmkdir -p ~/.claude/skills ~/.agents/skillsunzip -oq /tmp/geocourier-receiver.zip -d ~/.claude/skillsunzip -oq /tmp/geocourier-receiver.zip -d ~/.agents/skillsWhere Each Tool Looks
| Tool | Personal (all projects) | Project (in version control) |
|---|---|---|
| Claude Code | ~/.claude/skills/ | .claude/skills/ |
| Codex | ~/.agents/skills/ | .agents/skills/ |
| GitHub Copilot | ~/.copilot/skills/ | .github/skills/ |
| Cursor | ~/.cursor/skills/ | .cursor/skills/ |
Paths follow each tool's official docs as of October 2026. For other tools that support Agent Skills, put the unzipped geocourier-receiver folder in their skills directory.
Claude.ai and Claude Desktop
In Settings → Capabilities, turn on Code execution, then click Upload skill in the Skills section and choose the downloaded zip.
AI Without Skill Support
Give it https://tracker.iwi.tw/spec.md, or download the file and upload it. It's the same spec as plain text; this page is rendered with JavaScript, so most AI tools can't read it.
Things to Ask
- Write a GeoCourier receiver in Laravel that stores points in MySQL
- My Express server isn't receiving GeoCourier data. Help me find out why
- What is rule.heartbeat in GeoCourier, and how do I tell when a phone went offline?
- Design a table for GeoCourier points so I can draw the route of each session
Top level
verversionstringFormat version. It follows the app version and equals dev.appVer, for example 1.0.0. Use the major and minor numbers to tell formats apart: any change to the payload structure comes with a new major or minor version (for example 1.0 to 1.1). Versions that differ only in the patch number (1.0.0, 1.0.3) send the same format; the patch is for app-only fixes.
sentAtsent atint (ms)When this request was sent.
startAtstart atint (ms)May be null in Send TestWhen Start Tracking was tapped for this session. pt.id doesn't reset when tracking restarts, so use this to tell sessions apart. null for a Send Test while not tracking.
devdeviceDevice, mostly fixed after install
idstringA UUID generated when the app is installed. It is not included in system backups, so reinstalling the app or restoring a backup to a new phone creates a new one. Not a hardware identifier or an advertising ID.
osoperating systemstringPlatform.
iosiOSandroidAndroid
osVerOS versionstringOS version, for example 15.8.8 (iOS) or 15 (Android).
appVerapp versionstringApp version, for example 1.0.0.
buildstringApp build number.
tztime zonestringIANA time zone, for example Asia/Taipei.
localestringThe phone's language and region, always in the form language-region, for example zh-TW, en-US, ja-JP. If there's no region, only the language is sent. The language is the one the user picked for this app, or the phone's first language if they didn't pick one. Traditional and Simplified Chinese aren't told apart: a phone set to Simplified Chinese with the region Hong Kong sends zh-HK.
mfrmanufacturerstringManufacturer.
- iOS
- Always Apple
- Android
- For example OPPO
modelstringModel identifier, not the marketing name.
- iOS
- For example iPhone9,2
- Android
- For example CPH2581
isSimis simulatorboolWhether the app is running in a simulator or emulator.
- iOS
- Accurate
- Android
- No official API; detected from system traits. The official emulator is detected.
permpermissionPermissions, which the user can change at any time
loclocationintLocation access. Both 1 and 2 are normal while tracking: either one keeps tracking in the background. Send Test works without permission, in which case this is 3.
1While Using the App2Always3No permission (not asked yet, denied, or restricted)
preciseboolWhether Precise Location is allowed. When false, the location is only accurate to a few kilometers. Always false when loc is 3.
noBatOptno battery optimizationboolMay be nulltrue means the app is exempt from battery optimization and less likely to be stopped by the system in the background.
- iOS
- Not available, always null
batbatteryBattery and power
lvllevelintMay be nullBattery level, 0 to 100.
- iOS
- In 5% steps since iOS 17
- Android
- In 1% steps
stateintMay be nullCharging state. null when the system reports it as unknown.
1Unplugged2Charging3Full4Plugged in but not charging
- iOS
- Only 1 to 3
plugintMay be nullPower source. null when unplugged.
1AC2USB3Wireless4Dock
- iOS
- Not available, always null
- Android
- Dock requires Android 13 or later
temptemperaturenumberMay be nullBattery temperature in °C.
- iOS
- Not available, always null
voltvoltageintMay be nullBattery voltage in mV.
- iOS
- Not available, always null
- Android
- Sent as reported by the system. Some phones report volts instead (an integer such as 3 or 4), so check the magnitude
lowPowerboolLow Power Mode (iOS) or Battery Saver (Android).
thermalintMay be nullThe device's thermal state, on the same 1 to 7 scale for both platforms.
1Nominal2Fair3Warm4Serious (performance limited)5Critical (needs to cool down)6Emergency (some features disabled)7About to shut down
- iOS
- Only 1, 2, 4, and 5
- Android
- Android 10 or later
netnetworkNetwork
typeintMay be nullNetwork type. null when there is no connection.
1Wi-Fi2Cellular3Wired4Other
meteredboolMay be nullMetered network, such as cellular or a phone hotspot.
lowDatalow data modeboolMay be nullLow Data Mode is on.
- Android
- Not available, always null
gengenerationintMay be nullCellular generation, 2 to 5. Only has a value when net.type is 2.
- iOS
- 5G NSA (5G anchored on 4G) is reported as 5
- Android
- Android 13 or later. For 5G NSA the system reports 4G, so it is reported as 4
ruleCurrent capture rules
Users can save several capture modes and switch or edit them while tracking, so every point includes the rules in effect, letting your server work out how often to expect data.
namestringName of the capture mode, for display only. The app's built-in modes follow the app language (for example, 精準 in Chinese and Precise in English); names set by the user are sent as typed. To work out the rules, use mode, interval, distance, and heartbeat, not the name.
modeintWhat triggers a capture.
1Time2Distance
intervalint (sec)May be nullInterval in time mode. null in distance mode.
distanceint (m)May be nullDistance in distance mode. Can be 0 (capture every location update). null in time mode.
heartbeatint (sec)May be nullIn distance mode, how often a point is captured anyway while stationary. null in time mode or when turned off.
powerintThe app's power setting, which decides what location is sent while stationary.
1Full power: best accuracy at all times; a held location is sent only after 2 minutes without a new fix2Power saving: fewer fixes while stationary (lower accuracy on iOS, less often on Android), and send the held location instead (pt.held is true)
ptpointThis location point
When Android allows only approximate location (perm.precise is false), the system blurs the position: acc is about 2000 meters, and ellAlt, spd, crs, satUsed, and satView are null (tested on the emulator; not yet confirmed on a real device).
idintSequence number. Starts at 1 after install (like dev.id, it is not included in system backups) and increases by 1 for each captured point; it doesn't reset when tracking stops and starts again. It is saved on the phone before sending, so it won't repeat even if the app crashes. Always 0 for Send Test.
timeint (ms)May be null in Send TestWhen this location was determined, not when it was sent. Equals the previous point's when GPS hasn't updated or when a held location is sent while stationary (pt.held is true).
trigtriggerintWhy this point was captured.
1Time interval reached2Moved past distance3Stationary capture4Send Test
latlatitudenumberMay be null in Send TestWGS84 latitude, 7 decimal places.
lnglongitudenumberMay be null in Send TestWGS84 longitude, 7 decimal places.
accaccuracynumberMay be nullHorizontal accuracy in meters (radius).
altaltitudenumberMay be nullAltitude above mean sea level, in meters.
- Android
- Converted from ellipsoidal altitude; null if the conversion fails
ellAltellipsoidal altitudenumberMay be nullWGS84 ellipsoidal altitude, in meters.
altAccaltitude accuracynumberMay be nullVertical accuracy, in meters.
spdspeednumberMay be nullSpeed in m/s.
spdAccspeed accuracynumberMay be nullSpeed accuracy in m/s.
crscoursenumberMay be nullDirection of travel in degrees, 0 = true north, clockwise. This is where you're moving, not where the phone is pointing.
crsAcccourse accuracynumberMay be nullCourse accuracy in degrees.
satUsedsatellites usedintMay be nullNumber of satellites used for the fix. Android still reports it with the screen off. After the system turns GPS off (about 33 minutes after the screen went off, in a Xiaomi test), it carries the last value instead of null.
- iOS
- Not available, always null
satViewsatellites in viewintMay be nullNumber of satellites in view. Android still reports it with the screen off. After the system turns GPS off (about 33 minutes after the screen went off, in a Xiaomi test), it carries the last value instead of null.
- iOS
- Not available, always null
isSimis simulatedboolMay be nullWhether this location is simulated or mocked. On a real device with a mock location app, dev.isSim is false and this is true.
isExtis externalboolMay be nullWhether this location came from an external GPS accessory or CarPlay.
- Android
- Not available, always null
heldboolWhether this is a held location. In power saving mode (rule.power is 2), after the phone has been still for about 2 minutes, the app sends the most accurate fix from that stationary period, so the coordinates and pt.time repeat until it moves again. In either mode, it is also true after 2 minutes without a new fix (for example, when the system pauses GPS on a phone left still for a long time).
suspectboolWhether this point is suspect. The app compares it with the previous few points: if the phone couldn't have got this far in the elapsed time, even keeping its recent speed and accelerating as hard as plausible (with both points' pt.acc added as slack), it is true. A coarse fix (no speed, poor accuracy, usually a Wi-Fi estimate) is also compared with the last GPS fix: if it is further away than the phone could have gone at its recent speed (at least 100 m), it is true until GPS confirms it, or until it has stayed away for 20 seconds with no GPS. Suspect points are still sent; skip them when drawing a track or adding up distance. The check looks at the change in speed, not a fixed speed limit, so cars, high-speed trains and planes aren't flagged just for being fast. If a point is flagged by mistake and the next few points all land near it, the app switches to the new position as its reference.
Common Receiver Scenarios
Filtering out Send Test
A payload with pt.trig 4 and pt.id 0 came from Send Test in the app, not from real movement.
Ordering and lost points
Requests may arrive out of order, so sort by pt.id. The app doesn't retry, so a gap in pt.id means points were lost.
Telling sessions apart
Use dev.id to identify the phone and startAt to identify the session. startAt changes when tracking stops and starts again, while pt.id keeps counting up.
Detecting a dropped connection
In time mode, expect a point about every rule.interval seconds. In distance mode while stationary, expect one at least every rule.heartbeat seconds. When heartbeat is null, no data arrives while stationary, which doesn't mean the connection dropped. Users can switch capture modes while tracking, so base this on the rule in the latest point. If pt.held is true in the latest point, see the next section.
Fewer points from an Android phone that stays still
When an Android phone has its screen off, is unplugged, and has stayed still for about 30 minutes or more, the system puts it into deep sleep. During that time the app can only send when the system briefly wakes, so even with rule.interval at 3 seconds, points may arrive minutes apart (up to about 10 minutes on Xiaomi and OPPO phones in October 2026 testing, and possibly longer on other phones). These points are the held location, with pt.held set to true. To detect a dropped connection, check the latest point you received: if pt.held is false, use rule.interval; if pt.held is true, the phone is stationary, so allow at least 15 minutes, or don't treat silence alone as a dropped connection. As soon as the phone moves, it wakes up and sends at the normal interval again.
Coordinates stop changing while stationary
When pt.held is true, it's normal for the coordinates and pt.time to match the previous point: the phone isn't moving and the app is sending its held location. If pt.held is false and pt.time hasn't changed for a long time, GPS may have lost its signal.
Low-accuracy points
The app doesn't filter by accuracy, so low-accuracy points are sent too. Your server decides whether to keep them based on pt.acc. In power saving mode while stationary, the coarse fixes the app uses to detect movement are not sent; the held location (pt.held) is sent instead, and a coarse fix that lands far away is checked against GPS before the app treats it as a departure. Also, when an iPhone can't get GPS, the system estimates the position from nearby Wi-Fi. Now and then the estimate lands hundreds of meters to a kilometer away while pt.acc still reports only tens of meters (October 2026 testing). The app still sends such implausible jumps but sets pt.suspect to true; skip them when drawing a track or adding up distance. To filter further yourself, divide the distance between consecutive points by the difference in pt.time and drop points whose speed is clearly impossible. A null pt.spd alone isn't enough: most indoor iPhone points have no speed.
Fake locations
pt.isSim true means the location is simulated; dev.isSim true means the app is running in a simulator.