﻿# GeoCourier payload format

> For every point it captures, the app sends one HTTPS request to your server. This page explains how requests are sent, every field in the body, and how receivers commonly handle them.

- Endpoint: `POST / PUT https://your-server/any-path`
- The format version (ver) matches the app version. It is currently 1.0.0. This page always describes the latest format.
- Human-readable page: https://tracker.iwi.tw/spec.html
- Agent Skill for AI coding tools: https://tracker.iwi.tw/skill/geocourier-receiver.zip

## 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

Fields without a platform note have values on both iOS and Android.

### Top level

| Key | Type | Null | Description |
|---|---|---|---|
| `ver` (version) | string | No | Format 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. |
| `sentAt` (sent at) | int (ms) | No | When this request was sent. |
| `startAt` (start at) | int (ms) | May be null in Send Test | When 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. |

### `dev` (device): Device, mostly fixed after install

| Key | Type | Null | Description |
|---|---|---|---|
| `dev.id` | string | No | A 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. |
| `dev.os` (operating system) | string | No | Platform. Values: `ios` = iOS; `android` = Android. |
| `dev.osVer` (OS version) | string | No | OS version, for example 15.8.8 (iOS) or 15 (Android). |
| `dev.appVer` (app version) | string | No | App version, for example 1.0.0. |
| `dev.build` | string | No | App build number. |
| `dev.tz` (time zone) | string | No | IANA time zone, for example Asia/Taipei. |
| `dev.locale` | string | No | The 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. |
| `dev.mfr` (manufacturer) | string | No | Manufacturer. iOS: Always Apple. Android: For example OPPO. |
| `dev.model` | string | No | Model identifier, not the marketing name. iOS: For example iPhone9,2. Android: For example CPH2581. |
| `dev.isSim` (is simulator) | bool | No | Whether the app is running in a simulator or emulator. iOS: Accurate. Android: No official API; detected from system traits. The official emulator is detected. |

### `perm` (permission): Permissions, which the user can change at any time

| Key | Type | Null | Description |
|---|---|---|---|
| `perm.loc` (location) | int | No | Location 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. Values: `1` = While Using the App; `2` = Always; `3` = No permission (not asked yet, denied, or restricted). |
| `perm.precise` | bool | No | Whether Precise Location is allowed. When false, the location is only accurate to a few kilometers. Always false when loc is 3. |
| `perm.noBatOpt` (no battery optimization) | bool | May be null | true 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. |

### `bat` (battery): Battery and power

| Key | Type | Null | Description |
|---|---|---|---|
| `bat.lvl` (level) | int | May be null | Battery level, 0 to 100. iOS: In 5% steps since iOS 17. Android: In 1% steps. |
| `bat.state` | int | May be null | Charging state. null when the system reports it as unknown. Values: `1` = Unplugged; `2` = Charging; `3` = Full; `4` = Plugged in but not charging. iOS: Only 1 to 3. |
| `bat.plug` | int | May be null | Power source. null when unplugged. Values: `1` = AC; `2` = USB; `3` = Wireless; `4` = Dock. iOS: Not available, always null. Android: Dock requires Android 13 or later. |
| `bat.temp` (temperature) | number | May be null | Battery temperature in °C. iOS: Not available, always null. |
| `bat.volt` (voltage) | int | May be null | Battery 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. |
| `bat.lowPower` | bool | No | Low Power Mode (iOS) or Battery Saver (Android). |
| `bat.thermal` | int | May be null | The device's thermal state, on the same 1 to 7 scale for both platforms. Values: `1` = Nominal; `2` = Fair; `3` = Warm; `4` = Serious (performance limited); `5` = Critical (needs to cool down); `6` = Emergency (some features disabled); `7` = About to shut down. iOS: Only 1, 2, 4, and 5. Android: Android 10 or later. |

### `net` (network): Network

| Key | Type | Null | Description |
|---|---|---|---|
| `net.type` | int | May be null | Network type. null when there is no connection. Values: `1` = Wi-Fi; `2` = Cellular; `3` = Wired; `4` = Other. |
| `net.metered` | bool | May be null | Metered network, such as cellular or a phone hotspot. |
| `net.lowData` (low data mode) | bool | May be null | Low Data Mode is on. Android: Not available, always null. |
| `net.gen` (generation) | int | May be null | Cellular 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. |

### `rule`: Current 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.

| Key | Type | Null | Description |
|---|---|---|---|
| `rule.name` | string | No | Name 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. |
| `rule.mode` | int | No | What triggers a capture. Values: `1` = Time; `2` = Distance. |
| `rule.interval` | int (sec) | May be null | Interval in time mode. null in distance mode. |
| `rule.distance` | int (m) | May be null | Distance in distance mode. Can be 0 (capture every location update). null in time mode. |
| `rule.heartbeat` | int (sec) | May be null | In distance mode, how often a point is captured anyway while stationary. null in time mode or when turned off. |
| `rule.power` | int | No | The app's power setting, which decides what location is sent while stationary. Values: `1` = Full power: best accuracy at all times; a held location is sent only after 2 minutes without a new fix; `2` = Power saving: fewer fixes while stationary (lower accuracy on iOS, less often on Android), and send the held location instead (pt.held is true). |

### `pt` (point): This 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).

| Key | Type | Null | Description |
|---|---|---|---|
| `pt.id` | int | No | Sequence 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. |
| `pt.time` | int (ms) | May be null in Send Test | When 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). |
| `pt.trig` (trigger) | int | No | Why this point was captured. Values: `1` = Time interval reached; `2` = Moved past distance; `3` = Stationary capture; `4` = Send Test. |
| `pt.lat` (latitude) | number | May be null in Send Test | WGS84 latitude, 7 decimal places. |
| `pt.lng` (longitude) | number | May be null in Send Test | WGS84 longitude, 7 decimal places. |
| `pt.acc` (accuracy) | number | May be null | Horizontal accuracy in meters (radius). |
| `pt.alt` (altitude) | number | May be null | Altitude above mean sea level, in meters. Android: Converted from ellipsoidal altitude; null if the conversion fails. |
| `pt.ellAlt` (ellipsoidal altitude) | number | May be null | WGS84 ellipsoidal altitude, in meters. |
| `pt.altAcc` (altitude accuracy) | number | May be null | Vertical accuracy, in meters. |
| `pt.spd` (speed) | number | May be null | Speed in m/s. |
| `pt.spdAcc` (speed accuracy) | number | May be null | Speed accuracy in m/s. |
| `pt.crs` (course) | number | May be null | Direction of travel in degrees, 0 = true north, clockwise. This is where you're moving, not where the phone is pointing. |
| `pt.crsAcc` (course accuracy) | number | May be null | Course accuracy in degrees. |
| `pt.satUsed` (satellites used) | int | May be null | Number 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. |
| `pt.satView` (satellites in view) | int | May be null | Number 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. |
| `pt.isSim` (is simulated) | bool | May be null | Whether this location is simulated or mocked. On a real device with a mock location app, dev.isSim is false and this is true. |
| `pt.isExt` (is external) | bool | May be null | Whether this location came from an external GPS accessory or CarPlay. Android: Not available, always null. |
| `pt.held` | bool | No | Whether 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). |
| `pt.suspect` | bool | No | Whether 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. |

## Example payloads

### iOS

```json
{
  "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
  }
}
```

### Android

```json
{
  "ver": "1.0.0",
  "sentAt": 1759631403120,
  "startAt": 1759630000000,
  "dev": {
    "id": "9B7E0C42-1D3A-4F6B-8E5C-2A9D7F1B3C60",
    "os": "android",
    "osVer": "15",
    "appVer": "1.0.0",
    "build": "1",
    "tz": "Asia/Taipei",
    "locale": "zh-TW",
    "mfr": "OPPO",
    "model": "CPH2581",
    "isSim": false
  },
  "perm": {
    "loc": 2,
    "precise": true,
    "noBatOpt": false
  },
  "bat": {
    "lvl": 87,
    "state": 2,
    "plug": 2,
    "temp": 31.5,
    "volt": 4012,
    "lowPower": false,
    "thermal": 2
  },
  "net": {
    "type": 1,
    "metered": false,
    "lowData": null,
    "gen": null
  },
  "rule": {
    "name": "Walking",
    "mode": 2,
    "interval": null,
    "distance": 5,
    "heartbeat": 60,
    "power": 2
  },
  "pt": {
    "id": 88,
    "time": 1759631398000,
    "trig": 2,
    "lat": 23.5678901,
    "lng": 120.3045672,
    "acc": 3.9,
    "alt": 11.0,
    "ellAlt": 30.2,
    "altAcc": 2.5,
    "spd": 0.8,
    "spdAcc": 0.3,
    "crs": 12.0,
    "crsAcc": 20.5,
    "satUsed": 14,
    "satView": 28,
    "isSim": false,
    "isExt": null,
    "held": false,
    "suspect": false
  }
}
```

## 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.

## 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

```php
<?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 null
if ($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);
```

### Node.js

```javascript
const http = require('http')
const fs = require('fs')
const crypto = require('crypto')

const apiKey = Buffer.from('change-me')

http.createServer((req, res) => {
  // POST by default, or PUT if set in the app
  if (req.method !== 'POST' && req.method !== 'PUT') {
    res.writeHead(405).end()
    return
  }

  // The header you added in the app (any name and value)
  const key = Buffer.from(req.headers['x-api-key'] || '')
  if (key.length !== apiKey.length || !crypto.timingSafeEqual(key, apiKey)) {
    res.writeHead(401).end()
    return
  }

  let body = ''
  req.setEncoding('utf8')
  req.on('data', chunk => { body += chunk })
  req.on('end', () => {
    let payload = null
    try {
      payload = JSON.parse(body)
    } catch {}
    if (!(payload?.dev instanceof Object) || !(payload?.pt instanceof Object)) {
      res.writeHead(400).end()
      return
    }

    const { dev, startAt, pt } = payload

    // pt.trig 4 is "Send test": pt.id is 0 and lat/lng may be null
    if (pt.trig !== 4) {
      fs.appendFileSync('points.jsonl', body + '\n')
      console.log(dev.id, startAt, `#${pt.id}`, `${pt.lat},${pt.lng}`)
    }

    // Any 2xx is a success (10 s timeout, no retries)
    res.writeHead(204).end()
  })
}).listen(8080)
```

### Python

```python
import hmac
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

API_KEY = b'change-me'


class Receiver(BaseHTTPRequestHandler):
    # POST by default, or PUT if set in the app
    def do_POST(self):
        # The header you added in the app (any name and value)
        if not hmac.compare_digest(self.headers.get('X-Api-Key', '').encode(), API_KEY):
            return self.reply(401)

        body = self.rfile.read(int(self.headers.get('Content-Length', 0)))
        try:
            payload = json.loads(body)
        except ValueError:
            return self.reply(400)
        dev = payload.get('dev') if isinstance(payload, dict) else None
        pt = payload.get('pt') if isinstance(payload, dict) else None
        if not isinstance(dev, dict) or not isinstance(pt, dict):
            return self.reply(400)

        # pt.trig 4 is "Send test": pt.id is 0 and lat/lng may be null
        if pt['trig'] != 4:
            with open('points.jsonl', 'ab') as f:
                f.write(body + b'\n')
            print(dev['id'], payload['startAt'], pt['id'], pt['lat'], pt['lng'], flush=True)

        # Any 2xx is a success (10 s timeout, no retries)
        self.reply(204)

    do_PUT = do_POST

    def do_GET(self):
        self.reply(405)

    def reply(self, status):
        self.send_response(status)
        self.end_headers()


ThreadingHTTPServer(('', 8080), Receiver).serve_forever()
```

### Go

```go
package main

import (
	"crypto/subtle"
	"encoding/json"
	"io"
	"log"
	"net/http"
	"os"
)

const apiKey = "change-me"

// Only the fields you need; the rest are ignored
type Payload struct {
	StartAt int64 `json:"startAt"`
	Dev     *struct {
		ID string `json:"id"`
	} `json:"dev"`
	Pt *struct {
		ID   int64   `json:"id"`
		Trig int     `json:"trig"`
		Lat  float64 `json:"lat"`
		Lng  float64 `json:"lng"`
	} `json:"pt"`
}

func receive(w http.ResponseWriter, r *http.Request) {
	// POST by default, or PUT if set in the app
	if r.Method != http.MethodPost && r.Method != http.MethodPut {
		w.WriteHeader(http.StatusMethodNotAllowed)
		return
	}

	// The header you added in the app (any name and value)
	if subtle.ConstantTimeCompare([]byte(r.Header.Get("X-Api-Key")), []byte(apiKey)) != 1 {
		w.WriteHeader(http.StatusUnauthorized)
		return
	}

	body, err := io.ReadAll(r.Body)
	var p Payload
	if err != nil || json.Unmarshal(body, &p) != nil || p.Dev == nil || p.Pt == nil {
		w.WriteHeader(http.StatusBadRequest)
		return
	}

	// pt.trig 4 is "Send test": pt.id is 0 and lat/lng may be null
	if p.Pt.Trig != 4 {
		f, err := os.OpenFile("points.jsonl", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
		if err == nil {
			f.Write(append(body, '\n'))
			f.Close()
		}
		log.Println(p.Dev.ID, p.StartAt, p.Pt.ID, p.Pt.Lat, p.Pt.Lng)
	}

	// Any 2xx is a success (10 s timeout, no retries)
	w.WriteHeader(http.StatusNoContent)
}

func main() {
	http.HandleFunc("/", receive)
	log.Fatal(http.ListenAndServe(":8080", nil))
}
```

## 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.

```sh
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}}'
```

