Projectri

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.

ResourceRead scopeWrite scope
projectsproject.viewproject.update
taskstask.viewtask.update
time-entriestime.viewtime.log
expensesfinance.expense.viewfinance.expense.manage
clientsclient.viewclient.manage
milestonesproject.viewproject.plan
commentstask.viewtask.comment
usersuser.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.

codeStatusMeaning
missing_token401No `Authorization` header.
invalid_token401The token is not a live credential. Also returned for a token that never existed — the two are the same fact.
expired_token401Past its expiry date.
revoked_token401Revoked in the org console.
no_membership403The member who created the token is no longer active in the workspace.
insufficient_scope403Authenticated, but the token lacks the scope this endpoint requires.
not_found404No such row **in your workspace**. Never 403 — a 403 would confirm the id exists somewhere else.
invalid_request400 / 422Malformed JSON, an unknown field, or a value that failed validation. `errors` lists every field that failed, not just the first.
conflict409The request contradicts current state — most often a row inside a closed finance period.
idempotency_conflict409The `Idempotency-Key` was already used for a different request.
rate_limited429More 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.

GET/v1/projectsList projects
GET/v1/projects/{id}Fetch one project
POST/v1/projectsCreate a project
PATCH/v1/projects/{id}Update a project
DELETE/v1/projects/{id}Archive

tasks

A unit of work inside a project.

GET/v1/tasksList tasks
GET/v1/tasks/{id}Fetch one task
POST/v1/tasksCreate a task
PATCH/v1/tasks/{id}Update a task
DELETE/v1/tasks/{id}Archive

time-entries

Logged work against a task, with the rate and FX frozen at write time.

GET/v1/time-entriesList time-entries
GET/v1/time-entries/{id}Fetch one time entry
POST/v1/time-entriesCreate a time entry
PATCH/v1/time-entries/{id}Update a time entry
DELETE/v1/time-entries/{id}Delete permanently

expenses

A cost booked against a project, optionally rebilled to the client.

GET/v1/expensesList expenses
GET/v1/expenses/{id}Fetch one expense
POST/v1/expensesCreate a expense
PATCH/v1/expenses/{id}Update a expense
DELETE/v1/expenses/{id}Delete permanently

clients

An organisation the work is delivered for.

GET/v1/clientsList clients
GET/v1/clients/{id}Fetch one client
POST/v1/clientsCreate a client
PATCH/v1/clients/{id}Update a client

milestones

A dated commitment within a project, optionally requiring client sign-off.

GET/v1/milestonesList milestones
GET/v1/milestones/{id}Fetch one milestone
POST/v1/milestonesCreate a milestone
PATCH/v1/milestones/{id}Update a milestone
DELETE/v1/milestones/{id}Delete permanently

comments

A comment on a task.

GET/v1/commentsList comments
GET/v1/comments/{id}Fetch one comment
POST/v1/commentsCreate a comment
PATCH/v1/comments/{id}Update a comment
DELETE/v1/comments/{id}Delete permanently

users

A member of this workspace. Read-only: membership changes go through invitations.

GET/v1/usersList users
GET/v1/users/{id}Fetch one user

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.