---
title: Conventions
description: How every GPS API endpoint shapes responses, IDs, dates, pages, filters, attribution, errors and rate limits.
product: gps
section: api
order: 3
---

# Conventions

These rules apply to every endpoint in the GPS REST API.

## Base URL and versioning

```
https://plan.empiraa.app/api/v1
```

The version is part of the path. `GET /api/v1` reports `"version": "v1"`.

Responses can gain new fields over time, so ignore fields you don't recognize rather than failing on them.

## Requests

- Send request bodies as JSON with `Content-Type: application/json`. Bodies can be up to 10 MB.
- Fields a body doesn't recognize are ignored.
- The API uses `GET`, `POST`, `PATCH` and `DELETE`. `PATCH` changes only the fields you send.
- Cross-origin requests are allowed from any origin, with the headers `Authorization`, `X-API-Key`, `X-API-Secret`, `Content-Type` and `Accept`.

## Response envelope

A single record comes back under `data`:

```json
{ "data": { "id": "a1f3c5e7-2b4d-4f6a-8c9e-0b1d3f5a7c9e", "title": "Grow recurring revenue" } }
```

A list comes back under `data`, with paging details in `meta`:

```json
{
  "data": [ { "id": "a1f3c5e7-2b4d-4f6a-8c9e-0b1d3f5a7c9e", "title": "Grow recurring revenue" } ],
  "meta": { "total": 12, "limit": 50, "offset": 0 }
}
```

| Request | Success status |
|---------|----------------|
| `GET`, `PATCH` | `200 OK` |
| `POST` that creates something | `201 Created` |
| `DELETE` | `204 No Content`, with an empty body |

Two endpoints differ: `GET /api/v1` returns its fields at the top level, without `data`, and `POST /users` adds a `meta` object describing how the person was added.

## IDs

IDs are strings (UUIDs, for example `a1f3c5e7-2b4d-4f6a-8c9e-0b1d3f5a7c9e`). Treat them as opaque.

People are identified by their **member ID**: the `id` from [`GET /users`](reference/members.md). Use member IDs wherever a field holds people (`assignedTo`, `assignees`, `invitedUsers`, `attendees`, `onBehalfOf`) and expect them in fields such as `createdBy`, `authorId` and `updatedBy`. A member's `userId` is their GPS account ID, which isn't used anywhere else in the API.

## Dates and numbers

- Responses give timestamps as ISO 8601 strings in UTC, for example `2026-11-12T03:15:42.118Z`.
- Requests accept ISO 8601 dates (`2026-12-31`) or date-times (`2026-12-31T09:00:00+11:00`). A date on its own means midnight UTC on that day.
- Where a body has both `startDate` and `dueDate`, `dueDate` must be on or after `startDate`.
- Progress percentages on Goals and Objectives (`progress` and similar fields) come back as decimal **strings**, for example `"45"` or `"18.9"`. Targets and values (`targetValue`, `currentValue`) are numbers. Check-in figures are numbers.

## Pagination

Every list endpoint takes `limit` and `offset`:

| Parameter | Default | Allowed |
|-----------|---------|---------|
| `limit` | `50` | Whole number from 1 to 200 |
| `offset` | `0` | Whole number, 0 or more |

`meta.total` is the number of matching records before paging. Fetch the next page by adding `limit` to `offset` until `offset` reaches `total`.

```js
async function listAll(path) {
  const all = [];
  for (let offset = 0; ; offset += 200) {
    const res = await fetch(`https://plan.empiraa.app/api/v1${path}?limit=200&offset=${offset}`, { headers });
    const { data, meta } = await res.json();
    all.push(...data);
    if (offset + data.length >= meta.total) return all;
  }
}
```

A `limit` or `offset` outside these rules returns `422 VALIDATION_ERROR`, for example `limit must be between 1 and 200`.

Results come back in a stable order only where an endpoint's reference page says so. Sort on your side (by `createdAt`, for example) if order matters.

## Filtering

Filters are query parameters, listed on each endpoint's reference page. An unknown value for a `status`, `stage`, `role` or `inviteStatus` filter returns `400 INVALID_STATUS_QUERY` with the allowed values in the message.

What lists leave out:

- **Archived** Goals and Objectives aren't returned by the Goal and Objective lists, and `GET /goals/{id}` and `GET /objectives/{id}` return `404` for them. Actions whose Goal or Objective is archived are left out of the Action endpoints in the same way.
- **Archived** Meetings and **hidden** Ideas are left out and return `404`.
- Members: only active members unless you ask for more (see [`GET /users`](reference/members.md#list-members)).

Private items aren't filtered: an API key sees every Goal, Objective and Action in the organization, including ones marked private.

## Organization isolation

A key reads and writes only the organization it was created in. You never need to send an organization ID.

If a request does name an organization, in an `X-Organization-Id` header, an `organizationId` query parameter or an `organizationId` body field, it must be the key's own organization. Any other value returns `403 ORG_MISMATCH`.

Records in other organizations are invisible: asking for one returns `404 NOT_FOUND`, the same as for an ID that doesn't exist. Every ID you reference in a body (a parent Goal, an assignee, a linked Objective) must belong to the key's organization.

## Attribution with `onBehalfOf`

Records created through the API are attributed to the member who created the API key. To attribute a record to someone else, add `onBehalfOf` with their member ID:

```json
{ "title": "Draft the Q3 hiring plan", "objectiveId": "c7e9a1b3-4d5f-4a6b-9c8d-1e2f3a4b5c6d", "onBehalfOf": "6e8a0c2e-4f6b-4d8f-a0b1-3c4d5e6f7a81" }
```

- `onBehalfOf` must be an **active** member of the organization, or the request fails with `422 INVALID_USERS`.
- It's accepted by the endpoints that create records: `POST /goals`, `POST /objectives`, `POST /actions`, `POST /meetings`, `POST /ideas`, every comment `POST`, and `POST /objectives/{objectiveId}/progress`. It sets `createdBy`, a comment's `authorId`, or a check-in's `updatedBy`.
- Updates (`PATCH`) don't record who made them, so `onBehalfOf` has no effect there.
- If you leave `onBehalfOf` out and the member who created the key has since been removed from the organization, creates fail with `422 NO_KEY_OWNER`. Send `onBehalfOf`, or create a new key.

## Errors

Errors return a JSON body with a readable `error`, a stable `code` and a `docs` link:

```json
{
  "error": "Goal not found",
  "code": "NOT_FOUND",
  "docs": "https://empiraa.com/docs/api"
}
```

Build your handling on `code` and the HTTP status. The `error` text can change.

Validation failures add an `issues` array that names each field that failed:

```json
{
  "error": "Validation failed",
  "code": "VALIDATION_ERROR",
  "docs": "https://empiraa.com/docs/api",
  "issues": [
    {
      "code": "invalid_enum_value",
      "options": ["not_started", "started", "on_track", "off_track", "completed"],
      "path": ["status"],
      "message": "Invalid enum value. Expected 'not_started' | 'started' | 'on_track' | 'off_track' | 'completed', received 'done'",
      "received": "done"
    }
  ]
}
```

| Status | `code` | When |
|--------|--------|------|
| 400 | `INVALID_STATUS_QUERY` | A `status`, `stage`, `role` or `inviteStatus` filter has a value that isn't allowed. |
| 401 | `MISSING_CREDENTIALS`, `MISSING_SECRET`, `INVALID_CREDENTIALS`, `KEY_INACTIVE`, `KEY_EXPIRED` | See [Authentication](authentication.md#401-and-403-responses). |
| 403 | `INSUFFICIENT_SCOPE` | The key lacks the endpoint's scope. |
| 403 | `ORG_MISMATCH` | The request named another organization. |
| 403 | `FORBIDDEN` | A member change that isn't allowed, such as removing the person who created the organization's account. |
| 404 | `NOT_FOUND` | The record doesn't exist, belongs to another organization, is archived or hidden, or the path doesn't exist. |
| 409 | `MEMBER_IN_USE` | A member can't be removed while records still point to them. The body lists `blockers`. |
| 422 | `VALIDATION_ERROR` | The body or query failed validation. Schema failures include `issues`. |
| 422 | `INVALID_REFERENCE` | A referenced Goal, Objective or Action doesn't exist in the organization. |
| 422 | `INVALID_USERS` | One or more member IDs aren't active members. The body lists them in `invalidIds`. |
| 422 | `NO_KEY_OWNER` | No `onBehalfOf` was sent and the key's creator is no longer a member. |
| 422 | `NO_INVITER` | `POST /users` couldn't find a member to send the invitation from. |
| 429 | `RATE_LIMIT_EXCEEDED` | Too many requests. See [Rate limits](#rate-limits). |
| 500 | `INTERNAL_ERROR` | Something failed on our side. Retry later; contact support if it persists. |

## Rate limits

Each API key can make **100 requests per minute**, counted in fixed 60-second windows. Requests past the limit return `429` with a `Retry-After` header (seconds until the window resets) and the same number in the body:

```http
HTTP/1.1 429 Too Many Requests
Retry-After: 23
Content-Type: application/json

{ "error": "Too Many Requests", "code": "RATE_LIMIT_EXCEEDED", "retryAfterSeconds": 23 }
```

This body has no `docs` field. Responses don't carry remaining-quota headers, so pace bulk jobs yourself and wait for `Retry-After` when you see a `429`:

```js
async function gpsFetch(url, options) {
  for (;;) {
    const res = await fetch(url, options);
    if (res.status !== 429) return res;
    const wait = Number(res.headers.get('Retry-After') ?? 60);
    await new Promise((r) => setTimeout(r, wait * 1000));
  }
}
```

`GET /api/v1` reports the limit in `rateLimit.requestsPerMinute`.

## Idempotency

The API doesn't support idempotency keys. If a `POST` times out and you retry it, you can create the record twice. Before retrying a create, list the records (for example by title or parent) to check whether the first attempt landed.

`DELETE` is safe to repeat: a second call returns `404`.

## Deleting is permanent

`DELETE` removes records for good; there's no undo through the API. Deleting a Goal also deletes its Objectives (and their sub-Objectives), the Actions linked to the Goal or to those Objectives, and their check-in history and comments. Deleting an Objective does the same for its sub-Objectives, Actions, check-ins and comments.
