⬡ fencemaker
DocsPricing Control Tower

Quick Start

Pick a use case and get a complete integration guide with real endpoints and copy-paste code.

Quickstart Guides → Delivery zones · Territory assignment · Dynamic pricing · Real-time alerts · Route compliance · Idle time detection
Just the API? Scroll down for the full API reference, or use the left nav to jump to Authentication, Webhooks, or Alerts.

Authentication

All API v1 endpoints require an API key.

Getting Your Key

  1. Go to Alerts → API Keys
  2. Choose a key type (see below) and click Create Key
  3. Name it (e.g. "Production Backend")
  4. Copy immediately — shown only once
Security: Keys are hashed (SHA-256) before storage. We can never retrieve the raw key. If lost, revoke and create a new one.

Key Types

Two key types, both created from the same Alerts page section:

Secret (gfnsr_live_...)Full API access. Server-side only — never embed in an app or website.
Publishable (gfnsr_pub_...)Limited to POST /api/v1/track, POST /api/v1/track/batch, and GET /api/v1/map/territories (used by the mobile SDK's local polygon cache — see the mobile integration guide). Can send device locations and read zone boundaries. If it leaks, someone could send fake locations or see your zone shapes, so revoke it and issue a new one.

Both count against the same 10-key-per-plan limit.

Using the Key

Include it in the X-API-Key header:

curl -H "X-API-Key: gfnsr_live_..." https://fencemaker.app/api/v1/pip?lat=12.97&lon=77.59

Key Format

Secret prefixgfnsr_live_
Publishable prefixgfnsr_pub_
Random24 bytes, base64url encoded
StorageSHA-256 hash only

Errors

401 — Invalid or missing key
{ "error": "Invalid or missing API key" }
403 — Publishable key used outside its allowed routes
{ "error": "Publishable keys are restricted to /api/v1/track, /api/v1/track/batch, and GET /api/v1/map/territories.", "hint": "Use a secret key (gfnsr_live_...) for this endpoint." }

Best Practices

  • Use a publishable key for anything client-side — mobile apps, embedded SDKs — never a secret key
  • Environment variables — never hardcode secret keys
  • One key per environment — dev, staging, production
  • Rotate periodically — revoke old, create new
  • Monitor usage — check Usage for anomalies

Organisation Scoping

Each key belongs to an organisation. All queries auto-scope to your org's data. No cross-org data access is possible.

Webhook Integration

Receive real-time notifications when devices enter or exit geofenced territories. Prefer a no-code destination? See the Slack and Telegram integrations — both are free and built on this same webhook layer. Full walkthrough: Custom Webhooks integration guide.

How It Works

📱 Device GPS → your server → POST /api/v1/track
↓
⬡ Fencemaker evaluates all territory boundaries
↓
🔄 Compares current vs. previous territories
↓
⚡ Fires webhook for each entered / exited event
↓
🖥️ Your server receives the event

Setup

Set a webhook URL on your territory in the Territory Editor.

URL resolution: Territory webhook → Org fallback URL → skip.

Payload

Entry event:

{
  "event": "entered",
  "device_id": "RIDER-042",
  "territory_id": "uuid",
  "territory_code": "ZONE-A",
  "territory_name": "South Bengaluru",
  "lat": 12.9716,
  "lon": 77.5946,
  "timestamp": "2026-03-06T10:45:00.000Z",
  "org_id": "your-org-uuid",
  "event_id": "uuid"
}

Exit event — includes dwell_seconds (time spent inside the zone):

{
  "event": "exited",
  "device_id": "RIDER-042",
  "territory_id": "uuid",
  "territory_code": "ZONE-A",
  "territory_name": "South Bengaluru",
  "lat": 12.9716,
  "lon": 77.5946,
  "timestamp": "2026-03-06T10:59:07.000Z",
  "org_id": "your-org-uuid",
  "dwell_seconds": 847,
  "event_id": "uuid"
}

event_id uniquely identifies this delivery attempt's underlying geofence event — use it to de-duplicate if your endpoint ever receives the same delivery twice.

Headers

Content-Typeapplication/json
X-Fencemaker-Evententered or exited
X-Fencemaker-Event-IdSame UUID as the event_id body field — check this header if you dedupe before parsing the body.

Signature Verification

The Custom Webhook channel (configured in Alerts & Webhooks) supports HMAC-SHA256 signing. Add a signing secret in the dashboard and verify the signature in your handler:

// Header sent with every delivery (when a secret is set):
X-Fencemaker-Signature: sha256=<hex-digest>

// Node.js verification
const crypto = require("crypto");
function verify(secret, rawBody, sigHeader) {
  const expected = "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sigHeader));
}

app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const sig = req.headers["x-fencemaker-signature"];
  if (!verify(process.env.WEBHOOK_SECRET, req.body, sig))
    return res.status(401).json({ error: "Invalid signature" });
  const event = JSON.parse(req.body);
  // handle event…
  res.json({ ok: true });
});
Per-territory webhooks (set directly on a zone in the Territory Editor) do not carry a signature header. Use the Custom Webhook channel if signing is required.

Events

enteredDevice moved into a territory
exitedDevice moved out of a territory

Your Handler

Python (Flask)

@app.post("/webhooks/fencemaker")
def handle(request):
    event = request.json
    if event["event"] == "entered":
        notify(f'{event["device_id"]} arrived at {event["territory_name"]}')
    return {"ok": True}, 200

Node.js (Express)

app.post("/webhooks/fencemaker", (req, res) => {
  const { event, device_id, territory_name } = req.body;
  if (event === "entered") sendAlert(`${device_id} at ${territory_name}`);
  res.json({ ok: true });
});

Testing

curl -X POST https://fencemaker.app/api/v1/events/simulate \
  -H "X-API-Key: YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{"territory_id": "UUID", "device_id": "TEST-01", "event_type": "entered"}'

Delivery

Timeout10 seconds
MethodHTTP POST
Retries3 retries on 5xx (30s, 60s, 120s backoff). 4xx responses are not retried.
LoggingAll attempts logged — visible in Webhook Logs
Missed events? Events are always recorded in geofence_events regardless of webhook success. Query GET /api/v1/events to catch up.

Alerts & Notifications

Receive instant alerts in Slack, Telegram, or via a signed webhook whenever a geofence event fires. All channels are configured in Alerts & Webhooks and fire simultaneously on every entered / exited event.

Channel vs. territory webhook: Notification channels (Slack, Telegram, Custom Webhook) fire org-wide on every event. Per-territory webhooks (set in the Territory Editor) fire only for that zone and use a simpler payload. Both can run at the same time.

Slack

Setup

  1. Go to api.slack.com/messaging/webhooks → Create an app → enable Incoming Webhooks → add to a channel → copy the webhook URL.
  2. In Alerts & Webhooks, click Connect Slack, paste the URL, and click Connect.
  3. Click Send Test to confirm delivery.

What you receive

A formatted Slack Block Kit message with event type, device ID, zone name, timestamp, and a Google Maps link for the coordinates.

🟢 Geofence ENTERED
Device:  RIDER-042
Zone:    South Bengaluru
Time:    Today at 10:45 AM
Location: View on Map →
Disconnect: Click Disconnect on the Slack card. The incoming webhook URL is removed from Fencemaker; you can revoke it in Slack separately if needed.

Telegram

Setup

  1. In Alerts & Webhooks, click Connect Telegram — Fencemaker generates a one-time token (expires in 10 minutes).
  2. Link your chat using either method:
    • Mobile (one tap): tap Open in Telegram — the bot connects automatically.
    • Desktop / manual: open Telegram, search @{TELEGRAM_BOT_NAME}, send /connect <token>.
  3. Click ↻ I've connected — Refresh to confirm, then Send Test to verify.

Bot commands

/startSetup instructions
/connect <token>Link to a Fencemaker account
/statusCheck which org this chat is connected to

What you receive

🟢 Geofence ENTERED

📱 Device: RIDER-042
📍 Zone:   South Bengaluru
⏰ Time:   28 Apr 2026, 10:45:00
🗺 Location: View on Map

Custom Webhook

Setup

  1. In Alerts & Webhooks, enter your endpoint URL in the Custom Webhook card and click Save URL.
  2. Optionally click + Signing Secret to add an HMAC key (see Signature Verification below).
  3. Click Send Test to fire a sample payload.

Payload

Fencemaker POSTs JSON to your endpoint on every geofence event:

POST https://your-server.com/webhook
Content-Type: application/json
User-Agent: Fencemaker-Webhook/1.0
X-Fencemaker-Signature: sha256=<hex>   // only if signing secret is set

{
  "event": "geofence.entered",
  "timestamp": "2026-04-28T10:45:00.000Z",
  "data": {
    "device_id":      "RIDER-042",
    "territory_id":   "uuid",
    "territory_name": "South Bengaluru",
    "event_type":     "entered",
    "location": { "latitude": 12.9716, "longitude": 77.5946 }
  }
}

Event values

geofence.enteredDevice moved into a territory
geofence.exitedDevice moved out of a territory

Signature Verification

When a signing secret is configured, every request carries X-Fencemaker-Signature: sha256=<hmac-hex> computed over the raw JSON body.

// Node.js (Express)
const crypto = require("crypto");

app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
  const sig  = req.headers["x-fencemaker-signature"];
  const hmac = "sha256=" + crypto.createHmac("sha256", process.env.SECRET)
                                  .update(req.body).digest("hex");
  if (!crypto.timingSafeEqual(Buffer.from(hmac), Buffer.from(sig)))
    return res.status(401).json({ error: "Invalid signature" });

  const { event, data } = JSON.parse(req.body);
  console.log(event, data.device_id, data.territory_name);
  res.json({ ok: true });
});
# Python (Flask)
import hmac, hashlib, os
from flask import Flask, request, abort

app = Flask(__name__)

@app.post("/webhook")
def webhook():
    sig      = request.headers.get("X-Fencemaker-Signature", "")
    expected = "sha256=" + hmac.new(
        os.environ["SECRET"].encode(),
        request.data,
        hashlib.sha256
    ).hexdigest()
    if not hmac.compare_digest(expected, sig):
        abort(401)
    payload = request.json
    print(payload["event"], payload["data"]["device_id"])
    return {"ok": True}

Delivery

MethodHTTP POST
Timeout5 seconds
RetriesNone — use Webhook Logs to detect failures and replay via POST /api/v1/events/simulate
Always return 2xx. A non-2xx response is logged as a failure. Return { ok: true } immediately and process asynchronously if your handler is slow.

API Reference

Base URL: https://fencemaker.app — all endpoints require X-API-Key.

Common Errors
// 401 — invalid or missing API key
{ "error": "Invalid or missing API key" }

// 400 — bad request (missing field, invalid value)
{ "error": "lat and lon are required" }

// 404 — resource not found
{ "error": "Territory not found" }

// 429 — rate limit exceeded
{ "error": "Rate limit exceeded. Retry after 60s.", "retry_after": 60 }

Point-in-Polygon

GET /api/v1/pip
Quick PiP check via query params. Best for browser/curl testing and simple coordinate lookups.
Decision guide: use GET /pip for quick coordinate lookups. Use POST /territory when you need address input or full agent details.
Parameters
lat, lon, location_id (optional), multi (true to return all matches)
Response
// matched: true
{
  "matched": true,
  "territory": {
    "id": "uuid",
    "name": "South Bengaluru",
    "code": "ZONE-A",
    "agent_id": "R-001",
    "location_id": "uuid",
    "location_name": "Bengaluru"
  },
  "lat": 12.9716, "lon": 77.5946,
  "response_ms": 8
}

// matched: false (coordinate outside all territories)
{
  "matched": false,
  "territory": null,
  "lat": 28.6139, "lon": 77.2090,
  "response_ms": 6
}
Errors
// 400 — missing or invalid coordinates
{ "error": "lat and lon are required" }
{ "error": "lat must be between -90 and 90" }

// 401 — invalid or missing API key
{ "error": "Invalid or missing API key" }
POST /api/v1/territory
Check which territory a coordinate or address belongs to. Returns full agent details.
Decision guide: use POST /territory when you need address geocoding or full agent details. Use GET /pip for quick coordinate lookups.
Request Body
{ "lat": 12.9716, "lon": 77.5946, "location_id": "uuid" }

// OR with address:
{ "address": "14 MG Road, Bengaluru" }
Response
// matched: true
{
  "matched": true,
  "territory_id": "uuid",
  "territory_code": "ZONE-A",
  "agent_id": "R-001",
  "agent_name": "Rahul S.",
  "location_name": "Bengaluru South",
  "response_ms": 8
}

// matched: false
{
  "matched": false,
  "territory_id": null,
  "response_ms": 7
}
POST /api/v1/pip/batch
Batch point-in-polygon — up to 1,000 points per request.
Request Body
{
  "location_id": "uuid",
  "points": [
    { "id": "order-1", "lat": 12.97, "lon": 77.59 },
    { "id": "order-2", "address": "14 MG Road Bengaluru" }
  ]
}
Response
{
  "total": 2,
  "matched": 1,
  "unmatched": 1,
  "match_rate": 50,
  "results": [
    {
      "id": "order-1",
      "matched": true,
      "territory_id": "uuid",
      "territory_code": "ZONE-A",
      "territory_name": "South Bengaluru",
      "agent_id": "R-001"
    },
    {
      "id": "order-2",
      "matched": false,
      "territory_id": null,
      "territory_code": null
    }
  ],
  "response_ms": 45
}

Territories

GET /api/v1/territories
List territories (paginated).
Parameters
location_id, page, per_page
Response
{
  "territories": [
    {
      "id": "uuid",
      "name": "South Bengaluru",
      "code": "ZONE-A",
      "agent_id": "R-001",
      "agent_name": "Rahul S.",
      "location_id": "uuid",
      "location_name": "Bengaluru South",
      "color": "#3B82F6"
    }
  ],
  "total": 42,
  "page": 1,
  "per_page": 50
}
GET /api/v1/territories/:id
Get single territory by UUID.
Response
{
  "id": "uuid",
  "name": "South Bengaluru",
  "code": "ZONE-A",
  "agent_id": "R-001",
  "agent_name": "Rahul S.",
  "location_id": "uuid",
  "location_name": "Bengaluru South",
  "color": "#3B82F6",
  "webhook_url": "https://example.com/hook",
  "geometry": { "type": "Polygon", "coordinates": [[...]] }
}
Errors
// 404 — territory not found
{ "error": "Territory not found" }
POST /api/v1/territories
Create a new geofence territory.
name and boundary are required. hub_id defaults to your org's first hub if omitted; if your org has no hubs yet, a "Default" hub is created automatically.
Request Body
{
  "name": "Warehouse Zone A",
  "boundary": { "type": "Polygon", "coordinates": [[...]] },
  "hub_id": "uuid",
  "code": "WH-A",
  "color": "#ff5500"
}
Response
{
  "id": "uuid",
  "name": "Warehouse Zone A",
  "code": "WH-A",
  "color": "#ff5500",
  "location_id": "uuid",
  "created_at": "2026-05-06T10:00:00Z"
}
Errors
// 400 — missing required field
{ "error": "name and boundary are required" }
// 403 — plan territory limit reached
{ "error": "Territory limit reached (25/25)", "upgrade_url": "https://fencemaker.app/pricing" }
PATCH /api/v1/territories/:id
Activate or deactivate a geofence. Inactive geofences are excluded from PIP checks, event tracking, and webhook fires.
Request Body
{ "active": false }
Response
{
  "id": "uuid",
  "name": "Warehouse Zone A",
  "code": "WH-A",
  "is_active": false,
  "updated_at": "2026-05-06T10:05:00Z",
  "response_ms": 12
}
Errors
// 400 — invalid body
{ "error": "active must be a boolean" }
// 404 — territory not found
{ "error": "Territory not found" }
POST /api/v1/territories/corridor
Create a corridor geofence — one or more GPS paths buffered by a fixed distance and unioned into a single polygon. Use this for route compliance: a device ping outside the buffer fires the normal "exited" event/webhook, same as any other territory.
name, paths, buffer_meters, and hub_id (or location_id) are required. paths: 1-5 paths, each with 2-500 waypoints. buffer_meters: 1-10000, applied on both sides of each path. Multiple paths are unioned into one geometry — the result is a MultiPolygon if the buffered paths don't overlap; all existing PIP, /track, and webhook logic handles both Polygon and MultiPolygon the same way.
Request Body
{
  "name": "NH48 Highway Corridor",
  "code": "CORRIDOR-01",
  "hub_id": "uuid",
  "buffer_meters": 50,
  "paths": [
    [
      { "lat": 12.9716, "lon": 77.5946 },
      { "lat": 12.9800, "lon": 77.6010 },
      { "lat": 12.9850, "lon": 77.6100 }
    ]
  ],
  "webhook_url": "https://example.com/hook",
  "color": "#F59E0B"
}
Response
{
  "id": "uuid",
  "name": "NH48 Highway Corridor",
  "code": "CORRIDOR-01",
  "geometry_type": "corridor",
  "buffer_meters": 50,
  "path_count": 1,
  "total_waypoints": 3,
  "geometry": { "type": "Feature", "geometry": { "type": "Polygon", "coordinates": [[...]] } },
  "color": "#F59E0B",
  "webhook_url": "https://example.com/hook",
  "response_ms": 14
}
Errors
// 400 — missing or invalid fields
{ "error": "name is required" }
{ "error": "paths is required and must be an array" }
{ "error": "buffer_meters is required and must be a number" }
{ "error": "hub_id (or location_id) is required" }
// 400 — invalid path data
{ "error": "At least one path is required" }
{ "error": "Maximum 5 paths per corridor" }
{ "error": "Path 1 must have at least 2 waypoints" }
{ "error": "Path 1 exceeds maximum of 500 waypoints" }
{ "error": "buffer_meters must be between 1 and 10000" }
// 404 — hub not found for this org
{ "error": "hub_id not found" }
// 403 — plan territory limit reached
{ "error": "Territory limit reached (25/25)", "upgrade_url": "https://fencemaker.app/pricing" }

Hubs

GET /api/v1/hubs
List hubs for your org (paginated). Every org has at least one hub — a "Default" hub is created on signup, and on-the-fly if a territory is created before any hub exists.
Parameters
page, per_page
Response
{
  "hubs": [
    { "id": "uuid", "name": "Default" }
  ],
  "total": 1,
  "page": 1,
  "per_page": 50
}

Devices & Tracking

POST /api/v1/devices
Optional — register or upsert a device with labels/metadata. Devices are auto-created on first /track ping if not pre-registered.
Request Body
{ "device_id": "RIDER-042", "label": "Rahul's bike" }
Response
{ "id": "uuid", "device_id": "RIDER-042", "auto_created": false, "response_ms": 3 }
GET /api/v1/devices
List devices.
Parameters
device_id, page, per_page
Response
{
  "devices": [
    {
      "id": "uuid",
      "device_id": "RIDER-042",
      "label": "Rahul's bike",
      "last_lat": 12.9716,
      "last_lon": 77.5946,
      "last_seen": "2026-03-06T10:45:00.000Z",
      "territories_inside": ["uuid"]
    }
  ],
  "total": 1,
  "page": 1,
  "per_page": 50
}
GET /api/v1/devices/:id/status
Device location + which territories it is currently inside.
Response
{
  "device_id": "RIDER-042",
  "label": "Rahul's bike",
  "last_lat": 12.9716,
  "last_lon": 77.5946,
  "last_seen": "2026-03-06T10:45:00.000Z",
  "territories_inside": [
    {
      "id": "uuid",
      "code": "ZONE-A",
      "name": "South Bengaluru",
      "agent_id": "R-001"
    }
  ]
}
Errors
// 404 — device not found
{ "error": "Device not found" }
PATCH /api/v1/devices/:id
Update device metadata. Allow-listed fields only — any other field in the body is ignored, not saved.
tags are trimmed, empty entries dropped, and de-duplicated case-insensitively (first-seen casing kept) before the 20-item / 40-char limits are checked.
Request Body
{
  "label": "Rahul's bike",
  "device_group": "bengaluru-south",
  "tags": ["VIP", "fragile"],
  "status": "delayed"
}
Response
{
  "id": "uuid",
  "device_id": "RIDER-042",
  "label": "Rahul's bike",
  "device_group": "bengaluru-south",
  "tags": ["VIP", "fragile"],
  "status": "delayed",
  "last_lat": 12.9716,
  "last_lon": 77.5946,
  "last_seen_at": "2026-03-06T10:45:00.000Z",
  "is_active": true,
  "response_ms": 4
}
Errors
// 400 — invalid status
{ "error": "status must be one of: active, delayed, needs_review" }
// 400 — invalid tags
{ "error": "tags must be an array of strings" }
{ "error": "tag \"...\" exceeds 40 characters" }
{ "error": "tags: maximum 20 tags allowed" }
// 400 — no allow-listed fields in body
{ "error": "Nothing to update" }
// 404 — device not found, or belongs to another org (identical response either way)
{ "error": "Device not found" }
POST /api/v1/track
GPS ping — evaluates geofences, fires entry/exit webhooks.
A 503 means the geofence_events + device_territory_state write did not commit — territory membership did not change and no webhook was queued for this ping. Retry the same ping; it is safe to retry (state only advances once, on a committed write). timestamp is validated against a max-age window (default 24h, server-configurable) — a ping older than the last one already processed for that device is accepted (200) but marked "stale": true, with events: [] and territories_inside reflecting current state; it does not advance territory membership or fire webhooks. The 429 (daily new-device cap) and 403 (plan device limit) only ever apply when device_id has never been seen before — a ping for a device you already have never gets blocked by either.
Request Body
{
  "device_id": "RIDER-042",
  "lat": 12.9716,
  "lon": 77.5946,
  "accuracy": 8,        // optional, GPS accuracy in metres, >= 0
  "speed": 32,          // optional, km/h, 0-400
  "battery": 71,        // optional, percent, 0-100
  "heading": 90,         // optional, degrees, 0-360
  "timestamp": "2026-06-17T10:00:00Z" // optional, ISO 8601, defaults to now
}
Response
{
  "device_id": "RIDER-042",
  "events": [
    { "type": "entered", "territory_code": "ZONE-A", "webhook_fired": true }
  ],
  "territories_inside": ["uuid"],
  "response_ms": 12
}
Errors
// 400 — invalid or missing fields
{ "error": "device_id is required" }
{ "error": "lat and lon are required" }
{ "error": "speed must be between 0 and 400 km/h" }
{ "error": "battery must be between 0 and 100" }
{ "error": "heading must be between 0 and 360" }
{ "error": "accuracy must be >= 0" }
// 400 — timestamp out of bounds
{ "error": "timestamp cannot be more than 5 minutes in the future." }
{ "error": "timestamp too far in the past (max 24h)." }
// 503 — event write failed, state was not advanced; safe to retry
{ "error": "Failed to record event, please retry." }
// Retry-After: 2 (header, seconds) — wait this long before retrying
// 429 — this API key has hit its daily new-device creation cap (only for a device_id never seen before; existing devices are never affected)
{ "error": "Daily new-device limit reached for this API key (20/day). Use an existing device_id, or pre-register devices instead of relying on auto-create." }
// Retry-After header gives seconds until the window resets
// 403 — creating this device would exceed your plan's device limit (only for a new device_id; existing devices are never affected)
{ "error": "Device limit reached for your plan (free)", "limit": 5, "current": 5, "upgrade_url": "https://fencemaker.app/pricing" }
POST /api/v1/track/batch
Batch GPS pings — up to 100 devices. Each ping is processed independently: one ping failing does not fail the batch.
The request itself returns 200 even when some pings fail — check each results[] entry. An entry with "error" and no "events" failed for that device_id only; "retryable": true means the write did not commit (safe to retry just that device_id in your next batch call) — this is set for event-write/transaction failures, not for input-validation errors (e.g. missing device_id, which will never succeed on retry without a fix). A capped/over-plan-limit new device_id in a batch produces a results[] entry with an error (daily new-device cap or plan device limit — same messages as POST /api/v1/track) but never aborts the rest of the batch, and never affects entries for device_ids that already exist.
Request Body
{
  "pings": [
    { "device_id": "RIDER-042", "lat": 12.97, "lon": 77.59 },
    { "device_id": "RIDER-043", "lat": 12.96, "lon": 77.60 }
  ]
}
Response
{
  "total": 2,
  "events_fired": 1,
  "results": [
    {
      "device_id": "RIDER-042",
      "events": [
        { "type": "entered", "territory_code": "ZONE-A", "webhook_fired": true }
      ],
      "territories_inside": ["uuid"]
    },
    {
      "device_id": "RIDER-043",
      "error": "Failed to record event, please retry.",
      "retryable": true
    }
  ],
  "response_ms": 28
}

Trips

POST /api/v1/trips
Create a trip with its ordered stops. Each stop auto-creates a small geofence (150m radius) around its coordinates, so device pings through /api/v1/track automatically advance the trip as the device enters/exits each stop — no separate check-in call needed.
Secret keys only — not available to publishable keys. Stop territories count against your plan's territory limit; creating a trip whose stops would exceed it returns 403 before anything is written.
Request Body
{
  "device_id": "uuid",
  "stops": [
    { "sequence": 1, "type": "pickup",   "lat": 12.97, "lon": 77.59 },
    { "sequence": 2, "type": "delivery", "lat": 12.95, "lon": 77.62, "planned_eta": "2026-03-06T14:00:00.000Z" }
  ]
}
Response
{
  "id": "uuid",
  "device_id": "uuid",
  "status": "planned",
  "stops": [
    { "id": "uuid", "sequence": 1, "type": "pickup", "lat": 12.97, "lon": 77.59, "territory_id": "uuid" },
    { "id": "uuid", "sequence": 2, "type": "delivery", "lat": 12.95, "lon": 77.62, "territory_id": "uuid" }
  ],
  "response_ms": 40
}
GET /api/v1/trips
List trips, newest first.
Parameters
status, device_id, page, per_page
Response
{ "total": 1, "page": 1, "per_page": 50, "trips": [ { "id": "uuid", "status": "in_progress", "device_id": "uuid", "created_at": "..." } ] }
GET /api/v1/trips/:id
Trip detail — ordered stops with arrival/departure timestamps, the current and next stop, and deviation/delay flags.
distance_remaining_m / duration_remaining_seconds / eta are for next_stop, computed via routing (not straight-line) from the device's last known position. All three are omitted entirely (not 0, not null-as-placeholder) when there is no next stop or no known device position. If the routing provider is temporarily unreachable, distance_remaining_m falls back to a straight-line estimate and duration_remaining_seconds/eta are omitted rather than guessed.
Response
{
  "id": "uuid",
  "status": "in_progress",
  "stops": [
    { "id": "uuid", "sequence": 1, "arrived_at": "...", "departed_at": "...", "dwell_seconds": 312 },
    { "id": "uuid", "sequence": 2, "arrived_at": null, "departed_at": null }
  ],
  "current_stop": null,
  "next_stop": { "id": "uuid", "sequence": 2 },
  "deviated": false,
  "delayed": true,
  "distance_remaining_m": 5432,
  "duration_remaining_seconds": 612,
  "eta": "2026-03-06T14:12:00.000Z"
}
POST /api/v1/trips/:id/start
planned → in_progress. Returns 409 if the trip isn't currently planned.
Response
{ "id": "uuid", "status": "in_progress", "actual_start": "..." }
POST /api/v1/trips/:id/complete
in_progress → completed. Returns 409 if the trip isn't currently in progress. There is no update-in-place for a trip's stops once created — only these two state transitions.
Response
{ "id": "uuid", "status": "completed", "actual_end": "..." }

Events & Webhooks

GET /api/v1/events
Geofence event history.
Parameters
device_id, territory_id, event_type, since, until, limit, page
Response
{
  "events": [
    {
      "id": "uuid",
      "device_id": "RIDER-042",
      "territory_id": "uuid",
      "territory_code": "ZONE-A",
      "event_type": "entered",
      "lat": 12.9716,
      "lon": 77.5946,
      "created_at": "2026-03-06T10:45:00.000Z"
    }
  ],
  "total": 1,
  "page": 1
}
POST /api/v1/events/simulate
Simulate entry/exit to test webhooks without a real device.
Request Body
{ "territory_id": "uuid", "device_id": "TEST-01", "event_type": "entered" }
Response
{ "simulated": true, "webhook_fired": true, "response_ms": 5 }
GET /api/v1/webhooks/health
Webhook delivery stats (last 24h).
Response
{
  "total": 120,
  "delivered": 117,
  "failed": 3,
  "delivery_rate": 97.5,
  "avg_response_ms": 210
}

Geocoding

GET /api/v1/geocode/forward
Address → coordinates.
Parameters
address
Response
{
  "lat": 12.9716,
  "lon": 77.5946,
  "formatted": "14 MG Road, Bengaluru, Karnataka 560001, India",
  "response_ms": 90
}
GET /api/v1/geocode/reverse
Coordinates → address.
Parameters
lat, lon
Response
{
  "formatted": "14 MG Road, Bengaluru, Karnataka 560001, India",
  "city": "Bengaluru",
  "state": "Karnataka",
  "country": "India",
  "response_ms": 85
}
GET /api/v1/search/autocomplete
Partial address search.
Parameters
q, near
Response
{
  "results": [
    { "label": "MG Road, Bengaluru", "lat": 12.9752, "lon": 77.6074 },
    { "label": "MG Road, Pune",      "lat": 18.5204, "lon": 73.8567 }
  ]
}

Analytics

GET /api/v1/overlaps
Detect overlapping territories.
Parameters
location_id, refresh
Response
{
  "overlaps": [
    {
      "territory_a": "uuid",
      "territory_b": "uuid",
      "name_a": "ZONE-A",
      "name_b": "ZONE-B",
      "overlap_area_sqkm": 0.42
    }
  ],
  "count": 1
}
GET /api/v1/usage
API usage stats.
Parameters
period (7d, 30d, 90d), endpoint
Response
{
  "period": "7d",
  "total_requests": 14230,
  "by_endpoint": [
    { "endpoint": "/api/v1/pip",       "count": 8100 },
    { "endpoint": "/api/v1/track",     "count": 5200 },
    { "endpoint": "/api/v1/pip/batch", "count": 930  }
  ]
}

Map & Embed

GET /api/v1/map/tiles/{z}/{x}/{y}
Vector tile proxy — streams MapLibre-compatible PBF tiles server-side. The tile provider is invisible to clients. Use as the tiles URL in a MapLibre vector source with transformRequest to attach your API key.
Parameters
z (0–15), x, y — standard slippy-map tile coordinates
Response
// Binary PBF tile (application/x-protobuf)
// Cache-Control: public, max-age=86400

// Usage in MapLibre:
const map = new maplibregl.Map({
  style: {
    sources: { fm: {
      type: 'vector',
      tiles: ['https://fencemaker.app/api/v1/map/tiles/{z}/{x}/{y}'],
      maxzoom: 15
    }},
    layers: [ /* roads, water, labels … */ ]
  },
  transformRequest: (url) => ({
    url, headers: { 'X-API-Key': YOUR_KEY }
  })
});
GET /api/v1/map/territories
Returns the account's territories as a GeoJSON FeatureCollection, ready to drop into a MapLibre geojson source. Each feature carries id, name, code, agent_id, agent_name, color, location_id, location_name as properties.
Parameters
bbox=minLon,minLat,maxLon,maxLat (optional), code=ZONE-A,ZONE-B (optional)
Response
{
  "type": "FeatureCollection",
  "features": [
    {
      "type": "Feature",
      "geometry": { "type": "Polygon", "coordinates": [[...]] },
      "properties": {
        "id": "uuid",
        "name": "South Bengaluru",
        "code": "ZONE-A",
        "agent_id": "R-001",
        "agent_name": "Rahul S.",
        "color": "#3B82F6",
        "location_id": "uuid",
        "location_name": "Bengaluru South"
      }
    }
  ],
  "meta": { "count": 1, "generated_at": "2026-03-09T…", "response_ms": 11 }
}