Skip to content

jcolombo/ninety-api-php

Repository files navigation

ninety-api-php

A PHP 8.1+ SDK / API client for the Ninety.io Public API (v1).

Ninety.io is a platform for running the Entrepreneurial Operating System (EOS): Rocks, Level-10 (L10) meetings, Scorecards / Measurables, Issues (IDS), To-Dos, Milestones, Teams, and the Accountability Chart. This library wraps the full public API surface — every endpoint published in the official Swagger reference — behind a typed entity layer, a fluent query builder, client-side rate limiting, optional caching, and raw escape hatches for anything the API adds later.

  • Base URL: https://api.public.ninety.io/v1/
  • Auth: Personal Access Token (Bearer)
  • Coverage: all 25 operations / 18 paths / 7 domains (see API coverage)

Table of contents

Requirements

  • PHP 8.1+
  • ext-json
  • Guzzle ^7.8 (installed via Composer)
  • A Ninety Personal Access Token (see Authentication)

Installation

composer require jcolombo/ninety-api-php

Authentication

The Ninety Public API authenticates with a Personal Access Token sent as a Bearer token:

Authorization: Bearer <YOUR_PERSONAL_ACCESS_TOKEN>

Generate a token in Ninety under Settings → Developer Settings (https://app.ninety.io/settings/user/developer-settings).

Facts worth knowing about Ninety tokens and access (per Ninety's official docs):

  • Plan gating: the public API is available on the Thrive subscription plan only — Essentials, Accelerate, and Free plans have no API access.
  • Token expiry: tokens expire after 30, 90, 180, or 365 days (default 90). Ninety recommends rotating roughly every 90 days.
  • Shown once: the token value is displayed in full only at generation and cannot be recovered — losing it means generating a new one. A user may hold multiple active tokens.
  • Who can generate: Owners, Admins, Coaches, Managers, and Team Members; Observers cannot.
  • Verify access: GET /teams ($ninety->teams()->fetch()) returns 200 with the teams your token can see — Ninety's own suggested smoke test, and what tests/validate uses.

Never commit your token. Load it from an environment variable or an untracked config file. The provided .gitignore already excludes .env and ninetyapi.config.json.

Quick start

use Jcolombo\NinetyApiPhp\Ninety;

$ninety = Ninety::connect(getenv('NINETY_ACCESS_TOKEN'));

// List the teams the token can see (the API's only Teams endpoint)
foreach ($ninety->teams()->fetch() as $team) {
    echo "{$team->id}: {$team->name}\n";
}

// Query Issues for one team
$issues = $ninety->issues()
    ->where('teamId', $teamId)
    ->search('server')
    ->sort('createdDate', 'DESC')
    ->limit(0, 25)          // pageIndex 0, pageSize 25
    ->fetch();

echo $issues->totalCount() . " matching issues\n";

// Create a To-Do
use Jcolombo\NinetyApiPhp\Entity\Resource\Todo;

$todo = Todo::new($ninety)
    ->set('title', 'Follow up with vendor')
    ->set('dueDate', '2026-08-01')
    ->create();

echo "Created To-Do {$todo->id()}\n";

Ninety::disconnect();

Ninety::connect() is a singleton per token + base URL — calling it again with the same token (and same URL) returns the same connection (and its warmed-up Guzzle client / rate-limit state). The same token pointed at a different base URL yields a separate connection; disconnect($token) drops all of a token's connections.

Entity layer

Resources

One class per API object under Jcolombo\NinetyApiPhp\Entity\Resource\: Issue, Todo, Rock, Milestone, Kpi, Team, User, Score, Note.

use Jcolombo\NinetyApiPhp\Entity\Resource\Issue;
use Jcolombo\NinetyApiPhp\Enum\IntervalCode;

// Fetch by ID
$issue = Issue::new($ninety)->fetch($issueId);
echo $issue->title;

// Create — set() accepts enums or raw strings
$issue = Issue::new($ninety)
    ->set('title', 'Rework the onboarding flow')
    ->set('teamId', $teamId)
    ->set('interval', IntervalCode::LONG_TERM)
    ->create();

// Update — PATCHes only the dirty fields
$issue->set('completed', true)->update();

// Delete
$issue->delete();   // true on success (Ninety returns 204 for most deletes)

Properties are accessed via get()/set() or magic $issue->title. Fields the SDK does not declare are still kept — read them via getUnlisted(). Dirty tracking (isDirty(), getDirty()) makes update() send only what changed.

Operations the API does not expose are guarded: calling them dispatches a FATAL error through the configured handlers and never hits the network (e.g. Milestone::delete(), any Team/Kpi CRUD verb, User::create()).

Collections & the query builder

Ninety reads lists via POST …/query endpoints with a JSON query DTO (except GET /teams). Collections wrap that behind one fluent API:

$rocks = $ninety->rocks()
    ->where('statusCode', RockStatusCode::ON_TRACK)
    ->where('archived', false)
    ->sort(RockSortField::DUE_DATE, SortDirection::ASC)
    ->limit(0, 50)
    ->fetch();      // ONE page — never auto-paginates

foreach ($rocks as $rock) { /* … */ }
$byTeam = $rocks->groupedByTeam();   // Rocks only: teamId → Rock[]
  • where(field, value) — flat query-DTO filter fields (Ninety DTOs have no operators). Enums, DateTimeInterface, and arrays of either are converted.
  • search(text) — free-text searchText.
  • sort(field, direction) — accepts enums or strings; the per-domain wire format (sortField/sortDirection vs To-Dos' lowercase sort/order) is handled for you.
  • limit(page, pageSize) — page in the domain's own style: 0-based pageIndex (Issues, KPIs, Rocks) or 1-based page (To-Dos).
  • fetch() — one page. fetchAll() — auto-paginates until exhausted (use deliberately; it spends real rate-limit budget).

Collections implement Iterator, ArrayAccess (keyed by resource ID), Countable, and JsonSerializable, plus helpers: first(), raw(), flatten($prop), totalCount(), groupedByTeam().

Enums

All Ninety enumerations ship as PHP 8.1 backed string enums in Jcolombo\NinetyApiPhp\Enum\: IntervalCode, SortDirection, RockSortField, RockStatusCode, RockLevelCode, RockQuarter, RockFutureScope, PeriodInterval, KpiSortField, KpiUnit, KpiCurrency, KpiType. Every public API accepts either the enum or its raw string value; hydrated properties come back as enum instances.

API coverage

All 25 operations in the official Swagger spec are implemented:

# Endpoint SDK call
1 POST /issues/query $ninety->issues()->…->fetch()
2 POST /issues (201) Issue::new($n)->set(…)->create()
3 GET /issues/{issueId} Issue::new($n)->fetch($id)
4 PATCH /issues/{issueId} $issue->set(…)->update()
5 DELETE /issues/{issueId} (204) $issue->delete()
6 POST /todos/query (bare array) $ninety->todos()->…->fetch()
7 POST /todos (201) Todo::new($n)->set(…)->create()
8 GET /todos/{id} Todo::new($n)->fetch($id)
9 PATCH /todos/{id} $todo->set(…)->update()
10 DELETE /todos/{id} (204) $todo->delete()
11 GET /teams $ninety->teams()->fetch()
12 POST /scorecard/kpis/query $ninety->kpis()->…->fetch()
13 POST /scorecard/kpis/{kpiId}/scores (upsert) $kpi->putScore($value, $periodStartDate)
14 DELETE /scorecard/kpis/{kpiId}/scores/{periodStartDate} (204) $kpi->deleteScore($periodStartDate)
15 POST /scorecard/kpis/{kpiId}/notes (upsert) $kpi->putNote($note, $periodStartDate)
16 DELETE /scorecard/kpis/{kpiId}/notes/{periodStartDate} (204) $kpi->deleteNote($periodStartDate)
17 POST /rocks/query (teamId-keyed map) $ninety->rocks()->…->fetch()
18 POST /rocks (201 → array) Rock::new($n)->set(…)->create()
19 GET /rocks/{id} Rock::new($n)->fetch($id)
20 PATCH /rocks/{id} $rock->set(…)->update()
21 DELETE /rocks/{id} (200 → deleted Rock) $rock->delete()
22 POST /milestones (201) Milestone::new($n)->set(…)->create()
23 GET /milestones/{id} Milestone::new($n)->fetch($id)
24 PATCH /milestones/{id} $milestone->set(…)->update()
25 GET /users/{id} $ninety->users()->fetch($id)

Domain notes & API quirks

The Ninety API is not uniform across domains. The SDK normalizes these — but they matter when reading responses or the wire traffic:

  • Three query response shapes. Issues/KPIs return a paginated envelope ({items[], totalCount, …}); To-Dos and Teams return a bare JSON array; Rocks return an object keyed by teamId → Rock arrays. Collections unwrap all three; Rocks additionally preserve the grouping via groupedByTeam().
  • Two identifier fields. Issues, To-Dos, Teams, Users, Scores, and Notes use id; Rocks, Milestones, and KPIs use _id (Mongo ObjectId strings). id() returns the right one either way.
  • Issue interval vs intervalCode. Create/update DTOs send interval; responses (and the query filter) call it intervalCode. The SDK stores the canonical intervalCode, accepts interval as an alias, and renames it on the wire automatically.
  • To-Dos paginate differently. 1-based page (not pageIndex) and lowercase sort/order (not sortField/sortDirection). limit()/sort() map this for you. pageSize max is 100.
  • Rocks query requires sort/page fields. The DTO mandates sortField, sortDirection, pageSize, pageIndex — the SDK supplies the API defaults (dueDate/DESC, page 0, size 10) so you can just call fetch(). pageSize max is 200.
  • Rock create is wrapped: {rock: {...}, addCreatorToFollowersList} — use Rock::addCreatorToFollowers() for the flag. The 201 response is an array of Rocks; the SDK hydrates from its first element and keeps the full batch accessible via $rock->createdRocks().
  • Rock delete returns the entity. DELETE /rocks/{id} responds 200 with the soft-deleted Rock (not 204) — after $rock->delete() the object re-hydrates with deleted = true.
  • Scorecard Scores/Notes are per-period upserts keyed by periodStartDate. POSTing an existing period overwrites it, so there is no separate update call: putScore() / putNote() create or replace. DateTimeInterface period values are normalized to millisecond-precision UTC ISO 8601 (2026-01-01T00:00:00.000Z).
  • Milestones live inside Rocks. Rock responses embed milestones — read them as typed entities via $rock->milestones().

Known API limitations

These are Ninety API limitations, not SDK gaps. The SDK guards them with clear errors instead of faking endpoints that do not exist:

  • Milestones: no delete, no query/list. Find them through their parent Rock.
  • Teams: list only (GET /teams, {id, name}) — no get/create/update/delete.
  • Users: single GET /users/{id} only — no list/directory, no CRUD.
  • KPIs (Measurables): query + score/note upserts only — the KPI itself cannot be created, fetched singly, updated, or deleted via the public API.
  • Scorecard values: no read endpoint for Scores/Notes — write and delete only (values are visible in the app, not retrievable via the API).
  • No webhooks / push notifications — changes must be polled.
  • No bulk-write operations — one record per create/update/delete call.
  • Plan-gated: API access requires the Thrive plan (see Authentication); other plans receive no access.

Raw escape hatches

Every typed call ultimately flows through the same pipeline you can use directly — handy for future endpoints before typed support lands:

$teams = $ninety->get('teams');                          // decoded array
$issue = $ninety->post('issues', ['title' => 'X', 'teamId' => $tid]);
$ninety->patch("todos/{$id}", ['completed' => true]);
$ninety->delete("issues/{$id}");

// Full response object (status, headers, timing, cache origin):
$response = $ninety->request('POST', 'rocks/query', ['pageSize' => 10, /* … */]);
$response->success;       // bool
$response->responseCode;  // int
$response->body;          // ?array

// Or the static builder:
use Jcolombo\NinetyApiPhp\Request;
$response = Request::custom($ninety, 'POST', 'scorecard/kpis/query', ['pageIndex' => 0]);

POST payloads sent to a …/query path are automatically treated as read-via-POST query DTOs (cacheable, never invalidate); all other POST/PATCH/ DELETE calls count as mutations. Raw calls still get rate limiting, 429 retry, caching, logging, and error mapping.

Configuration

Defaults load from default.ninetyapi.config.json. Override any subset:

use Jcolombo\NinetyApiPhp\Configuration;

Configuration::set('enabled.cache', true);          // single key at runtime
Configuration::load('/path/to/overrides.json');     // merge a JSON file
Configuration::overload('/my/app/config');          // dir → ninetyapi.config.json inside it (missing = ignored)
Key Default Purpose
connection.url https://api.public.ninety.io/v1/ API base URL
connection.timeout 30 Request timeout (seconds)
connection.verify true TLS certificate verification
path.cache / path.logs null Directories for cache / log files (a path.logs value ending in .log/.txt is used as the full file path instead)
enabled.cache false Response caching on/off
enabled.logging false File logging on/off
cache.lifespan 300 Cache TTL (seconds)
cache.queryPosts false Also cache POST …/query responses
rateLimit.enabled true Client-side rate limiting
rateLimit.minDelayMs 50 Minimum delay between requests (~20 req/s, under Ninety's 25 req/s limit)
rateLimit.perMinute / rateLimit.perHour 1200 / 30000 Sliding-window budgets
rateLimit.safetyBuffer 1 Requests held back before a window cap
rateLimit.maxRetries 3 Max automatic retries after a 429
rateLimit.retryDelayMs 2000 Base backoff between 429 retries
rateLimit.maxRetryAfterSeconds 120 Cap on honored Retry-After waits (0 = uncapped)
log.connections / log.requests false / true What gets logged when logging is enabled
devMode false Extra SDK self-checks (constant validation, strict classMap overloads)
error.enabled true Error dispatching on/off
error.handlers notice/warn→log, fatal→log+echo Handlers per severity (log, echo)
error.logFilename ninety.log Log filename written inside the path.logs directory
error.triggerPhpErrors false Also raise native PHP errors per severity
testing.token null Token for the live smoke runner (tests/validate)
classMap.* built-ins Entity key → resource/collection FQCN mapping

Rate limiting

Ninety documents a limit of 25 requests per second per user (Thrive plan), enforced with a per-second window; exceeding it returns 429 Too Many Requests, and Ninety asks clients to back off at least one second before retrying. The SDK ships a fully configurable client-side limiter (per connection) whose defaults stay under that ceiling: minDelayMs 50 caps bursts at ~20 req/s, with conservative per-minute (1200) and per-hour (30000) sliding windows on top. It is additionally 429-awareRetry-After headers (seconds or HTTP-date) are honored ahead of exponential backoff (capped at rateLimit.maxRetryAfterSeconds so a hostile header can't block the process), and 429 responses retry automatically up to rateLimit.maxRetries with a base backoff of retryDelayMs 2000ms — always beyond the documented one-second minimum. A 429 means the request was rejected before processing, so mutations are retried too. Tune or disable via the rateLimit config block.

Caching

Off by default. When enabled.cache is true (and path.cache points at a writable directory), successful GET responses are cached for cache.lifespan seconds. Cache keys are scoped by a hash of the connection token; mutations (POST/PATCH/DELETE) invalidate every cached entry for their own token's scope — other connections' caches are untouched. POST …/query reads are not cached unless you opt in with cache.queryPosts — they are reads in POST clothing, so the flag exists, but stale query results are usually worse than the extra request.

Errors & logging

The SDK never throws on API failures — failed calls return unsuccessful RequestResponse objects (or false/null from entity methods) and dispatch through the configured handlers per severity (notice / warn / fatallog and/or echo, via error.handlers). Set error.triggerPhpErrors to also raise native PHP errors (E_USER_NOTICE / E_USER_WARNING / E_USER_ERROR) that a host app's error handling can intercept. API error bodies (NestJS {statusCode, message, error} shapes, incl. validation-message arrays) are parsed into readable messages. Connection construction with an empty token is the one hard RuntimeException. Enable file logging with enabled.logging + path.logs.

Extending the SDK (classMap)

Every entity class is resolved through the classMap config, so a host app can substitute its own subclasses without forking:

use Jcolombo\NinetyApiPhp\Entity\EntityMap;

class MyIssue extends \Jcolombo\NinetyApiPhp\Entity\Resource\Issue { /* … */ }

EntityMap::overload('issue', MyIssue::class);            // runtime
// …or statically in your config overlay under classMap.entity.issue.resource

Collections hydrate through the map too — $ninety->issues()->fetch() now yields MyIssue instances. In devMode, overloads are validated against the matching abstract base.

Examples

Runnable scripts in examples/ (set NINETY_ACCESS_TOKEN first):

  1. 01-connection.php — connect, list teams, raw escape hatches
  2. 02-crud.php — full To-Do + Issue lifecycle (create → fetch → update → delete)
  3. 03-query-filtering.php — fluent queries, the three response shapes, paging
  4. 04-rocks-milestones.php — Rocks (wrapper create, team grouping) + Milestones
  5. 05-scorecard.php — KPI queries, score & note upserts

Development & testing

composer install
composer test          # PHPUnit — fully offline (Guzzle MockHandler), no live calls

An optional live smoke runner exercises read-only endpoints against the real API. It is gated on a token and skips cleanly when none is configured:

export NINETY_ACCESS_TOKEN="your-token"   # or set testing.token in ./ninetyapi.config.json
./tests/validate                          # add --verbose for request detail

Design decisions and deviations from the sibling SDK pattern are documented in OVERRIDES.md; release history in CHANGELOG.md.

Contributing

Issues and pull requests are welcome at github.com/jcolombo/ninety-api-php.

License

MIT © Joel Colombo


This is an unofficial, community-maintained client and is not affiliated with or endorsed by Ninety.io. "Ninety", "EOS", "Rock", "Level 10", "IDS", "V/TO", and related marks belong to their respective owners.

About

An API SDK wrapper for Ninety.IO

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages