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, 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. 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.

JavaScript
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).

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.
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
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:

JavaScript
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.