Projectri API
A REST API over your projects, tasks, time, expenses and clients. Version 1.0.0. The machine-readable spec is at /api/v1/openapi.json — point a client generator at it rather than writing one by hand.
curl https://projectri.com/api/v1/projects \ -H "Authorization: Bearer pri_xxxxxxxxxxxx" \ -H "Accept: application/json"
Authentication
Create a token in Settings → Integrations → API tokens, then send it as a bearer credential. The plaintext is shown once, at creation; we store only a hash and cannot recover it for you.
A token acts as the member who created it.Its effective access is the intersection of its own scopes and that member’s current permissions, re-resolved on every request. Demote or suspend the owner and every token they minted loses the same access in the same instant — nobody has to remember to revoke anything.
The token goes in the Authorization header and
nowhere else. There is no query-parameter form, deliberately: a secret in a URL ends up
in access logs, browser history and Referer headers.
Scopes
Scopes are permission keys. There is no second authorisation vocabulary to
learn — project.view means here exactly what it
means in the product. A token minted with no scopes authenticates and can do nothing.
| Resource | Read scope | Write scope |
|---|---|---|
| projects | project.view | project.update |
| tasks | task.view | task.update |
| time-entries | time.view | time.log |
| expenses | finance.expense.view | finance.expense.manage |
| clients | client.view | client.manage |
| milestones | project.view | project.plan |
| comments | task.view | task.comment |
| users | user.view | — read-only in v1 |
Pagination
Every list endpoint is cursor-paginated. Follow nextCursor until it comes back null. Default page size is 50, maximum 200; asking for more clamps rather than errors.
let cursor = null
do {
const url = new URL("https://projectri.com/api/v1/tasks")
url.searchParams.set("limit", "200")
if (cursor) url.searchParams.set("cursor", cursor)
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } })
const page = await res.json()
for (const task of page.data) { /* ... */ }
cursor = page.nextCursor
} while (cursor)Cursors are keyset, not offset, and that is a correctness property rather than a performance one. With offset paging, a row inserted between your first and second page shifts everything down and your client silently skips a record — over a nightly sync, a record that never arrives, with no error anywhere. This cannot do that. Treat the cursor as opaque; its encoding is not part of the contract.
Use ?fields=id,title,status to trim the response. id is always included.
Money and dates
Every monetary value is an object: { "amount": 125000, "currency": "AED" }. The
amount is in minor units — 125000 is 1,250.00 — and is never a float.
An amount without its currency is a bug waiting to happen in a product that bills in
more than one, so there is no shape here that permits one.
Timestamps are ISO 8601 in UTC with the Z. Fields that are date-only in the product stay date-only on the wire (2026-09-30) — widening a due date to a midnight instant invents a timezone the record never had and moves it a day for half the world.
Errors
Errors are RFC 7807 problem documents, served as application/problem+jsonso you can tell one of ours from a proxy’s HTML 502.
{
"type": "https://projectri.com/developers/errors#insufficient_scope",
"title": "Access denied",
"status": 403,
"code": "insufficient_scope",
"detail": "This token cannot write tasks: the `task.update` scope is required.",
"requestId": "req_4a1f9c02be8d47e1"
}Branch on code, never on title or detail — those are for people and may be reworded. Every response, success or failure, carries X-Request-Id; send it to us and we can find the exact call.
| code | Status | Meaning |
|---|---|---|
| missing_token | 401 | No `Authorization` header. |
| invalid_token | 401 | The token is not a live credential. Also returned for a token that never existed — the two are the same fact. |
| expired_token | 401 | Past its expiry date. |
| revoked_token | 401 | Revoked in the org console. |
| no_membership | 403 | The member who created the token is no longer active in the workspace. |
| insufficient_scope | 403 | Authenticated, but the token lacks the scope this endpoint requires. |
| not_found | 404 | No such row **in your workspace**. Never 403 — a 403 would confirm the id exists somewhere else. |
| invalid_request | 400 / 422 | Malformed JSON, an unknown field, or a value that failed validation. `errors` lists every field that failed, not just the first. |
| conflict | 409 | The request contradicts current state — most often a row inside a closed finance period. |
| idempotency_conflict | 409 | The `Idempotency-Key` was already used for a different request. |
| rate_limited | 429 | More than 300 requests in a minute. `Retry-After` says how long to wait. |
Idempotency
Send an Idempotency-Key header on any POST to make it safe to retry. A repeat with the same key returns the original response with Idempotency-Replayed: true and creates nothing new.
Keys are honoured for 24 hours.
curl -X POST https://projectri.com/api/v1/tasks \
-H "Authorization: Bearer pri_xxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: task-import-4417" \
-d '{
"projectId": "8f2c...",
"title": "Migrate the billing exporter",
"priority": "HIGH",
"dueDate": "2026-09-30"
}'Reusing a key with a different body is a 409 idempotency_conflict, not a replay — returning
the first response for a second, different request would be worse than either
alternative. If a call fails validation the key is released, so you can fix the payload
and resubmit under the same one.
Rate limits
300 requests per minute per token, published in X-RateLimit-* headers. A 429 carries Retry-Afterin seconds. The budget is shared with that token’s MCP usage, because it is the same credential hitting the same database.
Reference
Full request and response schemas are in the OpenAPI 3.1 document. This index is generated from the same source the router dispatches from, so it cannot fall out of step with it.
projects
A unit of client-billable work with a budget, a team and a set of tasks.
tasks
A unit of work inside a project.
time-entries
Logged work against a task, with the rate and FX frozen at write time.
expenses
A cost booked against a project, optionally rebilled to the client.
clients
An organisation the work is delivered for.
milestones
A dated commitment within a project, optionally requiring client sign-off.
comments
A comment on a task.
users
A member of this workspace. Read-only: membership changes go through invitations.
Not in v1
Encrypted channels and direct messages are excluded on purpose. Their contents are end-to-end encrypted — the server holds ciphertext it cannot read — so an endpoint could only ever hand back bytes no integration can use. Platform administration is excluded too: those routes govern the service itself rather than your workspace.
We publish fewer endpoints than we have, and treat the ones here as frozen. An endpoint in v1 is a promise, and a promise withdrawn is more expensive than one never made.