# Endpoints

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

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

| Endpoint | Scope |
| --- | --- |
| `/health` | none |
| `/me` | none |
| `/projects` | `projects:read` |
| `/projects/{id}` | `projects:read` |
| `/projects/{id}/locations` | `locations:read` |
| `/locations/{id}` | `locations:read` |
| `/projects/{id}/tables/{table}` | `tables:read` |
| `/locations/{id}/tables/{table}` | `tables:read` |
| `/projects/{id}/boundaries` | `map:read` |
| `/projects/{id}/groups` | `map: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](/api/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.

```json
{ "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.

```json
{
  "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.

```json
{
  "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:

```json
"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.

```json
{
  "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.

```json
{
  "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`.

```json
"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.

```json
{
  "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.

```json
{
  "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.

```json
{
  "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.

```json
{
  "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.

```json
{
  "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.
