Areas (geofences)
An “area” is a geofence — circle, place, polygon or track — scoped to the key owner (their own and
their organization’s, same perimeter as v2). Another customer’s area always returns 404, never
403: the key can’t even discover that it exists.
| Field | Type | Notes |
|---|---|---|
id |
integer | |
name |
string | max 100 characters |
color |
string | #rrggbb; #3b82f6 if omitted at creation |
type |
enum | circle | polygon | polyline | point |
center |
object | null | { lat, lng } — circles and places; on polygons it’s the computed centroid |
radius_m |
number | null | only circle (minimum 25 m) and point (default 50 m if omitted) — maximum 100000 m |
buffer_m |
number | null | only polyline: maximum distance from the track, 5–5000 m, default 50 m |
points |
array | { lat, lng }[] — polygons (≥3) and tracks (≥2), maximum 2000 |
external_id |
string | null | the area’s code in your own system (ERP, CRM…); unique per account; 1–100 characters made of letters, digits and . _ : / @ # - |
dwell_seconds |
integer | minimum dwell time before notifying entry, in seconds; 0 = immediately (default, unchanged behavior) — see Minimum dwell time |
metrics.area_m2 |
number | null | polygons and circles |
metrics.perimeter_m |
number | null | polygons and circles |
metrics.length_m |
number | null | tracks only |
bounds |
object | null | { south, west, north, east } |
created_at |
string (ISO 8601) | null |
Minimum dwell time (dwell_seconds)
Section titled “Minimum dwell time (dwell_seconds)”An integer from 0 to 86400 (seconds), meant to avoid notifying on a vehicle that merely passes
through an area without stopping (a road running through a construction site, an intersection
inside a customer’s radius). 0 is the default and behaves exactly as before this field existed:
entry is notified as soon as the device is inside.
With a value above zero, the entry event (notifications, automations, and the entry shown in
GET /areas/{id}/events and in /api/v3/events)
only fires after the device has stayed inside the area for at least that long, measured on the GPS
timestamps of the points received — not the server clock. A vehicle that merely passes through
without staying that long generates neither an entry nor an exit. If the device leaves
before the time has elapsed, the count resets: a later entry starts from zero again. The exit is,
as a result, only notified after a confirmed entry.
Set it with dwell_seconds on POST/PATCH/PUT .../by-external-id
and with properties.dwell_seconds on POST /areas/import; in the app’s
area editor it corresponds to the “Notify entry after (minutes)” field. Out of range →
422 VALIDATION_ERROR with code: "OUT_OF_RANGE" — see Validation codes.
GET /api/v3/areas
Section titled “GET /api/v3/areas”List of areas. Permission: read_areas.
| Parameter | Default | Notes |
|---|---|---|
limit |
50 | 1–200, clamped |
offset |
0 | |
type |
— | circle | polygon | polyline | point; other values → 400 BAD_REQUEST |
format |
json |
geojson returns a FeatureCollection in data |
external_id |
— | look up the area with this exact external code (0 or 1 result); not a partial filter |
curl -H "X-API-Key: $AITRACK_API_KEY" \ "https://api.aitrack.it/api/v3/areas?type=circle&limit=50"{ "data": [ { "id": 42, "name": "North Warehouse", "color": "#3b82f6", "type": "circle", "center": { "lat": 45.4642, "lng": 9.19 }, "radius_m": 150, "buffer_m": null, "points": [], "metrics": { "area_m2": 70686, "perimeter_m": 942, "length_m": null }, "bounds": { "south": 45.4629, "west": 9.1881, "north": 45.4655, "east": 9.1919 }, "created_at": "2026-09-20T10:00:00.000Z" } ], "meta": { "pagination": { "limit": 50, "offset": 0, "count": 1 }, "total": 1 }}With ?format=geojson the same area comes back as a Feature inside a FeatureCollection — see
GeoJSON: input and output.
POST /api/v3/areas
Section titled “POST /api/v3/areas”Creates an area. Permission: write_areas. Optional Idempotency-Key header (24 h) — see
Overview. The body accepts the v3 JSON shape or
{ "geojson": <Feature> }.
curl -X POST https://api.aitrack.it/api/v3/areas \ -H "X-API-Key: $AITRACK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "North Warehouse", "color": "#3b82f6", "type": "circle", "center": { "lat": 45.4642, "lng": 9.19 }, "radius_m": 150 }'curl -X POST https://api.aitrack.it/api/v3/areas \ -H "X-API-Key: $AITRACK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "geojson": { "type": "Feature", "properties": { "name": "North Warehouse", "color": "#3b82f6", "radius": 150 }, "geometry": { "type": "Point", "coordinates": [9.19, 45.4642] } } }'Top-level body fields (name, color, radius_m, buffer_m, external_id, dwell_seconds)
take precedence over the feature’s properties (properties.external_id,
properties.dwell_seconds included) when both are sent.
{ "data": { "id": 42, "name": "North Warehouse", "color": "#3b82f6", "type": "circle", "center": { "lat": 45.4642, "lng": 9.19 }, "radius_m": 150, "buffer_m": null, "points": [], "metrics": { "area_m2": 70686, "perimeter_m": 942, "length_m": null }, "bounds": { "south": 45.4629, "west": 9.1881, "north": 45.4655, "east": 9.1919 }, "created_at": "2026-09-27T09:00:00.000Z" }}Response 201, header Location: /api/v3/areas/42. If there are non-blocking warnings (nearly
coincident vertices, a very small or very large area) meta.warnings also appears. Invalid data →
422 VALIDATION_ERROR with details[] — see Validation codes.
external_id already used by another of your areas → 409 CONFLICT: use
PUT /api/v3/areas/by-external-id/{external_id} to
update it instead of trying to recreate it.
PUT /api/v3/areas/by-external-id/{externalId}
Section titled “PUT /api/v3/areas/by-external-id/{externalId}”Creates the area if none exists yet with this external_id, otherwise updates it — an upsert.
Permission: write_areas. Built for a synchronization job that periodically resends its whole
list: calling it repeatedly with the same list never creates a duplicate.
| Path parameter | Notes |
|---|---|
externalId |
1–100 characters made of letters, digits and . _ : / @ # -; otherwise 400 BAD_REQUEST before hitting the database |
The body is the same v3 JSON shape as POST/PATCH. If the body also includes external_id, it
must match the one in the path, otherwise 422 VALIDATION_ERROR (code: "MISMATCH").
# First sync for customer 4521: the area doesn't exist yet → created (201)curl -X PUT https://api.aitrack.it/api/v3/areas/by-external-id/erp-customer-4521 \ -H "X-API-Key: $AITRACK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "Customer 4521 — Rossi Trasporti", "type": "circle", "center": { "lat": 45.4642, "lng": 9.19 }, "radius_m": 150 }'{ "data": { "id": 88, "name": "Customer 4521 — Rossi Trasporti", "color": "#3b82f6", "type": "circle", "center": { "lat": 45.4642, "lng": 9.19 }, "radius_m": 150, "buffer_m": null, "points": [], "external_id": "erp-customer-4521", "dwell_seconds": 0, "metrics": { "area_m2": 70686, "perimeter_m": 942, "length_m": null }, "bounds": { "south": 45.4629, "west": 9.1881, "north": 45.4655, "east": 9.1919 }, "created_at": "2026-09-27T02:00:00.000Z" }, "meta": { "upsert": "created" }}The next night, the same call with the same externalId — say, with an updated center because the
customer moved sites — updates area 88 instead of creating a new one (200,
meta.upsert: "updated"); just like PATCH, only the fields you send change:
{ "data": { "id": 88, "name": "Customer 4521 — Rossi Trasporti", "center": { "lat": 45.4650, "lng": 9.1905 } }, "meta": { "upsert": "updated" } }Response 201 (created, Location header) or 200 (updated) — also distinguishable via
meta.upsert ("created" | "updated"). 400 BAD_REQUEST if the path externalId is invalid;
422 VALIDATION_ERROR if the data is invalid or the body carries an external_id different from
the one in the path (MISMATCH).
GET /api/v3/areas/{id}
Section titled “GET /api/v3/areas/{id}”Detail of one area. Permission: read_areas. ?format=geojson returns a Feature. A malformed
id (not a positive integer) → 400 BAD_REQUEST, before hitting the database. A valid id that’s
nonexistent or belongs to another customer → 404 NOT_FOUND (the two cases aren’t distinguished).
curl -H "X-API-Key: $AITRACK_API_KEY" \ https://api.aitrack.it/api/v3/areas/42PATCH /api/v3/areas/{id}
Section titled “PATCH /api/v3/areas/{id}”Partial update. Permission: write_areas. Only the fields you send change; omitted ones keep
their saved value.
# Name onlycurl -X PATCH https://api.aitrack.it/api/v3/areas/42 \ -H "X-API-Key: $AITRACK_API_KEY" \ -H "Content-Type: application/json" \ -d '{"name": "North Warehouse — truck entrance"}'# From circle to polygon: the new geometry must be sentcurl -X PATCH https://api.aitrack.it/api/v3/areas/42 \ -H "X-API-Key: $AITRACK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "polygon", "points": [ { "lat": 45.4640, "lng": 9.1895 }, { "lat": 45.4648, "lng": 9.1900 }, { "lat": 45.4638, "lng": 9.1912 } ] }'external_id and dwell_seconds update like any other field — see Model and
Minimum dwell time. Sending "external_id": null removes
the external code from the area (for example if you stop syncing it from an external system);
omitting it keeps whatever is already saved, like any other field on a partial PATCH.
Response 200 with the full updated area (same shape as GET). 400 BAD_REQUEST if the path id
is malformed (not a positive integer). 404 NOT_FOUND if it doesn’t exist, 422 VALIDATION_ERROR if the data is invalid.
DELETE /api/v3/areas/{id}
Section titled “DELETE /api/v3/areas/{id}”Deletes an area. Permission: write_areas. Response 204 with no body. 400 BAD_REQUEST if the
id is malformed. 404 NOT_FOUND if it doesn’t exist or isn’t yours.
curl -X DELETE https://api.aitrack.it/api/v3/areas/42 \ -H "X-API-Key: $AITRACK_API_KEY"POST /api/v3/areas/import
Section titled “POST /api/v3/areas/import”Imports several areas from GeoJSON in one call. Permission: write_areas. Maximum 200 areas
per call (features past the 200th are dropped and reported in errors; the first 200 are still
processed). Body: a FeatureCollection, a Feature, or a bare Geometry, either as the request
body directly or wrapped in { "geojson": ... }. One invalid feature doesn’t block the others.
?dry_run=true (or "dry_run": true in the body) validates without saving anything — useful for
showing the user what would be imported before they confirm.
A feature with properties.external_id updates the existing area with that code instead of
duplicating it (listed in data.updated, not data.created) — the same logic as
PUT .../by-external-id, handy when importing a
GeoJSON export already enriched with your own system’s codes. properties.dwell_seconds sets the
minimum dwell time of each imported area, following the same rule as a direct POST/PATCH. A
Multi* geometry (MultiPoint, MultiLineString, MultiPolygon) with properties.external_id
on the same feature would produce several areas sharing the same code: it’s rejected with the
MULTI_GEOMETRY_EXTERNAL_ID error — split it into one feature per part, each with its own
external_id, or omit it.
curl -X POST "https://api.aitrack.it/api/v3/areas/import?dry_run=true" \ -H "X-API-Key: $AITRACK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "FeatureCollection", "features": [ { "type": "Feature", "properties": { "name": "South Site", "radius": 100 }, "geometry": { "type": "Point", "coordinates": [9.195, 45.460] } }, { "type": "Feature", "properties": { "name": "Depot" }, "geometry": { "type": "Polygon", "coordinates": [[[9.10,45.40],[9.10,45.40]]] } } ] }'{ "data": { "dry_run": true, "valid": 1, "created": [], "updated": [], "errors": [{ "index": 1, "code": "errPolygonMin" }], "warnings": [] }}curl -X POST https://api.aitrack.it/api/v3/areas/import \ -H "X-API-Key: $AITRACK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "type": "FeatureCollection", "features": [ { "type": "Feature", "properties": { "name": "South Site", "radius": 100 }, "geometry": { "type": "Point", "coordinates": [9.195, 45.460] } }, { "type": "Feature", "properties": { "name": "Customer 4521", "external_id": "erp-customer-4521" }, "geometry": { "type": "Point", "coordinates": [9.19, 45.4642] } } ] }'{ "data": { "dry_run": false, "valid": 2, "created": [{ "index": 0, "id": 91 }], "updated": [{ "index": 1, "id": 88, "external_id": "erp-customer-4521" }], "errors": [], "warnings": [] }}Response 200 when dry_run, or when the real call only updated existing areas (none
created); 201 if at least one area was created; 422 if none was valid. errors[] mixes two
families of codes, distinguishable by whether message is present:
- from reading the GeoJSON (only
indexandcode):errGeoJsonInvalid,errGeometryUnsupported,errCoordinates,errTooManyPoints,errPolygonMin,errPolylineMin,errTooManyFeatures(the last one withindex: -1, when there are more than 200 features),MULTI_GEOMETRY_EXTERNAL_ID(index,codeandmessage: anexternal_idon aMulti*geometry). - from validating the resulting area (
index,code,message): the same codes as the Validation codes table below.
warnings[] (non-blocking): warnHolesIgnored (a polygon’s interior holes are ignored),
warnSimplifiedDuplicates (nearly coincident vertices simplified).
GET /api/v3/areas/{id}/contains
Section titled “GET /api/v3/areas/{id}/contains”Is the point lat/lng inside the area? Permission: read_areas. Same geometric rule as the
engine that generates entry/exit events, so it’s consistent with the notifications received by
anyone who has an alert configured on that area.
| Parameter | Required |
|---|---|
lat |
yes |
lng |
yes |
curl -H "X-API-Key: $AITRACK_API_KEY" \ "https://api.aitrack.it/api/v3/areas/42/contains?lat=45.4642&lng=9.19"{ "data": { "inside": true, "distance_m": -37.5 } }distance_m is negative inside the area (meters from the edge, toward the interior),
positive outside; it can be null if it can’t be computed. Missing or invalid lat/lng, or
a malformed id → 400 BAD_REQUEST. A valid id for a nonexistent area or another customer’s →
404 NOT_FOUND.
GET /api/v3/areas/{id}/events
Section titled “GET /api/v3/areas/{id}/events”Entries and exits recorded by the key owner’s geofence notification rules on this area — with a
dwell_seconds minimum set, the entries listed here follow
that rule: a vehicle that merely passed through doesn’t show up. Permission: read_areas.
| Parameter | Default | Notes |
|---|---|---|
event |
— | entry | exit — filter only entries or only exits; any other value → 400 BAD_REQUEST |
device |
— | filter by IMEI |
from / to |
— | time window, ISO 8601; unparseable → 400 BAD_REQUEST |
limit |
50 | 1–200 |
offset |
0 |
curl -H "X-API-Key: $AITRACK_API_KEY" \ "https://api.aitrack.it/api/v3/areas/42/events?event=entry&limit=50"{ "data": [ { "id": 5521, "area_id": 42, "device": "352093081452312", "event": "entry", "position": { "lat": 45.4641, "lng": 9.1899 }, "timestamp": "2026-09-27T08:59:00.000Z" } ], "meta": { "pagination": { "limit": 50, "offset": 0, "count": 1 } }}A malformed id → 400 BAD_REQUEST, before hitting the database. A valid id for a nonexistent area
or another customer’s → 404 NOT_FOUND.
Validation codes
Section titled “Validation codes”Each entry is an element of details[] in the 422 VALIDATION_ERROR response of POST/PATCH/
PUT .../by-external-id (direct create, update and upsert) — on POST /areas/import the same
codes appear in errors[].code alongside the GeoJSON-reading codes, see above. external_id
already used by another area isn’t a validation error: it responds 409 CONFLICT (no details[])
only on POST /areas — see creation and
upsert above.
| Field | Code | When |
|---|---|---|
name |
REQUIRED |
missing or empty name |
name |
TOO_LONG |
over 100 characters |
type |
INVALID |
missing or not one of circle/polygon/polyline/point |
color |
INVALID |
not in #rrggbb format |
center |
REQUIRED |
missing center on a circle or a place |
center |
INVALID_COORDINATES |
center lat/lng out of range or not numeric |
points |
INVALID_COORDINATES |
a point has lat/lng out of range or not numeric |
external_id |
INVALID |
doesn’t match the format: 1–100 characters made of letters, digits and . _ : / @ # - |
external_id |
MISMATCH |
only on PUT .../by-external-id/{externalId}: the body carries an external_id different from the one in the path |
dwell_seconds |
OUT_OF_RANGE |
not an integer between 0 and 86400 |
points |
TOO_FEW_POINTS |
fewer than 3 points on a polygon, fewer than 2 on a track |
points |
TOO_MANY_POINTS |
over 2000 points |
points |
SELF_INTERSECTION |
the polygon’s sides cross each other |
radius_m |
OUT_OF_RANGE |
below 25 m (circle only) or above 100000 m (circle and place) |
buffer_m |
OUT_OF_RANGE |
outside 5–5000 m (track only) |
geojson |
INVALID_GEOJSON |
{ "geojson": ... } doesn’t contain a valid geometry |
| (none) | INVALID_BODY |
the request body isn’t a JSON object |
GeoJSON: input and output
Section titled “GeoJSON: input and output”To create or update an area from GeoJSON, the body of POST/PATCH /areas/{id} accepts
{ "geojson": <Feature | Geometry> } — one geometry per call. POST /areas/import instead accepts
a FeatureCollection, a Feature, or a bare Geometry directly (also wrapped in
{ "geojson": ... }), up to 200 elements, with Multi* types becoming one area per part:
| GeoJSON geometry | Becomes | Notes |
|---|---|---|
Point |
point (place, 50 m radius) |
or circle if the feature has properties.radius |
LineString |
polyline |
properties.bufferDistance (or buffer_distance) sets the margin, otherwise 50 m |
Polygon |
polygon |
interior holes (rings past the first) are dropped with the warnHolesIgnored warning |
MultiPoint / MultiLineString / MultiPolygon |
several areas | only on /areas/import, one area per part |
properties.name (or properties.title) and properties.color (or properties.stroke, if
#rrggbb) become name/color unless the request body overrides them. Same rule for
properties.external_id and properties.dwell_seconds → external_id/dwell_seconds.
On read, ?format=geojson on GET /areas and GET /areas/{id} returns a Feature (or a
FeatureCollection for the list) per RFC 7946:
{ "data": { "type": "Feature", "id": 42, "geometry": { "type": "Point", "coordinates": [9.19, 45.4642] }, "properties": { "id": 42, "name": "North Warehouse", "color": "#3b82f6", "type": "circle", "radius": 150, "external_id": null, "dwell_seconds": 0, "metrics": { "area_m2": 70686, "perimeter_m": 942, "length_m": null }, "created_at": "2026-09-27T09:00:00.000Z" } }}