Conventions
How every GPS API endpoint shapes responses, IDs, dates, pages, filters, attribution, errors and rate limits.
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,PATCHandDELETE.PATCHchanges only the fields you send. - Cross-origin requests are allowed from any origin, with the headers
Authorization,X-API-Key,X-API-Secret,Content-TypeandAccept.
Response envelope#
A single record comes back under data:
{ "data": { "id": "a1f3c5e7-2b4d-4f6a-8c9e-0b1d3f5a7c9e", "title": "Grow recurring revenue" } }
A list comes back under data, with paging details in meta:
{
"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. 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
startDateanddueDate,dueDatemust be on or afterstartDate. - Progress percentages on Goals and Objectives (
progressand 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.
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}andGET /objectives/{id}return404for 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).
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:
{ "title": "Draft the Q3 hiring plan", "objectiveId": "c7e9a1b3-4d5f-4a6b-9c8d-1e2f3a4b5c6d", "onBehalfOf": "6e8a0c2e-4f6b-4d8f-a0b1-3c4d5e6f7a81" }
onBehalfOfmust be an active member of the organization, or the request fails with422 INVALID_USERS.- It's accepted by the endpoints that create records:
POST /goals,POST /objectives,POST /actions,POST /meetings,POST /ideas, every commentPOST, andPOST /objectives/{objectiveId}/progress. It setscreatedBy, a comment'sauthorId, or a check-in'supdatedBy. - Updates (
PATCH) don't record who made them, soonBehalfOfhas no effect there. - If you leave
onBehalfOfout and the member who created the key has since been removed from the organization, creates fail with422 NO_KEY_OWNER. SendonBehalfOf, or create a new key.
Errors#
Errors return a JSON body with a readable error, a stable code and a docs link:
{
"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:
{
"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. |
| 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. |
| 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/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:
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.

