latlng

Send webhooks when vehicles enter or leave a zone

Register a geofence once and latlng calls your service every time a vehicle crosses it. You'll build a small receiver, register a depot zone, and see how retries keep events from getting lost.

Beginner · about 15 minutes · tested against latlng v0.2.0

Before you start

  • latlng-server installed, and familiarity with the vehicle tracker tutorial.
  • Node.js 18+ for the webhook receiver (any HTTP server works).

01 Run latlng with durable storage

Webhook jobs are written to the same append-only log as your data, so pending deliveries survive a restart (see storage and guarantees). Start the server with --aof and create a fleet if you haven't already.

Terminal
latlng-server --aof ./data/appendonly.aof

curl -s -X POST http://127.0.0.1:7421/collections/fleet-hamburg

02 Write a webhook receiver

latlng sends each geofence event as an HTTP POST with a JSON body. Any 2xx response acknowledges the delivery; anything else, or a timeout, schedules a retry.

Delivery is at-least-once, so the same event can arrive twice — for example, if your service processed it but the response was lost. Every request carries a stable X-LatLng-Event-Id header; store the IDs you've handled and skip repeats.

receiver.mjs — no dependencies, Node.js 18+
import { createServer } from "node:http";

const seen = new Set(); // use a database or Redis in production

createServer(async (req, res) => {
  let body = "";
  for await (const chunk of req) body += chunk;

  const eventId = req.headers["x-latlng-event-id"];
  if (seen.has(eventId)) {
    res.writeHead(200).end(); // duplicate: acknowledge and ignore
    return;
  }
  seen.add(eventId);

  const event = JSON.parse(body);
  const { lat, lon } = event.object.Point;
  console.log(`${event.detect} ${event.id} (${event.hook}) at ${lat}, ${lon}`);

  res.writeHead(200).end(); // acknowledge quickly; do slow work asynchronously
}).listen(8080, () => console.log("listening on :8080"));

03 Register a geofence hook

A hook combines a name, an endpoint, and a geofence definition: the collection to watch, the area query, which transitions to detect, and which commands trigger evaluation. This one watches a depot polygon and reports vehicles entering and leaving it.

Request
curl -s -X POST http://127.0.0.1:7421/hooks \
  -H 'content-type: application/json' \
  -d '{
    "name": "depot-altona",
    "endpoint": "http://127.0.0.1:8080/geofence",
    "def": {
      "collection": "fleet-hamburg",
      "query": {
        "Within": {
          "area": { "GeoJson": { "type": "Polygon", "coordinates": [[
            [9.930, 53.548], [9.945, 53.548], [9.945, 53.556], [9.930, 53.556], [9.930, 53.548]
          ]] } },
          "options": {}
        }
      },
      "detect": ["Enter", "Exit"],
      "commands": ["Set"]
    }
  }'
# {"ok":true}

For a circular zone, use {"Nearby": {"lat": 53.55, "lon": 9.99, "meters": 500, "options": {}}} as the query instead. Other detect types are Inside and Outside (every update inside or outside the zone), Cross (entered and left between two updates), and Roam for proximity between moving objects.

Areas are defined inline — GeoJSON, bounds, or a circle. A reference to a stored object is rejected with 400, and so are out-of-range coordinates or unknown keys in the definition.

To also hear about vehicles that disappear, add Del to commands. Expired objects are deleted by a background sweep and go through the normal delete path, so a vehicle whose TTL runs out inside the zone arrives as "command": "Del" with "detect": "Exit". Use command to tell an expiry or deletion apart from a vehicle that drove out.

04 Drive a vehicle through the zone

Send three position updates: outside, inside, and outside again. Your receiver prints one enter and one exit event.

Requests
for pos in "53.5520 9.9500" "53.5520 9.9380" "53.5530 9.9600"; do
  lat=${pos% *}; lon=${pos#* }
  curl -s -X POST http://127.0.0.1:7421/collections/fleet-hamburg/objects/truck-1 \
    -H 'content-type: application/json' \
    -d "{\"object\":{\"Point\":{\"lat\":$lat,\"lon\":$lon,\"z\":null}}}"
done
Receiver output
Enter truck-1 (depot-altona) at 53.552, 9.938
Exit truck-1 (depot-altona) at 53.553, 9.96
Webhook body
{
  "command": "Set",
  "detect": "Enter",
  "collection": "fleet-hamburg",
  "id": "truck-1",
  "object": { "Point": { "lat": 53.552, "lon": 9.938, "z": null } },
  "fields": { "values": { "speed": { "type": "number", "value": 30.0 } } },
  "timestamp_ns": 1790456341202287000,
  "event_id": "evt_B1hIgXYGb6W6oBt7yZWiWg",
  "job_id": "job_hhZVKdArzJ_MgKMJXxaGcQ",
  "hook": "depot-altona",
  "group": "depot-altona",
  "nearby": null
}

05 See retries in action

Stop the receiver and drive the vehicle into the zone again. The job waits in the queue; start the receiver and it's delivered on the next retry.

Queue while the receiver is down
curl -s http://127.0.0.1:7421/admin/webhook-queue
# {"dead_letter":0,"leased":0,"oldest_pending_age_ms":0,"pending":1}
Prometheus counters
curl -s http://127.0.0.1:7421/metrics | grep -E '^latlng_hook_'
# latlng_hook_attempts_total      every delivery attempt
# latlng_hook_success_total       2xx responses
# latlng_hook_failure_total       failed attempts (while the receiver was down)
# latlng_hook_retry_total         attempts that scheduled a retry
# latlng_hook_dead_letter_total   jobs that exhausted the retry budget

Retries back off exponentially from webhook_retry_initial_backoff_ms (200 ms) up to webhook_retry_max_backoff_ms (30 s). After webhook_retry_count retries (8 by default), the job moves to the dead-letter state instead of being dropped, and shows up in the dead_letter count. See delivery settings for every option and operations and observability for the full metric list.

06 Manage hooks

List hooks, read one back with its full definition, and delete it when the zone is no longer needed. Hook definitions are persisted and replayed on restart — see recovery guarantees.

Requests
curl -s http://127.0.0.1:7421/hooks
curl -s http://127.0.0.1:7421/hooks/depot-altona
curl -s -X DELETE http://127.0.0.1:7421/hooks/depot-altona

Only fire for some vehicles: add filters to the query's options, for example "options": {"where_in_filters": [{"field": "status", "values": ["en-route"]}]} — parked vehicles entering the zone are then ignored.

What you built

  • A webhook receiver that acknowledges quickly and ignores duplicate deliveries.
  • A polygon geofence that reports vehicles entering and leaving a depot.
  • Durable delivery: queued jobs, exponential backoff, and dead-lettering instead of silent loss.