fencemaker
HomeUse CasesHow It WorksLive DemoDocsOperations Portal

Quick Start

Get from zero to your first geofence check in 5 minutes.

1

Get Your API Key

Go to API Keys and create a new key. The full key is shown once — copy it immediately.

gfnsr_live_aBcDeFgHiJkLmNoPqRsT...
2

Your First PiP Call

Check which territory a coordinate belongs to:

curl -X GET "https://fencemaker.app/api/v1/pip?lat=12.9716&lon=77.5946" \
  -H "X-API-Key: YOUR_API_KEY"
Response
{
  "matched": true,
  "territory": { "name": "South Bengaluru", "code": "ZONE-A", "agent_id": "R-001" },
  "response_ms": 8
}
3

Register a Device (optional)

Device registration is optional. The /track endpoint auto-creates devices on first GPS ping. Use this only if you want to set labels or metadata upfront:

curl -X POST https://fencemaker.app/api/v1/devices \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"device_id": "RIDER-042", "label": "Rahul's bike"}'
4

Send a GPS Ping

Forward device location to evaluate geofences:

curl -X POST https://fencemaker.app/api/v1/track \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"device_id": "RIDER-042", "lat": 12.9716, "lon": 77.5946}'
Response (device entered a territory)
{
  "events": [{ "type": "entered", "territory_code": "ZONE-A", "webhook_fired": true }],
  "response_ms": 12
}
5

Test Your Webhook

Simulate an event without a real device:

curl -X POST https://fencemaker.app/api/v1/events/simulate \
  -H "X-API-Key: YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"territory_id": "UUID", "device_id": "TEST-01", "event_type": "entered"}'
Batch: Use POST /api/v1/pip/batch to check up to 1,000 points in one request.
Embed a live map: Use GET /api/v1/map/tiles/{z}/{x}/{y} as a MapLibre tile source and GET /api/v1/map/territories for your zone polygons as GeoJSON — one API key, no third-party map dependency. See the How It Works guide for full integration code.

Code Examples

Python

import requests

API_KEY = "gfnsr_live_..."
BASE = "https://fencemaker.app"

# Single PiP check
r = requests.get(f"{BASE}/api/v1/pip", params={"lat": 12.97, "lon": 77.59},
    headers={"X-API-Key": API_KEY})
print(r.json())

# Track a device
r = requests.post(f"{BASE}/api/v1/track",
    json={"device_id": "RIDER-042", "lat": 12.97, "lon": 77.59},
    headers={"X-API-Key": API_KEY, "Content-Type": "application/json"})
print(r.json()["events"])

Node.js

const API_KEY = "gfnsr_live_...";
const BASE = "https://fencemaker.app";

// PiP check
const res = await fetch(`${BASE}/api/v1/pip?lat=12.97&lon=77.59`, {
  headers: { "X-API-Key": API_KEY }
});
console.log(await res.json());

// Track
const t = await fetch(`${BASE}/api/v1/track`, {
  method: "POST",
  headers: { "X-API-Key": API_KEY, "Content-Type": "application/json" },
  body: JSON.stringify({ device_id: "RIDER-042", lat: 12.97, lon: 77.59 })
});
console.log((await t.json()).events);

Authentication

All API v1 endpoints require an API key.

Getting Your Key

  1. Go to API Keys
  2. Click Generate New 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.

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

Prefixgfnsr_live_
Random24 bytes, base64url encoded
StorageSHA-256 hash only

Errors

401 — Invalid or missing key
{ "error": "Invalid or missing API key" }

Best Practices

  • Environment variables — never hardcode keys
  • One key per environment — dev, staging, production
  • Server-side only — never expose in frontend code
  • 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.

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

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

Headers

Content-Typeapplication/json
X-Fencemaker-Evententered or exited

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/webhooksCreate 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.
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" }

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" }
POST /api/v1/track
GPS ping — evaluates geofences, fires entry/exit webhooks.
Request Body
{ "device_id": "RIDER-042", "lat": 12.9716, "lon": 77.5946 }
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 must be between -90 and 90" }
POST /api/v1/track/batch
Batch GPS pings — up to 100 devices.
Request Body
{
  "pings": [
    { "device_id": "RIDER-042", "lat": 12.97, "lon": 77.59 },
    { "device_id": "RIDER-043", "lat": 12.96, "lon": 77.60 }
  ]
}
Response
{
  "processed": 2,
  "total_events": 1,
  "events": [
    {
      "device_id": "RIDER-042",
      "type": "entered",
      "territory_code": "ZONE-A",
      "webhook_fired": true
    }
  ],
  "response_ms": 28
}

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