REST API
EOS Hub has a REST API for integrations -- automation tools such as n8n or Zapier, reporting scripts, spreadsheets, or your own apps. It returns the same data you see in the web app, as JSON.
Current scope
You can read organizations, teams, to-dos, issues, Rocks, the Scorecard, meetings, the V/TO, the Accountability Chart, People Analyzer entries, and documents.
You can create, change, and delete to-dos, issues, Rocks with their milestones, and Scorecard measurables with their weekly values -- see Writing Data. Meetings, the V/TO, the Accountability Chart, People Analyzer entries, documents, and organization and platform administration are read only through the API for now; writing them is planned for a later release.
| Base URL | https://<your-host>/api/v1 |
| Interactive reference | https://<your-host>/api/v1/docs -- browse every endpoint and try calls with your own key |
| OpenAPI spec | https://<your-host>/api/v1/openapi.json (OpenAPI 3.1) -- for code generators and API clients |
| Authentication | Authorization: Bearer ak_… (personal API key) |
| Format | JSON, camelCase field names |
<your-host> is the address of your EOS Hub installation -- the same one you open in the browser.
Authentication and API Keys
Every request needs a personal API key in the Authorization header:
Authorization: Bearer ak_k3m7q2xa_Vq0…Create keys in the web app under Settings > API keys -- see API Keys for the full walkthrough. In short:
- The full key is shown only once, right after you create it. EOS Hub stores only a hash of it, so a lost key cannot be recovered -- revoke it and create a new one.
- Only API keys are accepted. A signed-in browser session does not work for
/api/v1, and keys can be created or revoked only from the signed-in web app, never through the API. - A missing, malformed, expired, or revoked key gets
401 unauthenticated.
A Key Acts as You
A key has no permissions of its own. It acts as its owner, with the owner's current organization and team roles, checked on every request:
- The API returns exactly what you could see in the web app -- for an organization Member only their own teams, for Owners, Admins, and Implementers every team of the organization.
- If you are removed from an organization or a team, your keys lose access to it immediately.
- In a suspended organization, keys can still read, but every change is refused with
409 orgSuspended. - The platform operator's keys get no automatic access to customer data either -- like in the web app, they see an organization only if they are a member of it.
Scopes
The scopes of a key limit what it may do on top of your roles:
| Scope | Allows | Who can create it |
|---|---|---|
read | Reading everything your roles allow | Everyone (Read only in the create dialog) |
write | Creating and changing data as your roles allow; implies read | Everyone (Read & write) |
platform | Platform administration | Platform operators (SUPERADMIN) only; valid for at most 90 days |
A request whose key lacks the required scope gets 403 insufficientScope. A platform key stops working for platform administration as soon as its owner is no longer a platform operator. Reading needs read; creating, changing, and deleting data needs write (a Read & write key).
Your First Calls
Keep the key in an environment variable instead of typing it into every command. read -s does not echo the key and keeps it out of your shell history:
read -rsp "API key: " EOSHUB_KEY && export EOSHUB_KEY # paste your key, then Enter
export BASE="https://<your-host>/api/v1"1. Who am I? GET /me returns your account, your organizations with your role in each, and the scopes of the key you are using:
curl -s -H "Authorization: Bearer $EOSHUB_KEY" "$BASE/me"{
"user": { "id": "cmg4x…", "email": "jana@acme.com", "name": "Jana Nováková", "timezone": null },
"orgs": [{ "id": "cmg1a…", "name": "Acme Corp", "status": "ACTIVE", "role": "OWNER" }],
"scopes": ["read"],
"key": { "id": "cmg9k…", "name": "n8n reports", "expiresAt": "2027-01-04T09:12:44.000Z" }
}2. Teams of an organization. Take an organization id from the response:
curl -s -H "Authorization: Bearer $EOSHUB_KEY" "$BASE/orgs/cmg1a…/teams"{
"data": [
{ "id": "cmg2b…", "name": "Leadership Team", "slug": "leadership", "isLeadership": true },
{ "id": "cmg2c…", "name": "Marketing", "slug": "marketing", "isLeadership": false }
],
"nextCursor": null
}3. Open to-dos of a team. To-Dos are called tasks in the API:
curl -s -H "Authorization: Bearer $EOSHUB_KEY" "$BASE/teams/cmg2b…/tasks?completed=false"{
"data": [
{
"id": "cmg7t…",
"teamId": "cmg2b…",
"title": "Send the Q3 pricing proposal",
"ownerId": "cmg4x…",
"dueDate": "2026-10-13",
"completed": false,
"completedAt": null,
"meetingId": "cmg6m…",
"createdAt": "2026-10-06T08:41:02.000Z"
}
],
"nextCursor": null
}TIP
The interactive reference shows the full response schema of every endpoint. Enter your key there to try calls straight from the browser.
Pagination
Lists return one page at a time:
{ "data": [ … ], "nextCursor": "eyJrIjoiMjAyNi0xMC0wNlQwODo0MTowMi4wMDBaIiwiaWQiOiJjbWc3dCJ9" }| Parameter | Description |
|---|---|
limit | Page size, 1--200 (default 50) |
cursor | The nextCursor value from the previous page; omit it for the first page |
When nextCursor is null, you have reached the last page.
The API uses cursor pagination: the cursor marks the last item you received, and the next page continues right after it. Unlike page numbers, it stays stable when items are added or deleted while you are paging -- you do not get duplicates or skip items. The trade-off is that you cannot jump to page N; you walk through the pages in order. Treat the cursor as an opaque string and always use the same filters for every page.
A few small lists -- organizations, teams of an organization, Scorecard metrics, and Accountability Chart seats -- always return everything at once with nextCursor: null.
Fetching every page:
cursor=""
while :; do
page=$(curl -s -H "Authorization: Bearer $EOSHUB_KEY" \
"$BASE/teams/$TEAM_ID/tasks?limit=200${cursor:+&cursor=$cursor}")
echo "$page" | jq -r '.data[] | "\(.dueDate) \(.title)"'
cursor=$(echo "$page" | jq -r '.nextCursor // empty')
[ -z "$cursor" ] && break
doneconst BASE = "https://<your-host>/api/v1"
async function* listAll(path, params = {}) {
let cursor = null
do {
const url = new URL(BASE + path)
for (const [k, v] of Object.entries({ ...params, limit: 200 })) url.searchParams.set(k, v)
if (cursor) url.searchParams.set("cursor", cursor)
const res = await fetch(url, { headers: { Authorization: `Bearer ${process.env.EOSHUB_KEY}` } })
const body = await res.json()
if (!res.ok) throw new Error(`${res.status} ${body.error.code}: ${body.error.message}`)
yield* body.data
cursor = body.nextCursor
} while (cursor)
}
for await (const task of listAll(`/teams/${teamId}/tasks`, { completed: "false" })) {
console.log(task.dueDate, task.title)
}Errors
Errors use standard HTTP status codes and a JSON body with a machine-readable code:
{
"error": {
"code": "invalid",
"message": "The request is invalid",
"issues": [
{
"origin": "number",
"code": "too_big",
"maximum": 200,
"inclusive": true,
"path": ["limit"],
"message": "Too big: expected number to be <=200"
}
]
}
}| HTTP | code | Meaning |
|---|---|---|
| 401 | unauthenticated | Missing, malformed, expired, or revoked API key |
| 403 | forbidden | Your roles do not allow access to this organization or team, or do not allow this change |
| 403 | insufficientScope | The key's scopes do not allow this operation |
| 404 | notFound | The item does not exist, or you cannot see it |
| 409 | orgSuspended | The organization is suspended (read only) -- every change is refused |
| 409 | ratingClosed | The rating window of the meeting is closed |
| 422 | invalid | Invalid parameters or body; for format errors issues lists the problems field by field (see Validation) |
| 429 | rateLimited | Too many requests -- see Rate Limit |
| 500 | internal | Unexpected server error (no details are returned) |
Items you cannot access. Lists under a team or an organization you have no access to -- for example /teams/{teamId}/tasks -- return 403 forbidden. Single items addressed by id -- /tasks/{id}, /issues/{id}, /goals/{id}, /meetings/{id}, /metrics/{id}/values -- return 404 notFound instead, so ids from other organizations reveal nothing.
Changes are different: a PATCH, PUT, or DELETE of an existing item you are not allowed to change -- for example a to-do of a team where you are a Viewer -- returns 403 forbidden. An id that does not exist returns 404 notFound.
Every response, including errors, carries an X-Request-Id header. Include it when you report a problem -- it lets the operator find the request in the server logs.
Rate Limit
Each API key may make 120 requests per minute. Above that, the API answers 429 rateLimited with a Retry-After header -- the number of seconds until the next minute starts. Wait that long, then continue. The limit is per key, so separate integrations should use separate keys.
TIP
Use limit=200 when you need whole lists -- one request per 200 items instead of four.
Data Formats
| Kind | Format | Example |
|---|---|---|
| Field names | camelCase | dueDate, isLeadership |
| Ids | Opaque strings | "cmg7t…" |
| Instants (created, completed, started …) | ISO 8601, UTC | "2026-10-06T14:30:00.000Z" |
| Calendar days (due dates, meeting day, metric week) | YYYY-MM-DD, no time zone | "2026-10-07" |
| Quarters | YYYY-Qn | "2026-Q3" |
| Enum values | UPPER_CASE | OPEN, ON_TRACK, COMPLETED |
| Missing values | null (fields are always present) | "completedAt": null |
Calendar days never shift between time zones -- a to-do due on 2026-10-07 is due on that day everywhere. Filters such as from, to, and quarter use the same formats.
A few fields are kept as they are stored in the app:
- Metric goals keep their comparison operator, e.g.
">100000"or"<5", and metric values are strings. - V/TO fields are either plain text or structured JSON (lists, objects).
- People Analyzer
coreValuesScoresandgwcare JSON objects.
The API version is part of the URL (/v1). New fields may be added to responses without a new version, so make your integration ignore fields it does not know.
EOS Terms in the API
The web app uses EOS terminology. The API uses neutral names, so integrations read naturally to developers who do not know EOS:
| In EOS Hub | In the API |
|---|---|
| Organization, team | orgs, teams |
| To-Do | tasks |
| Issue | issues |
| Rock, milestone | goals (milestones are embedded in each goal) |
| Scorecard measurable, weekly value | metrics, metrics/{id}/values |
| L10 Meeting | meetings (with ratings, segues, headlines, messages) |
| V/TO | vision |
| Accountability Chart | org-chart (seats) |
| People Analyzer | people-reviews |
| Documents | documents |
Endpoints
The endpoints in this section read data: they use GET and need the read scope. Paginated lists accept limit and cursor in addition to the filters shown. Endpoints that change data are listed in Writing Data.
Account and Organizations
| Endpoint | Returns | Filters |
|---|---|---|
/me | Your account, organizations with your role, and the key's scopes | -- |
/orgs | Your organizations, with your role in each | -- |
/orgs/{orgId} | One organization | -- |
/orgs/{orgId}/members | All organization members with their organization role (paginated). Organization Owners and Admins only -- other members list the members of their teams via /teams/{teamId}/members | -- |
/orgs/{orgId}/teams | Teams of the organization you can see | -- |
Teams
| Endpoint | Returns | Filters |
|---|---|---|
/teams/{teamId} | One team, including its meeting duration settings | -- |
/teams/{teamId}/members | Team members with their team role (paginated) | -- |
Team Data
| Endpoint | Returns | Filters |
|---|---|---|
/teams/{teamId}/tasks | To-Dos (paginated) | completed (true/false), ownerId |
/tasks/{id} | One to-do | -- |
/teams/{teamId}/issues | Issues (paginated) | status (OPEN, SOLVING, SOLVED), priority (HIGH, MEDIUM, LOW) |
/issues/{id} | One issue | -- |
/teams/{teamId}/goals | Rocks with their milestones (paginated) | quarter (2026-Q3), status (ON_TRACK, OFF_TRACK, DONE), level (COMPANY, DEPARTMENT) |
/orgs/{orgId}/goals | Company Rocks of the organization, visible to all its members (paginated) | quarter, status |
/goals/{id} | One Rock with its milestones (company Rocks are readable by every member of the organization) | -- |
/teams/{teamId}/metrics | Scorecard measurables | -- |
/metrics/{id}/values | Weekly values of a measurable, oldest first (paginated) | from, to (days, inclusive) |
/teams/{teamId}/meetings | Meetings, newest first, each with its rating summary (paginated) | status (SCHEDULED, IN_PROGRESS, COMPLETED) |
/meetings/{id} | One meeting with notes, ratings, segues, headlines, cascading messages, and the rating window | -- |
/teams/{teamId}/vision | The team's V/TO (404 if the team has none yet) | -- |
/teams/{teamId}/org-chart | Accountability Chart seats; parentId links each seat to the one above it | -- |
/teams/{teamId}/people-reviews | People Analyzer entries (paginated) | quarter (2026-Q3) |
/teams/{teamId}/documents | Documents and links (paginated) | -- |
Meeting ratings follow the same rules as in the app: the rating.average excludes absent and unrated members (see L10 Meetings), and ratingWindow tells whether team members may still rate.
Writing Data
To-dos, issues, Rocks with their milestones, and Scorecard measurables with their weekly values can be created, changed, and deleted through the API. Every change needs a key with the write scope (Read & write in the create dialog) -- a Read only key gets 403 insufficientScope.
Methods
| Method | Use | Success response |
|---|---|---|
POST | Create an item | 201 Created with the created item |
PATCH | Partial update -- send only the fields you want to change; omitted fields keep their values | 200 OK with the updated item |
PUT | Set a metric's value for one week (create or replace) -- idempotent, sending the same request again changes nothing | 200 OK with the saved value |
DELETE | Delete an item | 204 No Content, no body |
Send the body as JSON with the header Content-Type: application/json. The responses have the same shape as the corresponding GET -- a created to-do looks exactly like one from GET /tasks/{id}.
A PATCH body must change at least one field: an empty body {} is refused with 422 invalid ("Nothing to update"). Fields the API does not know are ignored -- a body with only unknown fields counts as empty.
Permissions
Writes follow the same rules as the web app (see Roles & Permissions). Your role in the item's team decides; organization Owners, Admins, and Implementers act as team Admins in every team.
| Operation | Allowed for |
|---|---|
| Create, edit, complete, and reopen to-dos (anyone's, not only your own) | Team Members and above |
| Create and edit issues, change their status | Team Members and above |
| Enter weekly metric values | Team Members and above |
| Change a Rock's status; add, edit, complete, and delete its milestones | The Rock's owner (Member or above) and team Admins |
| Create Rocks; change their title, description, owner, quarter, or level | Team Admins and Owners |
| Add, edit, and reorder metrics | Team Admins and Owners |
| Delete to-dos, issues, Rocks, and metrics | Team Admins and Owners |
For example: a Viewer cannot change anything; a Member can complete a colleague's to-do but cannot delete it; the owner of a Rock may set its status to OFF_TRACK and tick off its milestones, but may not rename it, give it to someone else, or move it to another quarter. A refused change returns 403 forbidden.
More rules:
- Owners must be team members. An
ownerIdyou send for a to-do, Rock, or metric must be a member of the item's team (the ids are inGET /teams/{teamId}/members); otherwise the API answers422 invalid. Other edits work even if the current owner has left the team. - One request, one change. A
PATCHwith several fields (for example a new title and a status) is applied completely or not at all. - Small bodies. Request bodies are limited to 64 KB (
413otherwise). - Meetings must belong to the team. The optional
meetingIdof a new to-do or issue must be a meeting of the same team. - Suspended organizations are read only. Every change in a suspended organization returns
409 orgSuspended. - Calendar days (
dueDate, the metricweek) accept onlyYYYY-MM-DD. An instant such as2026-10-14T00:00:00Zor a non-existent day such as2026-02-30is refused with422 invalid. - Text is trimmed. Leading and trailing spaces are removed; a title that is empty after trimming is invalid.
Validation
Invalid input returns 422 invalid and changes nothing. There are two kinds:
- Format errors -- a missing required field, a wrong type (for example a number where a string is expected), an unknown enum value, a malformed day or quarter, an empty
PATCHbody. The response lists each problem inissueswith the field'spath. - Rule violations -- a text over its length limit (see the tables below), an owner who is not a team member, a meeting of another team, a company Rock in a non-leadership team. The response has the code
invalidbut noissues.
To-Dos (tasks)
| Endpoint | Purpose | Response |
|---|---|---|
POST /teams/{teamId}/tasks | Create a to-do | 201 Task |
PATCH /tasks/{id} | Change, complete, or reopen a to-do | 200 Task |
DELETE /tasks/{id} | Delete a to-do | 204 |
Create body:
| Field | Type | Required | Notes |
|---|---|---|---|
title | string | yes | 1--500 characters |
ownerId | string | yes | A member of the team |
dueDate | day | yes | YYYY-MM-DD |
meetingId | string | no | The meeting of the same team the to-do came from |
Update body -- all fields optional, at least one required:
| Field | Type | Notes |
|---|---|---|
title | string | 1--500 characters |
ownerId | string | A member of the team |
dueDate | day | YYYY-MM-DD |
completed | boolean | true completes the to-do and sets completedAt; false reopens it and clears completedAt |
Issues
| Endpoint | Purpose | Response |
|---|---|---|
POST /teams/{teamId}/issues | Create an issue | 201 Issue |
PATCH /issues/{id} | Change an issue or its status | 200 Issue |
DELETE /issues/{id} | Delete an issue | 204 |
Create body:
| Field | Type | Required | Notes |
|---|---|---|---|
title | string | yes | 1--500 characters |
description | string | no | Up to 5,000 characters |
priority | HIGH | MEDIUM | LOW | no | Default MEDIUM |
meetingId | string | no | The meeting of the same team the issue came from |
New issues start with status OPEN; you are recorded as createdById.
Update body -- all fields optional, at least one required:
| Field | Type | Notes |
|---|---|---|
title | string | 1--500 characters |
description | string | Up to 5,000 characters; "" clears it |
priority | HIGH | MEDIUM | LOW | |
status | OPEN | SOLVING | SOLVED | SOLVED sets resolvedAt to now; OPEN and SOLVING clear it |
Rocks (goals) and Milestones
| Endpoint | Purpose | Response |
|---|---|---|
POST /teams/{teamId}/goals | Create a Rock | 201 Goal |
PATCH /goals/{id} | Change a Rock or report its status | 200 Goal |
DELETE /goals/{id} | Delete a Rock with its milestones | 204 |
POST /goals/{id}/milestones | Add a milestone to a Rock | 201 Milestone |
PATCH /milestones/{id} | Change, complete, or reopen a milestone | 200 Milestone |
DELETE /milestones/{id} | Delete a milestone | 204 |
Milestone ids are in the milestones array of each Rock (GET /goals/{id}).
Create body for a Rock:
| Field | Type | Required | Notes |
|---|---|---|---|
title | string | yes | 1--300 characters |
description | string | no | Up to 5,000 characters |
ownerId | string | yes | A member of the team |
quarter | string | yes | YYYY-Qn, e.g. "2026-Q4" (years 2000--2100) |
level | COMPANY | DEPARTMENT | no | Default DEPARTMENT. COMPANY only in a leadership team, otherwise 422 invalid |
New Rocks start with status ON_TRACK and no milestones.
Update body -- all fields optional, at least one required:
| Field | Type | Notes |
|---|---|---|
title | string | 1--300 characters |
description | string | Up to 5,000 characters; "" clears it |
ownerId | string | A member of the team |
quarter | string | YYYY-Qn |
level | COMPANY | DEPARTMENT | COMPANY only in a leadership team |
status | ON_TRACK | OFF_TRACK | DONE | The only field the Rock's owner may change; all other fields need a team Admin |
Milestone bodies:
| Field | Type | Create | Update | Notes |
|---|---|---|---|---|
title | string | required | optional | 1--300 characters |
dueDate | day or null | optional, default null | optional | YYYY-MM-DD; null removes the due date |
completed | boolean | -- | optional | true completes, false reopens. New milestones start not completed |
Scorecard Metrics (metrics)
| Endpoint | Purpose | Response |
|---|---|---|
POST /teams/{teamId}/metrics | Add a metric at the end of the team's Scorecard | 201 Metric |
PATCH /metrics/{id} | Change or reorder a metric | 200 Metric |
DELETE /metrics/{id} | Delete a metric with all its values | 204 |
PUT /metrics/{id}/values/{week} | Set the value of one week | 200 MetricValue |
Create body:
| Field | Type | Required | Notes |
|---|---|---|---|
title | string | yes | 1--200 characters |
goal | string | yes | 1--200 characters. A target with a comparison operator >, >=, <, <=, or =, e.g. ">=10" or "<5"; a bare number such as "10" means at least (>=) |
ownerId | string | yes | A member of the team |
unit | string | no | Up to 20 characters, e.g. "€" or "%" |
description | string | no | Up to 5,000 characters |
Update body -- all fields optional, at least one required:
| Field | Type | Notes |
|---|---|---|
title, goal, ownerId | string | As when creating |
unit, description | string | As when creating; "" clears the field |
direction | "up" | "down" | Moves the metric one place up or down in the Scorecard. At the top or bottom nothing moves |
Weekly value -- PUT /metrics/{id}/values/{week}:
{week}is the Monday that starts the week, asYYYY-MM-DD. Any other day returns422 invalid("Weeks start on Monday").- The request is an upsert: it creates the week's value or replaces it. Repeating it gives the same result.
enteredByIdkeeps the person who entered the value first.
| Field | Type | Required | Notes |
|---|---|---|---|
value | string | yes | Up to 50 characters. Send numbers as strings: "12", not 12 |
onTrack | boolean | no | Whether the week met the goal. If omitted, it is computed from the goal (goal ">=10", value "12" → true; value "8" → false). When the value can't be judged -- text, or an ambiguous number such as "1.234,5" -- onTrack is required (422 otherwise) |
Numbers may use a decimal comma or dot ("3,5" and "3.5" are 3.5), thousands groups ("1.000" and "1,000" are 1000), spaces and a trailing %. A number that mixes both separators ("1.234,5") is not guessed.
Examples
The examples use the variables from Your First Calls plus ids you got from earlier responses.
Create a to-do:
curl -s -X POST "$BASE/teams/$TEAM_ID/tasks" \
-H "Authorization: Bearer $EOSHUB_KEY" \
-H "Content-Type: application/json" \
-d '{"title": "Call the new supplier", "ownerId": "cmg4x…", "dueDate": "2026-10-14"}'{
"id": "cmg7u…",
"teamId": "cmg2b…",
"title": "Call the new supplier",
"ownerId": "cmg4x…",
"dueDate": "2026-10-14",
"completed": false,
"completedAt": null,
"meetingId": null,
"createdAt": "2026-10-07T09:15:31.000Z"
}Complete it:
curl -s -X PATCH "$BASE/tasks/cmg7u…" \
-H "Authorization: Bearer $EOSHUB_KEY" \
-H "Content-Type: application/json" \
-d '{"completed": true}'The response is the to-do with "completed": true and completedAt set. Send {"completed": false} to reopen it.
Enter this week's value of a metric. Weeks are identified by their Monday:
MONDAY=$(date -d "-$(( $(date +%u) - 1 )) days" +%F) # GNU date; on macOS: date -v-$(( $(date +%u) - 1 ))d +%F
curl -s -X PUT "$BASE/metrics/$METRIC_ID/values/$MONDAY" \
-H "Authorization: Bearer $EOSHUB_KEY" \
-H "Content-Type: application/json" \
-d '{"value": "12"}'{ "week": "2026-10-05", "value": "12", "onTrack": true, "enteredById": "cmg4x…" }With the goal ">=10" the value "12" is on track, so onTrack is true. Running the same command again changes nothing; sending another value replaces the week's value.
Mark an issue as solved:
curl -s -X PATCH "$BASE/issues/cmg8i…" \
-H "Authorization: Bearer $EOSHUB_KEY" \
-H "Content-Type: application/json" \
-d '{"status": "SOLVED"}'The response is the issue with "status": "SOLVED" and resolvedAt set. Moving it back to OPEN or SOLVING clears resolvedAt.
Interactive Reference
Open https://<your-host>/api/v1/docs for the interactive API reference. It lists every endpoint with its parameters and full response schema, and you can enter your API key to send real requests from the browser.
The same description is available as an OpenAPI 3.1 document at https://<your-host>/api/v1/openapi.json. Import it into Postman, Insomnia, or a client code generator.
Related Pages
- API Keys -- Creating, revoking, and securing keys
- Roles & Permissions -- What each role can see and do
- Organizations & Teams -- How organizations and teams work