# Conventions

> Response shape, paging, incremental sync and rate limits.

## Response shape

Every successful response is wrapped:

```json
{ "data": ..., "meta": { "nextCursor": null } }
```

`data` is an object for a single resource and an array for a list. `meta` only
appears where there is something to say.

Every failure looks like this, with a stable machine-readable `type`:

```json
{
  "error": {
    "type": "insufficient_scope",
    "message": "This API key lacks the 'tables:read' scope.",
    "docs": "https://docs.gimlabs.io/api/errors#insufficient_scope"
  }
}
```

Branch on `type`, never on `message`. Messages get reworded; types do not.

## Paging

List endpoints are cursor-paged.

| Parameter | Default | Max |
| --- | --- | --- |
| `limit` | 100 | 200 |
| `cursor` | - | - |

Read `meta.nextCursor` and pass it back as `?cursor=`. When it comes back
`null`, you have everything.

```bash
curl -H "Authorization: Bearer $KEY" \
  "https://api.gimlabs.io/v1/projects?limit=100&cursor=6512a4b8c9d0e1f234567801"
```

Cursors are stable while data is being added underneath you, which offset
paging is not. Do not build a cursor yourself or store one long-term; treat it
as opaque.

## Incremental sync

Every list endpoint accepts `?updatedSince=<ISO 8601>` and returns only rows
changed at or after that moment.

```bash
curl -H "Authorization: Bearer $KEY" \
  "https://api.gimlabs.io/v1/projects/{id}/tables/GEOL?updatedSince=2026-08-01T00:00:00Z"
```

This is how you keep a warehouse or a dashboard fresh without re-pulling
everything. Store the timestamp of your last successful run and pass it next
time.

<Callout type="note">
Deleted rows are simply absent; there is no tombstone. If you need to detect
deletions, do a full pull periodically alongside your incremental ones.
</Callout>

## Rate limits

Limits apply on two axes at once. Per key stops one runaway script. Per company
is the real ceiling, since you can mint as many keys as you like.

Endpoints are grouped by how much work they cost us:

| Class | Endpoints | Per key | Per company |
| --- | --- | --- | --- |
| Cheap | `/me`, `/projects`, `/projects/{id}` | 120/min | 600/min |
| Read | locations, tables | 60/min | 300/min |

Plus a company-wide ceiling of **100,000 requests per month** across all keys.

Every response carries your current state:

```
RateLimit-Limit: 60
RateLimit-Remaining: 58
RateLimit-Reset: 1787781060
```

`RateLimit-Reset` is a Unix timestamp in seconds.

### When you hit a limit

You get **429** with a `Retry-After` header in seconds. Wait that long, then
retry. `Retry-After` accounts for which window you actually blew, so a monthly
breach tells you the truth rather than suggesting you try again in 40 seconds.

Back off rather than retrying in a tight loop. A sensible client watches
`RateLimit-Remaining` and slows down before it gets refused at all.

## Dates

All timestamps are UTC, ISO 8601, e.g. `2026-08-26T09:12:44.000Z`. Rate-limit
windows are UTC too, so a monthly allowance resets at midnight UTC on the 1st,
not in your local timezone.

## Values are strings

Inside a `data` object, values come back exactly as stored, as strings. We do
not coerce them, because guessing types is how precision gets quietly lost in
ground investigation data. Convert at your end.

The lifted location fields (`easting`, `northing`, `latitude`, `longitude`,
`groundLevel`, `finalDepth`) are the deliberate exception: numbers, or `null`
where the location has no such value.
