KairoProject Public API
Connect your tools to KairoProject via the public REST API. Authentication, endpoints, webhooks, and best practices for integrators.
The KairoProject public API lets your external tools and systems interact directly with your project management data: portfolios, projects, tasks, resources, logged time, and more. This guide covers authentication, available endpoints, outbound webhooks, and integration best practices.
API version 1
The public API is currently at version v1. The endpoints described in this guide are stable and production-ready. New features are added without breaking changes; any incompatible change will introduce a /v2/ prefix with at least 3 months' notice.
Prefer steering things through a conversational agent?
If you'd rather have Claude, ChatGPT, or another AI assistant drive KairoProject directly instead of integrating this REST API yourself, see Connect your AI agent to KairoProject. The MCP server exposes the same capabilities as structured tools, with identical authentication (same API clients, same scopes) and built-in CCPM guardrails.
Conceptual model
The API strictly follows KairoProject's data hierarchy:
Organisation
├── API Clients ← your integrations (credentials)
├── Members ← org users
├── Webhooks ← event delivery endpoints
└── Portfolio
├── Resource ← shared across all portfolio projects
├── Team ← group of resources, shared
└── Project
├── Root Task ← structural task (cannot be deleted)
├── Task ... ← your operational tasks
└── Finish Task ← structural task (cannot be deleted)
Key points:
- All data is strictly isolated per organisation — a token can never access another organisation's data.
- Resources and teams live at portfolio level, shared across all projects.
- Each project has two structural tasks (
root,finish) that cannot be deleted via the API. - The CCPM recompute (critical chain, buffer) is triggered via
POST /projects/{projectId}/recompute?portfolioId=...and returns a synchronous response.
Public API URL
https://app.kairoproject.com/api/public/v1
All dates are ISO 8601 UTC (2026-05-17T10:00:00.000Z).
Authentication
The API offers two OAuth 2.0 flows depending on your integration context: client_credentials for a server-to-server integration (your ERP holds the secret directly), and authorization_code + PKCE for an agent that needs an end user's explicit consent without ever seeing their password. Each organization can create API clients from the admin console.
Create an API client
An owner or admin creates an API client from the console. On creation, a clientId and a clientSecret are generated — the secret is only shown once.
Each API client holds:
- a name displayed in the console;
- scopes defining the granted permissions;
- a status (
activeorrevoked); - a token lifetime (
tokenTTLSeconds, default 3,600 seconds, never exceeded regardless of configuration).
Organization account or solo account
Creating an API client requires an active organization subscription (active or trialing) in addition to the owner/admin role — a cancelled or never-paid organization gets a 403. On an individual account, it requires the apiAccess entitlement (Solo Pro plan); without it, creation also returns a 403. This check only applies at creation time: managing or revoking an already-created client remains possible even if the subscription has since expired.
Server-to-server authentication (client_credentials)
POST /api/public/oauth/token
Request body:
{
"client_id": "org_acme-api-1234",
"client_secret": "kairo_sk_live_...",
"scope": "projects.read tasks.write"
}
Response:
{
"access_token": "eyJhbGciOiJIUzI1NiJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "projects.read tasks.write"
}
Use the token in all subsequent requests:
Authorization: Bearer <access_token>
Token lifetime
Tokens expire after 3,600 seconds by default. Manage expiry client-side by comparing issued_at + expires_in with the current time. There is no need to regenerate a token before every request.
User-consent authentication (authorization_code + PKCE)
Use this flow when an agent (Claude.ai, ChatGPT) needs to act on behalf of a specific user, with their explicit consent, without ever holding their password.
- Discovery — the agent reads the available endpoints:
curl https://app.kairoproject.com/.well-known/oauth-authorization-server - Generate the PKCE pair (a random
code_verifier, andcode_challenge= SHA-256 encoded as base64url):import { randomBytes, createHash } from "node:crypto"; const codeVerifier = randomBytes(32).toString("base64url"); const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url"); - Open the browser to the consent screen:
https://app.kairoproject.com/api/public/oauth/authorize ?response_type=code &client_id=org_demo-api-1234 &redirect_uri=https://integrator.example.com/callback &code_challenge=<codeChallenge> &code_challenge_method=S256 &scope=portfolios.read+tasks.write &state=<random-anti-csrf-value> - The user signs in, reviews the requested permissions, and clicks Authorize. KairoProject redirects to
redirect_uri?code=<code>&state=<state>(the code expires after 10 minutes). - Exchange the code for a token, using the
code_verifiergenerated in step 2:
Response identical to thecurl -X POST https://app.kairoproject.com/api/public/oauth/token \ -H "Content-Type: application/json" \ -d '{ "grant_type": "authorization_code", "client_id": "org_demo-api-1234", "client_secret": "kairo_sk_...", "code": "<code>", "redirect_uri": "https://integrator.example.com/callback", "code_verifier": "<codeVerifier>" }'client_credentialsflow, plus arefresh_tokenif theoffline_accessscope was requested.
Fixed redirect URI for Claude.ai
Redirect URIs are fixed for Claude.ai (https://claude.ai/api/mcp/auth_callback). Claude Code uses a direct token (client_credentials), not this flow.
Available scopes
| Scope | Access granted |
|---|---|
portfolios.read | Read portfolios |
portfolios.write | Create and update portfolios |
projects.read | Read projects |
projects.write | Create, update, and delete projects |
tasks.read | Read tasks |
tasks.write | Create, update, and delete tasks |
resources.read | Read resources and teams |
resources.write | Create, update, and delete resources and teams |
planning.trigger | Trigger planning recompute |
timesheet.read | Read logged time entries |
timesheet.write | Create, correct, and delete logged time entries |
bufferConsumptionEvents.read | Read the qualified delay-cause log |
bufferConsumptionEvents.write | Qualify delay causes and manage categories |
domains.read | Read domains and phases (task categorization reference data) |
domains.write | Create, update, and delete domains and phases |
clients.read | Read clients (billable third parties) |
clients.write | Create clients and attach/detach them from a project |
organization.read | Read organisation plan/seat information |
members.manage | Read, invite, and update members |
webhooks.manage | Manage outbound webhooks |
Rotation and revocation
- Rotation: create a new secret from the console. The new secret becomes active immediately and the previous secret is disabled during rotation.
- Revocation: revoke the client from the organisation console (API section). Access is cut immediately: every request re-checks the client's status, including for a token issued before revocation. There is no delay to wait out — a compromised token stops working as soon as the client is revoked, without waiting for its natural expiry. After a reactivation, tokens issued before the revocation remain rejected; only those issued after the reactivation are valid.
Rate limiting
Each authenticated request is counted in a sliding window per API client. Limits vary based on the most restrictive scope in the request.
| Scope | Requests / minute |
|---|---|
tasks.read | 120 |
tasks.write | 60 |
projects.read | 60 |
projects.write | 30 |
planning.trigger | 20 |
webhooks.manage | 30 |
| Others | 60 |
Every successful response includes the following headers:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 47
X-RateLimit-Reset: 1747476120
When the limit is exceeded, the API returns 429 with a uniform JSON body in the form { "error": "..." }. Depending on the endpoint, retry timing information may also be exposed in headers.
Available endpoints
All endpoints are prefixed with /api/public/v1 and require a valid Bearer token. portfolioId is always required as a query param for portfolio sub-resources.
Legend: ✅ Idempotent (repeating the request produces the same result) — ❌ Creates a duplicate
Portfolios
| Method | Route | Required scope | Idempotent |
|---|---|---|---|
GET | /portfolios | portfolios.read | ✅ |
POST | /portfolios | portfolios.write | ❌ |
GET | /portfolios/:id | portfolios.read | ✅ |
PATCH | /portfolios/:id | portfolios.write | ✅ |
Patchable fields: name, description, aiAssistEnabled, domainId (must reference an existing domain from the Domains & Phases library, see below — 404 otherwise).
| Method | Route | Required scope | Idempotent |
|---|---|---|---|
GET | /portfolios/:id/decisions | portfolios.read + projects.read + tasks.read + resources.read | ✅ |
Portfolio CCPM priority decision and strategic resource assessment (the constraint, in Theory of Constraints terms) — computed on demand on every call, never cached. Indicates which project to protect first and which resource, if given more capacity, would improve the whole portfolio the most. Only covers released: true projects.
Projects
| Method | Route | Required scope | Idempotent |
|---|---|---|---|
GET | /projects?portfolioId= | projects.read | ✅ |
POST | /projects | projects.write | ❌ |
GET | /projects/:id?portfolioId= | projects.read | ✅ |
PATCH | /projects/:id?portfolioId= | projects.write | ✅ |
DELETE | /projects/:id?portfolioId= | projects.write | ✅ |
POST | /projects/:id/recompute?portfolioId= | planning.trigger | ✅ |
Irreversible deletion
Deleting a project removes the project and all its tasks. This operation cannot be undone. The progress and bufferConsumption fields are computed — they are read-only.
Tasks
| Method | Route | Required scope | Idempotent |
|---|---|---|---|
GET | /projects/:id/tasks?portfolioId= | tasks.read | ✅ |
POST | /projects/:id/tasks | tasks.write | ❌ |
GET | /projects/:id/tasks/:taskId?portfolioId= | tasks.read | ✅ |
PATCH | /projects/:id/tasks/:taskId?portfolioId= | tasks.write | ✅ |
DELETE | /projects/:id/tasks/:taskId?portfolioId= | tasks.write | ✅ |
Updating ettcHours (Estimated Time to Complete — remaining hours) is the progress-reporting mechanism. The root and finish tasks cannot be deleted (returns 422).
Resources and teams
| Method | Route | Required scope | Idempotent |
|---|---|---|---|
GET / POST / PATCH / DELETE | /projects/:id/resources?portfolioId= | resources.read / resources.write | ✅ / ❌ |
GET | /portfolios/:id/resources/:resId/load-timeline | resources.read | ✅ |
GET / POST / PATCH / DELETE | /projects/:id/teams?portfolioId= | resources.read / resources.write | ✅ / ❌ |
Resources and teams are stored at portfolio level (an equivalent /portfolios/:id/resources[...] and /portfolios/:id/teams[...] path exists and operates on the same collection, without triggering a recompute). The projectId in the URL is used solely to trigger a planning recompute for the affected project after a mutation.
load-timeline returns a resource's weekly load curve (planned hours vs. capacity) — indicative only: it doesn't distinguish tasks co-assigned to multiple resources, nor team-based assignments.
Domains & Phases
Shared reference data (independent of any specific portfolio) used to categorize tasks by phase and color the Gantt chart.
| Method | Route | Required scope | Idempotent |
|---|---|---|---|
GET / POST | /domains | domains.read / domains.write | ✅ / ❌ |
GET / PATCH / DELETE | /domains/:id | domains.read / domains.write | ✅ |
POST | /domains/:id/phases | domains.write | ❌ |
GET / PATCH / DELETE | /domains/:id/phases/:phaseId | domains.read / domains.write | ✅ |
PATCH /domains/:id replaces name and/or the entire phases array — not a merge. To add/update/remove a single phase without resubmitting the others, prefer the dedicated .../phases[/:phaseId] endpoints. DELETE /domains/:id is refused (400) if a portfolio still references this domain. A phase's color must belong to the allowed palette (18 predefined colors).
Clients
A client is a billable third party (account-level, never nested under a portfolio) — typically the name/ERP code of a project's end client.
| Method | Route | Required scope | Idempotent |
|---|---|---|---|
GET | /clients | clients.read | ✅ |
GET | /projects/:id/client?portfolioId= | clients.read | ✅ |
PATCH | /projects/:id/client?portfolioId= | clients.write | ✅ (by clientId) |
DELETE | /projects/:id/client?portfolioId= | clients.write | ✅ |
PATCH always overwrites any existing attachment (the ERP is the source of truth). If clientId doesn't match any existing client, a new one is automatically created rather than rejected.
Project import
| Method | Route | Required scope | Idempotent |
|---|---|---|---|
POST | /projects/import | projects.write | ✅ (by externalId) |
Creates a complete project (tasks, resources, teams, calendars) in a single call from a kairo-project-export object — for migrating from a third-party tool (Jira, MS Project, Asana, Excel) or generating a project from a spec document. Re-importing with the same project externalId updates the same resource instead of creating a duplicate.
Unvalidated references on import
tasks[].phaseId, tasks[].pertDefinitionId, and project.clientId are accepted as-is by this endpoint, without validation or creation — only use identifiers that already exist in the target account/portfolio (obtained via GET /domains, GET /pert-definitions, GET /clients).
Timesheet
| Method | Route | Required scope | Idempotent |
|---|---|---|---|
GET / POST | /portfolios/:id/timeEntries | timesheet.read / timesheet.write | ✅ / ❌ |
PATCH / DELETE | /portfolios/:id/timeEntries/:entryId | timesheet.write | ✅ |
POST | /portfolios/:id/timeEntries/:entryId/adjustments | timesheet.write | ❌ |
Log of hours worked, for reporting and cost calculation. Two origins: source: "auto" (auto-generated whenever PATCH .../tasks/:taskId changes ettcHours) and source: "manual". Creating or correcting an entry does not modify ettcHours on the associated task and doesn't trigger any planning recompute.
.../adjustments corrects an entry that's already exported (locked) without ever rewriting the original — an accounting credit-note pattern. Fails with 400 if the referenced entry isn't exported yet (use a normal PATCH instead). durationHours is signed (negative to subtract, positive to add).
Qualified delay causes (buffer)
| Method | Route | Required scope | Idempotent |
|---|---|---|---|
GET | /portfolios/:id/bufferConsumptionEvents | bufferConsumptionEvents.read | ✅ |
PATCH | /portfolios/:id/bufferConsumptionEvents/:eventId | bufferConsumptionEvents.write | ✅ |
GET / POST | /portfolios/:id/issueCategories | bufferConsumptionEvents.read / bufferConsumptionEvents.write | ✅ / ❌ |
GET / PATCH / DELETE | /portfolios/:id/issueCategories/:categoryId | bufferConsumptionEvents.read / bufferConsumptionEvents.write | ✅ |
Log of qualified delay causes — each event corresponds to a CCPM buffer consumption attributed to a specific cause (source data for the delay-cause Pareto chart). categoryId: null declassifies the event as "Uncategorized." Renaming or deleting a category never affects categoryNameSnapshot on already-qualified events — the Pareto history stays stable over time.
Cost
| Method | Route | Required scope | Idempotent |
|---|---|---|---|
GET | /portfolios/:id/resources/:resId/cost | timesheet.read | ✅ |
GET | /projects/:id/cost?portfolioId= | timesheet.read | ✅ |
Computed from timesheet entries (durationHours × hourlyRate) — never persisted, recomputed on every call. If no resource has a hourlyRate set, cost fields are null rather than 0, to distinguish "no rate configured" from "zero cost."
PERT presets and delay risk (ML)
| Method | Route | Required scope | Idempotent |
|---|---|---|---|
GET | /pert-definitions | projects.read | ✅ |
GET | /projects/:id/risk?portfolioId= | projects.read | ✅ |
PERT presets (optimistic/median/pessimistic speeds) convert a quantity of work into hours before an import or task creation. /risk returns a delay probability predicted by an ML model trained on the organization's history (horizonDays, default 7, bounded between 7 and 21); source: "fallback" means no trained model is available for this scope.
Organization and members
| Method | Route | Required scope | Idempotent |
|---|---|---|---|
GET | /organization | organization.read | ✅ |
GET | /members | members.manage | ✅ |
POST | /members | members.manage | ❌ |
GET | /members/:id | members.manage | ✅ |
PATCH | /members/:id | members.manage | ✅ |
POST /members supports two modes: with uid (adds an existing user, returns 200) or with email without uid (creates an invitation, returns 201).
Pagination
Lists use a cursor-based pagination system.
pageSize: number of items per page (default 25, max 100).pageToken: cursor returned asnextPageTokenby the previous response.
async function fetchAllProjects(token: string, portfolioId: string) {
const projects = [];
let pageToken: string | null = null;
do {
const url = new URL("https://app.kairoproject.com/api/public/v1/projects");
url.searchParams.set("portfolioId", portfolioId);
url.searchParams.set("pageSize", "100");
if (pageToken) url.searchParams.set("pageToken", pageToken);
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
const data = await res.json();
projects.push(...data.items);
pageToken = data.nextPageToken;
} while (pageToken);
return projects;
}
Outbound webhooks
KairoProject can notify your systems in real time via HTTP webhooks.
Configure a webhook
POST /api/public/v1/webhooks
Required scope: webhooks.manage.
Each webhook associates:
- an HTTPS destination URL;
- a list of events to subscribe to;
- an automatically generated signing secret.
Available events
| Event | Trigger |
|---|---|
project.created | Project creation |
project.updated | Project update (includes progress, bufferConsumption) |
project.deleted | Project deletion |
task.created | Task creation |
task.updated | Task update |
task.deleted | Task deletion |
planning.recompute.completed | Planning recompute finished — includes bufferConsumption, progress, status |
planning.buffer_overflow | bufferConsumption >= 1.0 — includes threshold and exceededAt |
Verify the signature
Each delivery includes the header:
X-Kairo-Signature: t=<unix>,v1=<digest>
The digest is computed as: HMAC-SHA256(secret, "${t}.${body}").
import { createHmac, timingSafeEqual } from "node:crypto";
function verifyKairoSignature(
signingSecret: string,
signatureHeader: string,
rawBody: string
): boolean {
const match = signatureHeader.match(/t=([^,]+),v1=([a-f0-9]+)/);
if (!match) return false;
const [, timestamp, signature] = match;
const payload = `${timestamp}.${rawBody}`;
const expected = createHmac("sha256", signingSecret).update(payload).digest("hex");
return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(signature, "hex"));
}
Discard deliveries with a timestamp older than 5 minutes to protect against replay attacks.
Response time and retries
Your endpoint must respond within 10 seconds with a 2xx code. On failure, the platform automatically retries up to 5 times: +2 min, +8 min, +30 min, +2h. After that, the event is permanently marked failed. Use event.id to deduplicate multiple deliveries.
Rotate the webhook secret
Use POST /api/public/v1/webhooks/{id}/rotate-secret to renew the signing secret for a webhook. The old value is revoked as soon as rotation occurs.
OpenAPI and SDK generation
The API's full technical contract is available as an OpenAPI 3.1 spec. Two main uses:
- Publish the spec to your teams or integration partners.
- Generate a typed SDK instead of writing HTTP calls by hand:
The generated file exposes types (npx openapi-typescript docs/api/public-api-openapi.yaml --output sdk/public-api.tsPortfolio,Resource,Member, etc.) and can be published as an internal package.
For other languages, openapi-generator-cli can start from the same spec:
openapi-generator-cli generate -i docs/api/public-api-openapi.yaml -g go
Error codes
All errors return a uniform JSON body: { "error": "Explicit description of the problem." }
| Code | Common reason |
|---|---|
400 | Validation failed — missing field, invalid format, missing portfolioId |
401 | Token missing, invalid, expired, or client revoked |
403 | Valid token but insufficient scope |
404 | Resource not found in your organisation |
422 | Semantically impossible operation (e.g. deleting a root task) |
429 | Rate limit exceeded |
500 | Unexpected server error — retry after a few seconds |
2 minutes · personalized result
Newsletter
Want to go further?
Get regular, actionable takes on multi-project prioritization, the Critical Chain, shared resource constraints, and steering practices.
Read next
Connect Your AI Agent to KairoProject
Give Claude, ChatGPT, or any AI assistant direct access to your KairoProject data. Set up the connection in minutes, no coding required.
CCPM: The Method That Explains Why Your Projects Slip (And How to Fix It)
What is CCPM (Critical Chain Project Management)? A complete guide to the Critical Chain method: principles, mechanisms, buffers, constrained resources, and multi-project steering for SMEs and engineering firms.
Project Portfolio Management: Methods, Tools and Mistakes to Avoid
What is a project portfolio, how to manage it effectively, handle shared resources, and avoid the classic mistakes that derail teams running multiple projects simultaneously.