Nifty v3 API (Public)
The Nifty v3 REST API. Everything below is a cross-cutting rule that holds on every endpoint; the per-endpoint pages describe only what is specific to them.
Authentication
Send a personal access token (nft_user_…) or an OAuth 2.1 access token (nft_oauth_…) as a bearer token on every request. There is no other
authentication method, no API key query parameter and no cookie fallback. A missing or
expired token answers 401; a valid token that lacks the scope an endpoint needs answers
403 — each endpoint lists its required scopes under Security. Tokens are workspace-scoped:
one token reaches exactly one workspace.
Code
Base URL and versions
Every endpoint lives under https://openapi.niftypm.com/api/v3. Paths shown in this reference are relative to that
base. New fields and new endpoints can appear within v3 without notice —
ignore response keys you do not recognise rather than rejecting them.
See the Getting started guide for a first request end to end.
Code
Renamed names
Some names have changed, so that one concept has one name everywhere. This reference publishes only the current spelling; the retired spelling keeps working, so nothing breaks on the day a name changes.
A call that uses a retired ROUTE spelling is answered normally and additionally carries
Deprecation: true and a Link header naming the route that replaces it. A retired FIELD name
sent in a standard create or update body is accepted and applied to the current field. Both are
accepted but not published: read the current names here, and treat the headers as your list of
what to change.
The exception is an operation with a purpose-built body — the request shape published on that endpoint is validated exactly, so send the names it lists rather than the ones a general create takes.
There is no Sunset header and no removal date. Retired names go away in a later release,
announced in the changelog before it ships; until then they keep working, and we would rather
give no date than one we later have to move.
Code
Pagination
Every collection returns the same envelope: data (the page), total, limit, hasMore and
nextCursor. Page forward by passing the previous nextCursor back as ?cursor= until
hasMore is false — never by counting rows. limit accepts 1–200 and defaults to 50; a larger
value is silently clamped to 200 rather than rejected, so a short page never means the end.
total is computed only when you ask for it with ?includeTotal=true, because counting matching
rows costs an extra query.
Code
Expanding objects
Related objects are returned as ids by default. Pass ?expand= with a comma-separated list to
inline them, using a dot for a nested hop; paths may be up to four hops deep. Each read endpoint
publishes the relations it can resolve as an enum on its own expand parameter — a relation
outside that list is refused with 400 expand_unknown_relation, whose available array names
the legal ones. Expansion is never automatic: omit the parameter and you get ids only.
Code
Idempotent requests
Any mutating request may carry an Idempotency-Key header of up to 250 characters. A retry
sending the same key replays the FIRST call’s stored response instead of performing the work a
second time, so a client that times out can retry safely. The key is remembered for 10 minutes
and is bound to the request body: reusing it with a different body is refused with 422 rather
than silently replaying the wrong result. Generate a fresh key per distinct request.
Code
Concurrency
Updates are last-writer-wins unless you say otherwise. To make an update conditional on the row
not having changed since you read it, send the updatedAt you read back as expectedUpdatedAt in
the request body. If another writer — a teammate, an agent or an automation — committed in the
meantime, the update is refused with 409 stale_write. Recover by re-reading the row,
re-deriving your change from the current values, and retrying with the fresh updatedAt.
Code
Errors
Every failure returns the same RFC 7807 Problem Details body, served as application/json:
type, title, status and detail, plus the Nifty extensions code, requestId and
docsUrl. Branch on code, never on the wording of detail. A 400 from validation adds an
errors array naming the offending fields. Quote requestId when you contact support — it
identifies the exact request in our logs.
Full list with recovery steps: Error reference.
Code
Rate limits
Limits are per token and per HTTP method: 3,000 GETs per 5 minutes, 500 POSTs per minute, 100 PATCHes per minute and 200 DELETEs per 10 minutes. Two buckets apply to each request, one keyed by token and one by IP.
Block periods, and the status each limiter answers with: Rate limits.
Ids and timestamps
Resource ids are short opaque strings — treat them as text, never parse, pad or generate them,
and never assume a length (today they run 8–17 characters, and that is not part of the contract).
Actor ids (createdByActorId, updatedByActorId) are UUIDs instead, because they identify a
principal rather than a row, and the two id spaces are not interchangeable. Every timestamp is ISO
8601 in UTC with millisecond precision, on both the way in and the way out. Fields ending in Date
are calendar dates the workspace timezone applies to; fields ending in At are exact instants.
Code
Soft delete and restore
DELETE means two different things here, and each endpoint says which one it does. On a
soft-deleting resource the row moves to trash: it leaves list results at once, is purged
permanently after a 30-day retention window, and can be brought back before then with a
restore call such as POST /documents/{id}/restore. Everything else is destroyed immediately and
cannot be recovered.
Restoring something that is already active succeeds and changes nothing.
Code
assignees, labels or subscribers when you update a task replaces the whole set — use the matching sub-resource route, such as POST /tasks/{id}/assignees, to add or remove one at a time.summary is the one line a person reads (what changed, what is next); body is JSON keyed by body.schema — nifty.checkpoint/1 is validated, any other <vendor>.<kind>/<version> is stored as-is. Newest of a kind wins; never edit an old one (there is no update). List with taskId and schema filters, limit: 1 for the latest.?expand=checklistItems to materialize them inline). Deleting a checklist deletes its items.completed via update. Find legal checklistIds via the checklist resource (or task ?expand=checklists).date. type reads as a readable key: 'custom' (the only creatable kind — omit type and you get it) or 'overdue' (created by Nifty from task due dates; read-only). Create requires taskId (immutable after create). Reminders are personal — only their creator can see or change one. A reminder carries no message of its own: the notification is rendered from the task it is anchored to.type decides which configuration fields are legal, and the combinations are enforced — a mismatch is a 400, and even an explicit null counts as sending the field:
type | required | must be omitted |
|---|---|---|
date | cron or schedule | triggerStatusId |
status | triggerStatusId | cron, schedule |
completion | — | cron, schedule, triggerStatusId |
type and sourceTaskId are fixed once the rule exists; everything else is editable. Only task-anchored rules are on this surface — rules attached to lists or check-ins are managed elsewhere and never appear here, so a list you know recurs may show no rule at all.memberId narrows that to one person — this is how a team-wide time report is built.
Note for integrations: breadth follows the ROLE, not the token. A token granted time-entry read by someone holding viewTimeReports can read that whole team, not only the person who granted it.
search is the exception: it stays scoped to your own entries whatever your role, so it under-reports a team. Use list for reporting.
Omit end (or send null) to start a running entry, then PATCH end to stop it. taskId and projectId are set once: an entry cannot be moved to another task or project afterwards.projectFolderId; set it to null to unfile. A folder belongs to exactly one portfolio and cannot be moved between portfolios.isMilestone: false); set isMilestone: true for a date-spanning milestone with start/end timestamps and timeline ordering.statusId. To mark a task done, set its completed flag. Completed tasks render in a virtual "Completed" column.progressCompleted, progressTotal and (for targetType: dynamic) targetValue are computed from the goal task set — send them and they are overwritten. A create needs at least name and endAt. A goal completes itself: completedAt is stamped when its tracked progress reaches its target and cleared when it falls back below. members, tasks and lists are honoured on create and ignored on update./goals. List a group's goals with ?expand=goals, or with GET /goals?goalGroupId[eq]={id} when you want the full goal payloads. Groups have no intrinsic order — sort them however suits your UI.fields replaces the entire set, deleting anything you leave out. Keep a question by including its id. A form must always keep at least one question, may hold at most 200, and each question at most 100 choices.assignees and subscribers sub-resource routes.content); after that the body can only be extended with the append operation, never replaced, and it is not returned by list or get — read it with the content operation. accessType CANNOT BE CHANGED after creation: choose it when you create the document, or change it in the Nifty app. Choosing limited also decides who can reach the document — the member list is seeded from the resolved author, so a document created by an agent is reachable by that author, not by the agent that called the API. A child document always inherits its parent's access type. Google- and Microsoft-linked documents are readable here but are created in those apps.name.POST /messages with annotationId), and the first comment is what creates the annotation's comment thread. Read the thread with ?expand=messages.chatId, taskId, documentId, fileId, annotationId or parentMessageId, or set recipientMemberId to send a direct message to a team member (the 1:1 chat is created for you if it does not exist yet). fileId and annotationId are the one legal pair: send both to comment on a region of a file. Sending no context, or more than one, is rejected. Mention people with tagged and attach existing files with files in the same create call.recipientMemberId, and a project discussion is provisioned with its project. Members are set when the chat is created (memberIds) and cannot be changed afterwards. Messages live on the separate message resource — list them with GET /messages?chatId=....type decides what resourceId points at, and two of the types also need a projectId:
type | resourceId | projectId |
|---|---|---|
project, projectEmbed | required | required |
document, file, fileFolder, documentFolder | required | — |
personalNotes, personalTasks, createdByMe | — | — |
name is a label you supply and the server never refreshes it, so a renamed project keeps its old favorite label until you update it.theme and storyPointsCapacity are writable only on your own member record; writing another member's returns 403 whatever your role.editable: false: they accept a change to initialScreen and nothing else, and they cannot be deleted.widgets array uses REPLACE semantics: the array you send becomes the complete set, so a PATCH that omits a widget DELETES it — omit the widgets key entirely to leave them untouched. Widget order is the order of the array.?projectId[eq]=<id> to fetch a single project’s feed. Exactly one of taskId, listId, documentId and fileId is set on any given row, determined by type; project-level events set none of them. The mapping is: taskCompleted → taskId; listCreated, listMilestoneCreated and listUnlocked → listId; docCreated → documentId; fileUploaded → fileId; usersJoined and usersLeft → memberId; projectCreated → none. Newest first by default.customFields: [{ customFieldId, value }] in a task, project, list or member create or update body.
value is ALWAYS a string, whatever the field kind — send "true" or "false" for a checkbox, a member id for a user field, one of the option values from ?expand=options for a dropdown, and the written-out value for the rest. Send "" to clear a field.default: true and are protected — renaming or deleting one returns 403.
Deleting a label moves it to the trash: purgeAt is then a FUTURE date, 30 days out, when it will be purged for good.Authorization: Bearer nft_oauth_…, exactly like a personal access token.