Skip to content
GIMLabsDocs

Endpoints

Every endpoint, what it returns and which scope it needs.

All paths are relative to https://api.gimlabs.io/v1. Everything is GET.

EndpointScope
/healthnone
/menone
/projectsprojects:read
/projects/{id}projects:read
/projects/{id}/locationslocations:read
/locations/{id}locations:read
/projects/{id}/tables/{table}tables:read
/locations/{id}/tables/{table}tables:read
/projects/{id}/boundariesmap:read
/projects/{id}/groupsmap:read
/media/markers/{id}map:read

Every {id} is the id field from a list response, a 24-character hex string. It is not your own project reference.

Every list endpoint accepts limit, cursor, updatedSince and sort. See Conventions for paging and incremental sync.

sort takes created (oldest first, the default) or -created (newest first). Nothing else is sortable: paging is built on creation order, so a sort we cannot page is refused with 400 invalid_sort rather than accepted and ignored.

GET /health

Unauthenticated. Confirms you have the right base URL and that the v1 namespace is deployed.

{ "data": { "status": "ok", "version": "v1" } }

It does not check the database, so treat it as "am I pointing at the right place", not as a status page.

GET /me

What this key is and what it can reach. Needs no scope, so it answers even for a key with nothing ticked. The first thing to call when a key misbehaves.

{
  "data": {
    "company": "GIMLabs",
    "scopes": ["projects:read", "locations:read"],
    "projects": [{ "id": "6512a4b8c9d0e1f234567801", "name": "Riverside Depot" }],
    "rateLimit": { "limit": 120, "remaining": 119, "reset": 1787781060 }
  }
}

projects is null when the key is not restricted to particular projects.

GET /projects

Your projects, paginated. A project-restricted key sees only its allow-list.

Accepts q and include in addition to the usual list parameters.

{
  "data": [
    {
      "id": "6512a4b8c9d0e1f234567801",
      "projectId": "GL-2201",
      "projectName": "Riverside Depot",
      "status": "open",
      "client": "Northgate Developments",
      "contractor": "Deepcut Drilling",
      "consultant": null,
      "contractStartDate": null,
      "hasSiteBoundary": true,
      "createdAt": "2026-06-05T08:20:11.000Z",
      "updatedAt": "2026-08-01T14:02:55.000Z"
    }
  ],
  "meta": { "nextCursor": "6512a4b8c9d0e1f234567801" }
}

client, contractor and consultant are returned as names rather than ids, because there is no endpoint to look an id up.

hasSiteBoundary says whether a boundary has been drawn for the project. The boundary geometry itself is not returned.

?q=

Filters by project reference or name, matching anywhere and ignoring case:

GET /projects?q=riverside&sort=-created&limit=25

Built for pickers. A firm with two thousand projects should not have to page through all of them to find one, and a client should not have to hold them all in memory to offer a search box.

?include=location

Adds the mean position of the project's locations:

"location": { "longitude": -1.9034, "latitude": 52.4812, "count": 14 }

Opt-in because working it out means loading every location in every project on the page. null when a project has no locations, or none with coordinates - note that a location pinned on the map but without surveyed coordinates does not count towards the centre.

GET /projects/{id}

Everything about one project: the list fields, its centre, and which tables it actually holds. This is the call to make first - one request tells you what is there and what to fetch next, instead of guessing table codes and collecting 404s.

{
  "data": {
    "id": "6512a4b8c9d0e1f234567801",
    "projectId": "GL-2201",
    "projectName": "Riverside Depot",
    "status": "open",
    "client": "Northgate Developments",
    "contractor": "Deepcut Drilling",
    "consultant": null,
    "hasSiteBoundary": true,
    "location": { "longitude": -1.9034, "latitude": 52.4812, "count": 14 },
    "tables": [
      { "code": "LOCA", "rows": 14, "name": "Location Details" },
      { "code": "GEOL", "rows": 86, "name": "Field Geological Descriptions" },
      { "code": "SAMP", "rows": 41, "name": "Sample Information" }
    ],
    "createdAt": "2026-06-05T08:20:11.000Z",
    "updatedAt": "2026-08-01T14:02:55.000Z"
  }
}

tables lists only tables holding at least one row. code is the AGS code you pass to /tables/{table}, name is what your configuration calls the table. A null code means no AGS export code is configured, so those rows exist but cannot be fetched.

Unlike the list, location is always resolved here. It is one project, so the cost that makes it opt-in on a page of 200 does not apply.

GET /projects/{id}/locations

The locations in a project. ?locationId=BH01 finds one by name, which is how you get from a record back to its borehole.

{
  "data": [
    {
      "id": "6512a4b8c9d0e1f234567890",
      "locationId": "BH01",
      "table": "LOCA",
      "easting": null,
      "northing": null,
      "latitude": null,
      "longitude": null,
      "groundLevel": null,
      "finalDepth": 1.65,
      "mapPlacement": { "latitude": 52.4812, "longitude": -1.9034 },
      "data": { "LOCA_ID": "BH01", "LOCA_TYPE": "CP", "LOCA_FDEP": "1.65" },
      "updatedAt": "2026-06-14T09:12:44.000Z"
    }
  ],
  "meta": { "nextCursor": null }
}

The survey fields are resolved through your configuration and converted to numbers, so you do not need to know which AGS heading holds the easting. They are null when the location genuinely has no such value.

mapPlacement is the position pinned on the map in Manage, which is independent of any surveyed coordinate. It is null when nothing has been pinned.

?include=style

Adds the resolved marker style for each location. Needs map:read on top of locations:read.

"style": {
  "locationType": "CP",
  "source": "type:6512a4b8c9d0e1f234567891",
  "markerColor": "#3fcf8e",
  "markerSize": 20,
  "markerSymbol": "borehole",
  "markerAnchor": null,
  "showMarker": true,
  "showLabel": true,
  "labelColor": null,
  "labelSize": null,
  "labelOffsetAngle": null,
  "labelOffsetDistance": null,
  "hasCustomIcon": false,
  "markerIconId": null
}

This is the style Manage actually draws, resolved through the whole cascade: the system default, then the location type, then this project's override for that type, then any style set on the location itself. source names the level that won, so you can tell a deliberate colour from an inherited one.

hasCustomIcon is true when the resolved marker is custom artwork rather than a built-in symbol, and markerIconId is the id to fetch it with. Both are null/false for a location using a plain symbol.

GET /media/markers/{id}

The SVG behind a location's marker icon, for the markerIconId on a styled location. Returns the image itself, not the JSON envelope.

GET /media/markers/69309df2b2f26b49280580cb
-> 200 image/svg+xml

Deliberately narrow: it serves marker artwork and nothing else. Anything that is not an SVG configuration asset is a 404, project media included, so a map:read key cannot reach site photographs through it. Icons are cached for a day; they change about never.

Colours are not baked in. A marker's colour comes from the location's style, and the artwork is expected to inherit it - which is why the same icon serves every colour of the same location type.

GET /projects/{id}/boundaries

Site boundaries drawn on the project, as GeoJSON geometry in WGS84.

{
  "data": [
    {
      "id": "6512a4b8c9d0e1f234567892",
      "name": "Site Boundary",
      "geometry": {
        "type": "Polygon",
        "coordinates": [[[-1.9034, 52.4812], [-1.9020, 52.4812], [-1.9020, 52.4801], [-1.9034, 52.4801], [-1.9034, 52.4812]]]
      },
      "visible": true,
      "labelVisible": true,
      "style": {
        "strokeColor": "#ef4444",
        "strokeWidth": 3,
        "strokeOpacity": 1,
        "strokeStyle": "solid",
        "fillColor": "#ef4444",
        "fillOpacity": 0
      },
      "updatedAt": "2026-06-14T09:12:44.000Z"
    }
  ],
  "meta": { "nextCursor": null }
}

geometry is a Polygon or a LineString. visible and labelVisible are the toggles set in Manage; honouring them keeps a drawing in step with what the map shows, and ignoring them is a reasonable choice if you want everything.

GET /projects/{id}/groups

Location groups: a named set of locations plus how the map draws it.

{
  "data": [
    {
      "id": "6512a4b8c9d0e1f234567893",
      "name": "Phase 1 Locations",
      "locationIds": ["6512a4b8c9d0e1f234567890", "6512a4b8c9d0e1f234567894"],
      "locationCount": 2,
      "markerSymbol": "circle",
      "showMarkers": true,
      "visible": true,
      "labelVisible": true,
      "style": {
        "strokeColor": "#3b82f6",
        "strokeWidth": 3,
        "strokeOpacity": 1,
        "strokeStyle": "solid",
        "fillColor": "#3b82f6",
        "fillOpacity": 0.2
      },
      "updatedAt": "2026-06-14T09:12:44.000Z"
    }
  ],
  "meta": { "nextCursor": null }
}

A group has no geometry. The outline Manage draws is a convex hull computed from its locations at render time, not something stored, so what you get is the membership and the styling. Fetch the locations, take the hull yourself, and apply style to it - QGIS, PostGIS and turf all have a convex hull built in. Manage skips a group with fewer than three positioned locations, since there is no polygon to draw.

locationIds are the id values from /projects/{id}/locations, so the join is direct.

GET /locations/{id}

One location, with normalised coordinates and what data exists for it. The same row is reachable via /projects/{id}/tables/LOCA, but only here do you get the coordinates resolved into numbers, mapPlacement, and the per-location table counts.

{
  "data": {
    "id": "6512a4b8c9d0e1f234567890",
    "locationId": "TP306",
    "table": "LOCA",
    "easting": 528123.4,
    "northing": 181447.2,
    "latitude": null,
    "longitude": null,
    "groundLevel": 12.35,
    "finalDepth": 4.5,
    "mapPlacement": null,
    "data": {
      "LOCA_ID": "TP306",
      "LOCA_TYPE": "TP",
      "LOCA_NATE": "528123.4",
      "LOCA_NATN": "181447.2",
      "LOCA_GL": "12.35",
      "LOCA_FDEP": "4.5"
    },
    "tables": [
      { "code": "BKFL", "rows": 1, "name": "Exploratory Hole Backfill Details" },
      { "code": "ERES", "rows": 65, "name": "Environmental Contaminant Testing" },
      { "code": "GEOL", "rows": 2, "name": "Field Geological Descriptions" },
      { "code": "SAMP", "rows": 3, "name": "Sample Information" }
    ],
    "updatedAt": "2026-06-14T09:54:46.935Z"
  }
}

The coordinate fields are resolved through your configuration and converted to numbers, so you do not need to know which AGS heading holds the easting. Any of them can be null, which is common - check rather than assume.

mapPlacement is where the location is pinned on the map in Manage, independent of any surveyed coordinate. tables counts what hangs off this location.

Only rows in your configuration's locations table are addressable here; a GEOL row id returns not_found.

GET /projects/{id}/tables/{table}

One table across every location in the project. Rows come back keyed by AGS heading, each carrying the location it belongs to.

{
  "data": [
    {
      "id": "6512a4b8c9d0e1f2345678ab",
      "table": "ERES",
      "locationId": "BH01",
      "parents": [
        {
          "table": "SAMP",
          "keys": {
            "SAMP_ID": "BH01-0.5-1",
            "SAMP_REF": "1",
            "SAMP_TOP": "0.50",
            "SAMP_TYPE": "ES"
          }
        }
      ],
      "data": { "ERES_TEST": "pH", "ERES_RVAL": "7.8", "SPEC_REF": "1" },
      "updatedAt": "2026-06-14T10:31:02.000Z"
    }
  ],
  "meta": { "nextCursor": null, "table": "ERES" }
}

Every row is self-describing. locationId is the LOCA_ID - the same name you see on the location and in an AGS file - and parents describes each ancestor by the key headings that identify it, nearest first. Between them you can place any row in the hierarchy and join it to anything without a second request.

Rows whose parent is the location itself, such as GEOL, have an empty parents: locationId already says it.

We deliberately do not return internal row ids for locations or parents. There is no endpoint that resolves one, so they would be identifiers with no lock to put them in. To go from a row back to its location, use /projects/{id}/locations?locationId=BH01.

Only fields with an AGS export code appear in data. Internal fields are left out.

GET /locations/{id}/tables/{table}

The same rows, filtered to one location by the server.

{
  "data": [
    {
      "id": "6512a4b8c9d0e1f2345678ab",
      "table": "GEOL",
      "locationId": "BH01",
      "parents": [],
      "data": {
        "GEOL_TOP": "0.00",
        "GEOL_BASE": "0.35",
        "GEOL_DESC": "Firm brown slightly sandy CLAY with rare gravel.",
        "GEOL_LEG": "103"
      },
      "updatedAt": "2026-06-14T10:31:02.000Z"
    }
  ],
  "meta": { "nextCursor": null, "table": "GEOL" }
}

Use this rather than filtering the project-wide response yourself. The project route has no location parameter, so on a large table the difference is substantial: one borehole's ERES rows might be 65 records here against 8,836 across 45 pages there.

An id that is not a location - a GEOL row id, say - returns not_found rather than an empty list.

Last updated 26 August 2026