jcolombo / ninety-api-php
PHP SDK / API client for the Ninety.io (EOS) public API
Requires
- php: >=8.1
- ext-json: *
- adbario/php-dot-notation: ^3.3
- guzzlehttp/guzzle: ^7.8
Requires (Dev)
- phpunit/phpunit: ^10.5
This package is auto-updated.
Last update: 2026-07-25 03:36:10 UTC
README
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
- Installation
- Authentication
- Quick start
- Entity layer
- API coverage
- Domain notes & API quirks
- Known API limitations
- Raw escape hatches
- Configuration
- Rate limiting
- Caching
- Errors & logging
- Extending the SDK (classMap)
- Examples
- Development & testing
- License
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()) returns200with the teams your token can see — Ninety's own suggested smoke test, and whattests/validateuses.
Never commit your token. Load it from an environment variable or an untracked config file. The provided
.gitignorealready excludes.envandninetyapi.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-textsearchText.sort(field, direction)— accepts enums or strings; the per-domain wire format (sortField/sortDirectionvs To-Dos' lowercasesort/order) is handled for you.limit(page, pageSize)— page in the domain's own style: 0-basedpageIndex(Issues, KPIs, Rocks) or 1-basedpage(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 viagroupedByTeam(). - 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
intervalvsintervalCode. Create/update DTOs sendinterval; responses (and the query filter) call itintervalCode. The SDK stores the canonicalintervalCode, acceptsintervalas an alias, and renames it on the wire automatically. - To-Dos paginate differently. 1-based
page(notpageIndex) and lowercasesort/order(notsortField/sortDirection).limit()/sort()map this for you.pageSizemax 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 callfetch().pageSizemax is 200. - Rock create is wrapped:
{rock: {...}, addCreatorToFollowersList}— useRock::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 withdeleted = 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.DateTimeInterfaceperiod 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-aware —
Retry-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 / fatal →
log 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):
01-connection.php— connect, list teams, raw escape hatches02-crud.php— full To-Do + Issue lifecycle (create → fetch → update → delete)03-query-filtering.php— fluent queries, the three response shapes, paging04-rocks-milestones.php— Rocks (wrapper create, team grouping) + Milestones05-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.