Subscriptions & callbacks

A subscription = rule + target. When the rule fires, the service records an event (de-duplicated) and POSTs it to the target, retrying until it gets a 2xx.

{
  "rule":   {"type": "geofence", "name": "Home", "lat": 37.3861, "lon": -122.0839, "radius_m": 150, "on": "both"},
  "target": "webhook",                    // or "openclaw" (no callback_url; see OpenClaw integration)
  "callback_url": "https://example.com/hook",
  "secret": "optional HMAC key",
  "note":   "optional free text, copied into every event (OpenClaw follows it)",
  "once":   false                         // true = deactivate after the first event
}

Rules

Rule Fields Fires
geofence name, lat, lon, radius_m, on: enter/exit/both geofence.entered / geofence.exited
landed optional min_distance_km (default 300), min_gap_hours (default 1) landed
stale hours stale

Geofence jitter handling (events.evaluate_geofence, thresholds in config.py):

Use radius_m ≥ ~100 m; 150–300 m for buildings/stores.

Landed: on each new fix, compare with the device's previous fix (accuracy ≤ 1000 m). If the gap is ≥ 1 h and the distance ≥ 300 km → landed, with place from OpenStreetMap Nominatim reverse geocoding (city, country) when available, else coordinates. Limitation: if the phone records GPS during the flight (no gap), it won't fire.

Stale: checked every 5 min (LOCATION_STALE_CHECK_INTERVAL_S). Fires once when the newest fix (any device) is older than hours; a new fix re-arms it.

Event payload (target: "webhook")

POST <callback_url> with Content-Type: application/json and headers:

{
  "id": "6f1c…",                          // unique event id; use it to de-dup on your side
  "type": "geofence.entered",             // geofence.entered | geofence.exited | landed | stale
  "subscription_id": "2b9e…",
  "occurred_at": "2026-10-02T21:00:00Z",  // fix time (stale: detection time)
  "message": "Entered geofence 'Home' (iphone, 2026-10-02 21:00 UTC)",
  "note": null,
  "fix": {"lat": 37.3861, "lon": -122.0839, "accuracy_m": 12.0, "timestamp": "2026-10-02T21:00:00Z",
          "battery": 0.8, "motion": "walking", "source": "overland", "device": "iphone"},
  "previous_fix": null,                   // landed: last fix before the gap
  "geofence": {"type": "geofence", "name": "Home", "lat": 37.3861, "lon": -122.0839, "radius_m": 150, "on": "both"},
  "place": null,                          // landed: "Paris, France"
  "distance_km": null,                    // landed
  "hours_since_fix": null                 // stale
}

Verifying the signature

X-Location-Signature is sha256= + hex HMAC-SHA256 of the raw request body with the subscription's secret. Compare in constant time, before parsing JSON:

import hashlib
import hmac

def verify(secret: str, raw_body: bytes, header: str) -> bool:
    """
    Check a location-service webhook signature.
    1. Recompute sha256 HMAC over the raw body; constant-time compare with the header.
    """
    expected = "sha256=" + hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header or "")
printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET"   # shell equivalent

Delivery & retries