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
}
| 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):
accuracy_m > 100 m are ignored (LOCATION_GEOFENCE_MAX_ACCURACY_M).radius_m. Outside = distance > radius_m + 50 m (LOCATION_GEOFENCE_HYSTERESIS_M).
The ring in between changes nothing, so standing near the edge can't flap.LOCATION_GEOFENCE_CONFIRM_FIXES).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.
target: "webhook")POST <callback_url> with Content-Type: application/json and headers:
X-Location-Event: geofence.entered (event type)X-Location-Signature: sha256=<hex> (only if the subscription has a secret){
"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
}
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
events table is the queue), so they survive restarts.status: "failed". Inspect with GET /events.id.