API conventions
The rules that hold across every nima endpoint: vocabulary, ids, casing, null, timestamps, enums, query arrays, the filter DSL and the base path of each resource.
Everything under https://api.getnima.io takes JSON and returns JSON. Almost
every route authenticates with one Authorization: Bearer header; the
exceptions are listed under Base paths and versioning below. See Authentication
for how to get a token. This page is the part that does not change from one
resource to the next.
Vocabulary of the nima API
The API is the pipeline, so its nouns are the pipeline's nouns, plus the nouns of the watches, reminders, agent operations and outbound actions built on top of it.
| Term | What it is | Where it lives |
|---|---|---|
| Signal | One piece of raw evidence: a Slack thread, a GitHub comment, a posted webhook payload, a note someone typed. Classified, de-duplicated, and tagged with an origin. | /v1/signals |
| Beacon | A ranked collection of related signals. The unit a PM decides on. | /v1/beacons |
| Spec | A document whose type is spec: the grounded write-up a coding agent can execute. Its lifecycle is specMetadata.status, not a top-level field. | /documents |
| Card | The ticket a spec is promoted into. It is what links a spec to its pull requests. A spec has at most one. | POST /documents/{id}/promote, GET /documents/{id}/delivery |
| Event | A recorded change to a signal, beacon, spec or pull request. Events are always recorded; a watch decides which ones reach a person. | /v1/events |
| Watch | A saved rule, usually owned by one person: when an event happens about a subject, run saved instructions and deliver the answer. | /v1/watches |
| Inbox item | One delivery a watch made to a person, readable with GET /v1/inbox. | /v1/inbox |
| Scheduled message | A one-time reminder delivered to the creator's own linked Slack DM at a set time. | /v1/scheduled-messages |
| Operation | One unit of agent work, usually on a spec: a handoff to your own coding agent or a run on a hosted VM. | /v1/operations |
| Action | A draft that nima holds for approval before it writes outside nima, such as a reply on a ticket or a Slack DM. | /v1/actions |
| Policy | The rule for one kind of outbound action: never, ask, or auto. | /v1/dispatch/policies |
| Identity | The link between a person in an external system, such as a Slack or Linear account, and a nima workspace member. | /v1/identities |
A spec is a document, so it comes back from the same endpoints as any other
document. Ask for ?type=spec if you only want specs; a call with no type
returns every document type.
Ids in the nima API
Every id in every path and body is a UUID string. Treat it as opaque. Do not parse it, do not sort on it, do not assume a UUID version.
Two human-readable keys sit beside ids and are not ids:
- A beacon's
key, shapedBCN-12, sequential per workspace and never reused. - A card's
ticketPrefixplusticketNumber.
Both are for display. Every path segment takes the UUID.
On signals, beacons and specs, path ids are not checked for shape. A
well-formed UUID that does not exist gives a clean 404, but a string that is
not a UUID at all can reach the database and come back as a 500 instead of a
400. Newer resources (watches, scheduled messages, operations, actions, API
keys) answer a malformed id with 400. Validate ids on your side before
sending them.
Field name casing in nima requests and responses
Request and response bodies use camelCase. The exceptions are the OAuth
endpoints under /oauth, which follow the OAuth specs and use snake_case
both ways: the token endpoint takes grant_type and refresh_token and
answers with access_token, refresh_token, token_type and expires_in,
and client registration uses redirect_uris, client_name, client_id and
the other RFC 7591 names. /oauth/userinfo answers in camelCase apart from
sub.
A signal's metadata is an open object carried through from the source
integration. Its keys are not nima's and not normalised. Do not assume any
particular key is present.
Null, absent and unchanged values in nima requests and responses
In the nima API, null, an absent key and an unchanged field are three
different things, and the difference is load-bearing.
In responses, a field that is always present but may have no value is
typed as nullable and comes back as null. classification and impact on a
signal are null until the classifier has run. A field that is absent means
something else: a key missing from a beacon's sourceCounts map means zero for
that producer, and specMetadata.generation is present while a
spec-generation run owns the document. It is removed when the run succeeds; a
run that gives up leaves it in place with stage: "failed".
In PATCH bodies, a key you omit is left alone and a key you send as
null is written as null. Clearing a spec's due date and leaving it alone
are two different requests:
{ "dueDate": null }{ "priority": "p1" }The first clears the date. The second leaves it exactly as it was.
Never send "" to mean "no value". It is stored as an empty string.
Unknown fields in a request body
How nima checks a request body depends on the resource.
Signals, beacons, specs and the other older resources drop fields they do
not declare, silently, before the handler runs. A typo in a field name looks
like a successful write that did nothing, so read the response back. A bad
value there comes back as 400 with "error": "Bad Request" and
"message": "Bad Request Exception", and the per-field reasons are dropped.
PUT /documents/{id}/markdown declares no schema at all, so nothing is
stripped and nothing is validated.
Watches reject unknown fields with a 400 whose details.issues names
each problem.
Action policies, identities and the Jira and Linear writer connections
check the body against a schema. A bad value comes back as a 400 whose
details.issues lists each problem with its path.
Agent compute settings, operations and spec dispatch also check the body
against a schema, and agent compute settings reject unknown fields. Their
400 carries only a message (Invalid agent compute settings,
Invalid operation request, Invalid spec dispatch request) and no
details, so compare your body with the resource page.
See Errors.
Timestamps and calendar dates
Timestamps are ISO 8601 in UTC, always with the Z suffix and
milliseconds:
"createdAt": "2026-09-14T09:12:44.018Z"Everything that names a moment works this way: createdAt, updatedAt,
formedAt, mergedAt, autoPromotedAt, timestamp on an error. Convert to
local time in your own layer, never in the query.
Calendar dates are a plain YYYY-MM-DD string with no time and no zone.
They appear on a spec's startDate and dueDate, and on the daily buckets of
timeline responses. Parse them by hand:
const [y, m, d] = "2026-04-15".split("-").map(Number);
const local = new Date(y, m - 1, d);new Date("2026-04-15") parses as midnight UTC and shifts the day for anyone
west of Greenwich.
The two write paths onto a spec's dates disagree today:
POST /documents/{id}/promote validates dueDate as an ISO 8601 date, while
PATCH /documents/{id}/spec-metadata accepts any string and stores it
verbatim. Send YYYY-MM-DD to both.
Enum values in the nima API
Enum values on the wire are lowercase, with snake_case for multiple words:
feature_request, in_review, spec_drift, not_addressed.
Treat every enum as open. Values are added and retired without a version
bump. Handle a value you do not recognise instead of throwing. Retirement is real and quiet:
waiting_for_response is a signal status that is still declared but that
nothing sets any more; expect it to be removed without notice.
Unknown values behave differently depending on how you pass them:
| Where | An unrecognised enum value does this |
|---|---|
filter DSL (signals, beacons) | 400, with the failing condition's path in details.issues |
?status= on /v1/signals | Passed to the query, matches nothing, empty page |
?status= on /v1/beacons | Dropped. If every value you sent is dropped, the filter disappears and you get the unfiltered list |
?slice= on /v1/beacons | 400 listing the allowed values |
Only the DSL and ?slice= tell you; the two ?status= parameters fail quietly.
Query string arrays and booleans
Query strings are parsed in the bracket-aware style, so a list can be written either way and both arrive as a list:
?status[]=ready&status[]=forming
?status=ready&status=formingGET /v1/beacons and GET /v1/beacons/facets read status as a list.
GET /v1/signals and GET /v1/signals/facets read status and impact as
comma-separated strings, and a repeated or bracketed value is joined with
commas before it is read, so all three forms work there too. Every other flat
parameter takes one value: repeat classification or source on
/v1/signals and the values are joined into one string that matches nothing.
Prefer the comma form everywhere: ?status=ready,classified.
Send the filter parameter below exactly once, as a plain filter= with no
brackets. A repeated filter is joined with commas and a bracketed one
arrives as the text [object Object], so either one fails as a 400.
Boolean query parameters are read as the literal strings true and false.
Anything else, including 1 and yes, is treated as not set.
Page size is named inconsistently: /v1/signals, /v1/beacons, /v1/inbox
and /v1/actions take page and limit, while /documents and
/v1/operations take page and pageSize. See
Pagination.
Filter DSL for signals and beacons
GET /v1/signals and GET /v1/beacons accept a structured filter as a single
JSON-encoded filter query parameter. Their facet endpoints,
/v1/signals/facets and /v1/beacons/facets, take the same parameter. No
other resource has one.
Shape of a filter expression
A filter is one group. A group has a combinator and a list of conditions,
and any condition may itself be a group:
{
"combinator": "and",
"conditions": [
{ "field": "classification", "operator": "in", "value": ["bug", "performance"] },
{ "field": "createdAt", "operator": "in_last", "value": { "amount": 14, "unit": "day" } }
]
}combinator is and or or. An empty conditions array matches everything,
so a filter you built by clearing every condition returns the unfiltered list
rather than nothing.
Filterable fields on signals and beacons
Signals:
| Field | Type |
|---|---|
status, classification, impact, source, origin | enum |
search | search |
content, pmNotes | text |
productArea, stakeholder | string_array |
priorityScore, sourceCount, reactionCount, confidence | number |
createdAt, updatedAt | date |
inBeacon, hasSpec | relation |
Beacons:
| Field | Type |
|---|---|
status, slice | enum |
title, pmNotes | text |
rankScore, evidenceCount | number |
createdAt, formedAt, updatedAt | date |
hasSpec | relation |
Filter operators by field type
The field's type decides which operators it accepts. Anything else is a 400.
| Type | Operators | value |
|---|---|---|
| enum | in, not_in | array of allowed values |
| enum, text, date, string_array | is_set, is_not_set | omit it |
| text | contains, not_contains | string |
| search | matches, not_matches | string |
| number | eq, neq, gt, gte, lt, lte | number |
| number, date | between | [from, to] |
| date | gt, gte, lt, lte | ISO 8601 timestamp string |
| date | in_last | { "amount": 14, "unit": "day" | "week" | "month" } |
| string_array | has_any, has_all | array of strings |
| relation | exists, not_exists | omit it |
| boolean | eq | boolean |
Notes on behaviour you cannot guess from the names:
- For
is_set,is_not_set,existsandnot_exists, leavevalueout entirely. Sending"value": nullis a400. containsis a case-insensitive substring match, and%,_and\in your string are matched literally rather than as wildcards.matchesis word-boundary full-text search, not a substring match: your string is split into words, each word is matched whole and case-insensitively, and the words are ANDed. There is no stemming, soexportdoes not matchexports, and a fragment likev2.or500is only reachable throughcontent contains. The flat?search=parameter onGET /v1/signalsworks the same way, withsearchMode=prefixas the option that matches word beginnings.in_lastcounts amonthas 30 days.
No field declares the boolean type on either resource today, so that row is
the DSL's, not something you can use yet.
Filter size limits
| Limit | Value |
|---|---|
| Nesting depth | 3 |
| Conditions in the whole tree | 32 |
Values in one in, not_in, has_any or has_all list | 50 |
| Characters in one text value | 200 |
in_last amount | 3650, in whatever unit the condition names |
Filter request example
curl -sS --get https://api.getnima.io/v1/signals \
-H "Authorization: Bearer $NIMA_API_KEY" \
--data-urlencode 'filter={"combinator":"and","conditions":[{"field":"classification","operator":"in","value":["bug","performance"]},{"field":"createdAt","operator":"in_last","value":{"amount":14,"unit":"day"}}]}' \
--data 'limit=1'{
"data": [
{
"id": "7c9e6679-7425-40de-944b-e07fc1f90ae7",
"source": "slack",
"status": "ready",
"classification": "bug",
"impact": "high",
"origin": "customer",
"preview": "Bulk export times out on large workspaces",
"extractedIntent": "Make bulk export finish for workspaces over 40k rows",
"extractedProductArea": ["export"],
"pmNotes": null,
"sourceCount": 3,
"createdAt": "2026-09-14T09:12:44.018Z",
"updatedAt": "2026-09-18T16:03:10.774Z"
}
],
"meta": { "page": 1, "pageSize": 1, "totalItems": 37, "totalPages": 37 }
}Fields are trimmed here. Signals has the full response shape.
The flat parameters and the DSL both apply: ?status=ready&filter=... narrows
by both. Flat parameters are the older surface and cover less.
The MCP tools for signals and beacons take the flat parameters only, not
filter; see Pipeline tools. Over HTTP you
get both.
How nima rejects a malformed filter
A malformed filter is always a 400, never a predicate that is quietly
ignored, because ignoring one returns more rows than you asked for. The
message tells you which of two things went wrong.
message | Cause |
|---|---|
filter must be valid JSON | The string did not parse, including a filter you sent twice or with brackets. details.filter echoes your first 200 characters. |
filter is not a valid filter expression | It parsed, but the expression is wrong: unknown field, operator the field does not accept, enum value outside the list, wrong value type, or a limit above. details.issues lists each problem with its path. |
Every limit in the table above surfaces as the second one:
{
"statusCode": 400,
"error": "VALIDATION_ERROR",
"message": "filter is not a valid filter expression",
"details": {
"issues": [
{ "path": "conditions", "message": "Filter is nested deeper than 3 levels (4)." }
]
},
"timestamp": "2026-09-22T11:04:18.220Z",
"path": "/v1/signals"
}An empty filter= is not an error. It is read as no filter at all.
Base paths and versioning
/v1 is a literal part of most paths and absent from others. It is not
version negotiation: there is no /v2, no version header, and no way to ask
for an older response shape.
| Resource | Base path | Credential |
|---|---|---|
| Signals | /v1/signals | key or OAuth token |
| Beacons | /v1/beacons | key or OAuth token |
| Specs and documents | /documents | key or OAuth token |
| Spec dispatch to an agent | /v1/documents/{id}/dispatch | key with operations:write (minted from Slack) |
| Spec sections | /prd | key or OAuth token |
| Event history | /v1/events | key or OAuth token |
| Watches and event catalog | /v1/watches, /v1/triggers | key or OAuth token |
| Inbox | /v1/inbox | key or OAuth token |
| Scheduled messages | /v1/scheduled-messages | key or OAuth token |
| Agent operations | /v1/operations | key with operations:write for writes (minted from Slack; see Scopes) |
| Hosted agent compute settings | /v1/agent-compute | key with integrations:write (minted from Slack) |
| Outbound action policies and project audience | /v1/dispatch | key with integrations:write (minted from Slack) |
| Outbound actions awaiting approval | /v1/actions (list and read also at /v1/dispatch/actions; approve and discard only under /v1/actions/{id}) | actions:approve, which no key or OAuth client can hold today; approve on the Slack card |
| Identities | /v1/identities | key with integrations:write (minted from Slack); link-me needs identities:write, which no key can hold today |
| GitHub writer connection | /v1/github-writer | key with integrations:write (minted from Slack) |
| Jira and Linear writer connections | /v1/integrations/jira/writer, /v1/integrations/linear/writer | key with integrations:write (minted from Slack) |
| Knowledge graph | /v1/knowledge-graph | key or OAuth token |
| Code search | /v1/code-intel | key or OAuth token |
| Workspace settings | /organizations/settings | signed-in session only |
| API keys | /v1/api-keys | signed-in session only |
| Inbound signal webhook ingest | /v1/webhooks/signals/{id} | the webhook's secret, no key; see Webhooks |
| Inbound signal webhook endpoints | /v1/signal-webhooks | signed-in session only |
OAuth, including userinfo | /oauth | none, or any scope for userinfo |
| Remote MCP | /mcp | key or OAuth token |
Copy the path from the resource page. Do not add or remove /v1 by analogy
with a neighbouring resource. A route that carries no scope, such as
/organizations/settings, answers 403 to a key or OAuth token; see
Scopes for what each scope unlocks.
There is no deprecation mechanism on the wire. Responses carry no
Deprecation or Sunset header, and nothing in a payload marks a field as
going away. latestDrift on the spec delivery response, for example, is
already superseded by the newest entry of pullRequests[].drift and will stop
being filled with no warning in the response itself. Write clients that
tolerate a field disappearing.
Request ids for support
Every response carries an X-Request-Id header. On a 5xx the same value
comes back in the body as incidentId. Quote it when you report a problem: it
is the one string that finds the whole request.