docs
API

Signals

List, search, count, read and create signals, and fetch the original document behind one.

A signal is one piece of raw product evidence: a Slack thread, a GitHub comment, a Sentry issue, a meeting note, a JSON payload posted to one of your webhooks, a customer-voice record from your CRM, or a note someone typed. nima classifies it, extracts what it claims, embeds it, tags it with an origin and links it to the knowledge graph. A signal normally leaves this stage attached to a beacon or dismissed; it can also be merged into a duplicate or fail processing.

On for every workspace. The five signal routes below accept an API key or an OAuth token. Everything that changes an existing signal needs a signed-in session; see Signal routes that need a signed-in session.

Signal API operations at a glance

Five routes are reachable with an API key or OAuth token. Paths are relative to https://api.getnima.io.

MethodPathScopeMCP tool
GET/v1/signalssignals:readsearch_signals
GET/v1/signals/facetssignals:readnone
GET/v1/signals/{id}signals:readget_signal
GET/v1/signals/{id}/sourcesources:readget_signal_source
POST/v1/signalssignals:writecreate_signal

Every call carries Authorization: Bearer <your key> and needs the scope in the table. Create a key under Settings in the nima admin panel: it acts as you, in the workspace you created it in, and reaches only the endpoints covered by the permissions you picked. See Create an API key and Scopes and permissions.

These routes are in the OpenAPI document, which covers every operation on this site. Being described there is not a stability promise. Fields can be added and behaviour can change without a version bump, so re-check anything you pin to them.

List and search signals

GET/v1/signals
scopesignals:readMCP toolsearch_signals

GET /v1/signals returns the signals in your workspace, filtered, sorted and paged.

searchstring

Full-text search over the content, the preview, the extracted intent, the PM notes, the stakeholders and the product areas. Whole words by default. See How signal search matches words.

searchModeenum

word or prefix. Defaults to word. prefix matches words that start with each search term.

statusstring

Comma-separated list of statuses. ?status=ready,resolved.

classificationstring

One classification. A list is not split here.

sourcestring

One source. A list is not split here.

impactstring

Comma-separated list of low, medium, high.

groupedboolean

true returns only signals sitting in a live beacon, false only signals that are not.

inSpecboolean

true returns only signals cited by a live spec.

sortByenum

priority, newest or volume. Defaults to priority. volume sorts by sourceCount, the number of source reports behind the signal. priority breaks ties by newest first.

sortOrderenum

asc or desc. Defaults to desc. Ignored when sortBy is priority, which is always descending.

groupByenum

impact, product_area, classification or none. Defaults to none. Changes the response shape. See Grouped signal lists.

pageinteger

Defaults to 1.

limitinteger

Defaults to 20. Clamped to 1 to 100.

filterJSON string

The structured filter. See The signal filter parameter.

page and limit are clamped, never rejected: limit=500 becomes 100, limit=abc becomes 20, page=0 becomes 1. A value the database does not know is not an error either: status=bogus matches nothing and returns an empty page, and an unrecognised sortBy falls back to priority. Give the flat parameters a comma-separated string, not repeated keys and not status[]=ready; the bracket form arrives as an array and fails the request with a 500.

Three kinds of row are never listed, whatever you ask for: signals folded into a duplicate (mergedIntoId set), signals from a channel whose auto-skip rule silenced them, and deleted signals. Filtering by status=merged therefore returns an empty page. You can still read a merged signal by id.

attachments is always empty on this endpoint, even for a signal that has them. Read the signal by id to see them.

How signal search matches words

The search parameter is word-boundary full-text search, not a substring scan. nima lowercases and tokenises the text and never stems it, so search=fracti does not match fractional, and search=import does not match imports.

With searchMode=prefix, each term matches words that start with it, so fracti finds fractional. Use prefix for as-you-type queries and word (the default) for exact terms. The search text is matched against the preview, extracted intent, content and PM notes, and separately against the stakeholder and product-area lists.

To find a fragment a tokeniser throws away, such as v2. or 500 , use the content field of the filter parameter with the contains operator, which is a plain, case-insensitive substring scan.

The search_signals MCP tool takes a narrower set of inputs and returns a trimmed row; see search_signals tool.

The signal filter parameter

The filter parameter takes one URL-encoded JSON object. It is ANDed with every flat parameter above, so the two compose.

{
  "combinator": "and",
  "conditions": [
    { "field": "classification", "operator": "in", "value": ["bug", "performance"] },
    { "field": "createdAt", "operator": "in_last", "value": { "amount": 14, "unit": "day" } },
    { "field": "inBeacon", "operator": "not_exists" }
  ]
}

A condition is { field, operator, value }. A nested group is { combinator, conditions } and can sit anywhere a condition can. Which operators a field accepts is decided by its type.

FieldType
status, classification, impact, source, originenum
searchfull-text search
content, pmNotestext
productArea, stakeholderstring array
priorityScore, sourceCount, reactionCount, confidencenumber
createdAt, updatedAtdate
inBeacon, hasSpecrelation
TypeOperatorsValue
enumin, not_in, is_set, is_not_setarray of the field's own values
full-text searchmatches, not_matchesstring, tokenised
textcontains, not_contains, is_set, is_not_setstring, case-insensitive substring
string arrayhas_any, has_all, is_set, is_not_setarray of strings
numbereq, neq, gt, gte, lt, lte, betweennumber, or a pair for between
dategt, gte, lt, lte, between, in_last, is_set, is_not_setISO 8601 UTC string, or { "amount": n, "unit": "day" | "week" | "month" } for in_last
relationexists, not_existsomit value

The search field and the content field are not the same thing. search runs against a tokenised index and is the fast one. content is a plain, case-insensitive substring scan.

Limits on a filter, all of them a 400 when exceeded: nesting depth 3, 32 conditions, 50 values in one list, 200 characters in one text value, and an in_last amount of 3650.

reactionCount is filterable but is not a field of the signal object. You can filter a list down to signals with many reactions; you cannot sort by it or read the number back.

Signal list paging and the 3000-row cap

The list response carries meta with page, pageSize, totalItems and totalPages. Paging stops hard at 3000 rows: a request whose offset plus page size would pass it is a 400 with details.maxOffset, so at the default limit=20 page 151 fails. Narrow the filter instead of paging deeper.

meta.countMode is usually absent, which means totalItems is an exact count. It appears as "estimated" when the filter uses origin, productArea, stakeholder, inBeacon, hasSpec, or an or group with more than one condition: no index reaches those, so the total is the query planner's guess while the rows themselves are exact. A short final page always reports "exact".

Grouped signal lists

Setting groupBy replaces the paginated shape with { "groups": [{ "key", "label", "count", "data" }], "meta": { "totalItems" } }. Grouping happens over the rows of the current page only, so count is how many of that group landed on this page, not how many exist. Signals with no value for the grouped field land under unknown.

Errors from list signals

StatuserrorWhen
400VALIDATION_ERRORfilter is not a single JSON string, is not valid JSON, fails the schema, or exceeds a filter limit. details names the issue.
400VALIDATION_ERRORThe offset passes 3000 rows.
401TOKEN_INVALIDMissing, malformed, revoked or expired key.
403INSUFFICIENT_PERMISSIONSThe key does not carry signals:read. details.requiredScopes says what it needed.
429RATE_LIMITED100 requests per 60 seconds from one address.

Count signals by field

GET/v1/signals/facets
scopesignals:read

GET /v1/signals/facets returns how many signals each value of a field would leave, under the same scope the list is using. It is a second call rather than a field on the list because it runs under its own budget and its own cache. It has no MCP tool.

fieldsstring

Comma-separated. Defaults to every enum field. Enum fields are status, classification, impact, source, origin. Array fields productArea and stakeholder are opt-in, because each one costs a second query.

filterJSON string

Same DSL as the list.

status, classification, source, impact, search, grouped and inSpec are taken here too: the same flat parameters the list takes, with the same meaning. searchMode is not taken here, so search always matches whole words.

Counts drill sideways: for the field being counted, that field's own top-level conditions are dropped from filter and everything else is kept, so ticking a second value of the same facet widens the list instead of emptying it. The flat parameters are not dropped, so status=ready&fields=status reports 0 for every status but ready.

Every value the schema declares is present, zero-filled. unset counts rows whose value is null, which is a bucket of its own and never one of the values. An array facet returns at most 50 values, highest count first. arrays is null unless you asked for an array field by name.

A count has 500 milliseconds to finish. Past that the answer is still a 200, with "mode": "timeout" and total, facets and arrays all null. Show no numbers rather than wrong ones, and retry: the result is cached for 10 seconds after a timeout and 30 seconds after a success.

Errors from signal facet counts

StatuserrorWhen
400VALIDATION_ERRORfields names something that is not a facet, or is not a single comma-separated string.
400VALIDATION_ERRORfilter is malformed, same rules as the list.
401TOKEN_INVALIDMissing, malformed, revoked or expired key.
403INSUFFICIENT_PERMISSIONSThe key does not carry signals:read.
429RATE_LIMITED100 requests per 60 seconds from one address.
503SERVICE_UNAVAILABLEThe count failed for a reason other than the budget. Retryable.

Get a signal

GET/v1/signals/{id}
scopesignals:readMCP toolget_signal

GET /v1/signals/{id} returns one signal by id. Unlike the list, it returns merged and auto-skipped signals, and it fills attachments. It does not fill linkedEntities, which is always [] here and populated on the list: the two endpoints are complementary, not interchangeable.

iduuidrequired

The signal id, in the path.

The body is a single signal object, the same shape as one element of the list's data. Its sourceDocument.available field says whether a fuller original exists and can be fetched with the source endpoint.

proxyUrl is honest about where the file is, but the route behind it needs a signed-in session: a key gets a 403 there.

An {id} that is not a UUID is not checked at the route. It reaches the database as-is and comes back as a 500 with INTERNAL_SERVER_ERROR and an incidentId, not a 400. Validate ids before sending them. A well-formed UUID that does not exist, or belongs to another workspace, is a clean 404.

Errors from get a signal

StatuserrorWhen
401TOKEN_INVALIDMissing, malformed, revoked or expired key.
403INSUFFICIENT_PERMISSIONSThe key does not carry signals:read.
404RESOURCE_NOT_FOUNDNo such signal in this workspace. A signal in another workspace is a 404, never a 403.
429RATE_LIMITED100 requests per 60 seconds from one address.
500INTERNAL_SERVER_ERROR{id} is not a UUID.

Get the original document behind a signal

GET/v1/signals/{id}/source
scopesources:readMCP toolget_signal_source

GET /v1/signals/{id}/source fetches the original document behind a signal. nima stores the excerpt that produced a signal, never the original. This route fetches the original live, with your workspace's own credential for that integration: the whole Slack thread, the full GitHub issue or pull request discussion, the HubSpot record, the Granola note, the PostHog or Sentry issue, the Linear issue with its comments, the ClickUp task with its thread. Nothing is cached and nothing is written.

sources:read is the one scope on this page that an ordinary member cannot grant. The credential it borrows is broader than any one person's access, so only a workspace owner or admin can mint a key carrying it. A member who asks for it gets a key with the scope silently dropped, and every call here answers 403. See Scopes and permissions.

iduuidrequired

The signal id, in the path.

cursorstring

An opaque nextCursor from a previous page of the same signal and the same part.

partenum

summary or full. Defaults to summary. summary is the document as nima's producer saw it. full asks for everything the source holds. Two sources treat them differently. On Granola, full is the meeting transcript. On a HubSpot ticket with a conversation thread, summary stops after the first page of messages and says how many remain, and only full returns a cursor that pages through the whole thread. Every other source returns the same document either way.

A page is at most 40,000 bytes. When nextCursor is not null, pass it back as cursor for the next page, unchanged, with the same part. A cursor belongs to one signal, one source and one part; reusing it anywhere else is a 400, and so is continuing to page a document that changed underneath you.

changedSinceIngest compares a hash of the unit that produced the signal against the same unit today. It is null when nima stored no hash or the source has no stable unit, which is not the same as false.

duplicates lists the signals folded into this one, at most 50. Each has its own original: fetch them by their own id.

Source document availability values

A problem at the source is a 200, not an error status. Read availability before you read content. Only nima's own failures raise a status code.

availabilityMeaning
liveFetched from the source system just now. content is set.
storednima is the system of record. Manual, webhook and web performance signals have no external original, so the stored text is returned and changedSinceIngest is null. Extraction may have rewritten that text, so it is not guaranteed verbatim.
unavailableNothing was fetched. content is null and reason says why.
reasonMeaning
missing_referenceIngested before nima recorded source references. Nothing to rebuild from.
integration_disconnectedThe integration is gone, or its token no longer works.
integration_mismatchThe integration was reconnected to a different workspace, portal or installation than the one the signal came from. Ids collide across workspaces, so nima refuses rather than return someone else's document.
not_found_at_sourceDeleted, retention-expired or merged away upstream.
access_denied_at_sourceThe bot left the channel, the repository was uninstalled, or the token lost the org.
rate_limitedEither the vendor said so, or nima's own per-workspace budget for that source is spent for this minute.
source_errorAnything else the source returned.
workspace_pending_deletionThe workspace is on its deletion hold. Nothing is fetched.
source_terms_pendingThe full document is switched off on nima's side for this source. It can occur only for Granola transcripts requested with part=full, and Granola transcripts are on today.

The per-workspace fetch budget, per minute, per source: Slack 20; HubSpot, Granola and ClickUp 30; GitHub, PostHog, Sentry and Linear 60. Past it you get rate_limited rather than a 429.

Errors from get the original document behind a signal

StatuserrorWhen
400VALIDATION_ERRORThe cursor is invalid, belongs to another signal or part, or the document changed while paging. Restart without a cursor.
401TOKEN_INVALIDMissing, malformed, revoked or expired key.
403INSUFFICIENT_PERMISSIONSThe key does not carry sources:read, or the key's owner is not an owner or admin.
404RESOURCE_NOT_FOUNDNo such signal in this workspace.
429RATE_LIMITED300 requests per 10 seconds from one address. This is the transport throttle, not the per-source budget above.
500INTERNAL_SERVER_ERROR{id} is not a UUID.

Create a signal

POST/v1/signals
scopesignals:writeMCP toolcreate_signal

POST /v1/signals files one piece of feedback into the workspace by hand. It is the right endpoint for something a person or an agent observed. For a system posting on a schedule, use an inbound webhook instead: the webhook endpoint de-duplicates and this one does not.

contentstringrequired

The feedback in the reporter's own words. Maximum 10,000 characters.

sourceLabelstring

Where it came from, for example customer call or support ticket 412. Maximum 100 characters. Defaults to manual and is stored as sourceChannel.

stakeholdersstring[]

Who this affects. At most 10 entries, each at most 80 characters. Entries are trimmed, blanks dropped, duplicates removed. Stored as extractedStakeholder.

productAreasstring[]

What this touches. Same rules. Stored as extractedProductArea.

contentBlocksobject

A rich editor document. Not exposed to the MCP tool. Plain content covers every other case.

Unknown fields are stripped silently rather than rejected.

What nima does after a signal is created

The signal is written with source: "manual", status: "pending", confidence: 1, a generated sourceId, and preview set to the first 280 characters of content. Then extraction is queued and the response is sent: 201, with the created signal.

The response is the signal as it exists a millisecond after the write, so classification, impact, extractedIntent and verdict are all null and priorityScore is 0. nima fills them asynchronously. When extraction marks the signal actionable it also replaces content and preview with the extracted intent. The signal moves from pending to extracting to ready, or to a terminal dismissed (noise, or nothing to extract) or failed. Poll GET /v1/signals/{id} if you need the outcome.

Manual signals are never de-duplicated. There is no idempotency key on this route, so a retried POST creates a second signal, and nothing folds the two together at write time or later: nima does not merge signals automatically. Only a person merges duplicates, with a signed-in session.

Errors from create a signal

StatuserrorWhen
400Bad RequestThe body failed validation. See the note below.
401TOKEN_INVALIDMissing, malformed, revoked or expired key.
403INSUFFICIENT_PERMISSIONSThe key does not carry signals:write.
429RATE_LIMITED300 requests per 10 seconds from one address.

A body that fails validation is the one response on this page that breaks the error envelope. You get {"statusCode": 400, "error": "Bad Request", "message": "Bad Request Exception"}: error is an English phrase rather than an error code, and the per-field messages are dropped. Check the rules in the parameter list above rather than the response. Every other 400 on this page is a proper VALIDATION_ERROR with details.

Signal routes that need a signed-in session

Every signal route that changes an existing signal, and several that read one, answer 403 to an API key or an OAuth token, because none of them declares a scope. They need a signed-in owner, admin or member session. The web app is an admin panel only, so there is no page that calls them; they are listed so you know what an agent cannot do.

RouteWhat it does
POST /v1/signals/{id}/dismissDismisses a signal. Records the verdict manual_dismiss and an optional reason. A signal that is already dismissed is a 400.
POST /v1/signals/{id}/restoreReturns a dismissed signal to ready.
POST /v1/signals/{id}/mergeFolds the signal into a target signal as a duplicate. Manual only.
POST /v1/signals/{id}/unmergeReverses a merge.
POST /v1/signals/bulk-dismiss, bulk-restore, bulk-mergeThe same actions in bulk. At most 100 ids per call; if some fail the response is HTTP 207.
PATCH /v1/signals/{id}, PATCH /v1/signals/batchOverrides classification, impact, origin, pmNotes, stakeholders, product areas or contentBlocks. It cannot write status.
DELETE /v1/signals/{id}, POST /v1/signals/batch-deleteDeletes signals.
GET /v1/signals/{id}/eventsThe signal's event history.
GET /v1/signals/{id}/attachments/{index}The attachment proxy behind proxyUrl.
GET /v1/signals/{id}/related, GET /v1/signals/{id}/documentsSimilar signals, and the specs that cite this signal.
POST /v1/signals/filter/aiTurns a plain-language question into the filter object. It returns the filter and does not run it.
GET /v1/signals/stats, /stats/timeline, /clusters, /delivery/healthAggregate feeds.

A PM override with PATCH is how origin gets changed from what nima inferred.

The signal object

The signal object is the JSON body returned by the list, get and create routes.

iduuid

The signal id.

organizationIduuid

The workspace. Always your own.

sourcestring
sourceIdstring

The id in the source system: a Slack message timestamp, an issue id, a generated UUID for a manual signal.

sourceChannelstring

Where inside the source: a Slack channel name, a GitHub owner/repo, sentry:<org>/<project>, linear:<workspace>/<team>, or the sourceLabel you sent.

contentstring

The evidence text. Until extraction finishes it is the excerpt nima received. Once a signal is ready, it is the one-line extracted intent, because extraction overwrites it. Fetch the original wording with the source endpoint.

previewstring

The first 280 characters of content.

confidencenumber

0 to 1. How sure the classifier was. A manual signal is written with 1 and extraction replaces it with the classifier's own value.

statusstring
classificationstring | null

See Signal classification values. Null until classified.

impactstring | null

low, medium or high. Null until classified.

originstring

internal, external or customer. Never null. See Signal origin values.

metadataobject

Free-form, shaped by the producer. Carries sourceRef (the reference used by the source endpoint) and whatever else that integration recorded.

extractedIntentstring | null

What nima decided the signal is asking for, in one line.

extractedStakeholderstring[] | null

People or accounts affected.

extractedProductAreastring[] | null

Product areas touched.

contentBlocksobject | null

Rich editor document, set only for manual signals created with one.

mergedIntoIdstring | null

The signal this one was folded into. Set means this signal is a duplicate and will not appear in lists.

sourceCountnumber

How many source reports stand behind this signal: 1 for a signal with a single origin (every manual, webhook, GitHub, HubSpot, Granola, PostHog, Sentry, Linear or ClickUp signal starts at 1), the number of linked messages for a Slack signal, and the combined count after a person merges duplicates into it. A HubSpot customer-voice signal also gains one for each matching record nima folds into it.

pmNotesstring | null

Free text a PM attached to the signal.

priorityScorenumber

Set when a person overrides impact with PATCH: 300 for high, 200 medium, 100 low, 0 none, plus 10 per sourceCount at that moment. Extraction does not set it, so a signal nobody re-triaged has 0. The default list sort orders by it, then by createdAt newest first, so an untouched list comes back newest first.

linkedEntitiesarray

Knowledge-graph entities this signal touches: { entityId, name, entityType, linkType, confidence }, where linkType is maps_to_feature, maps_to_user_need, maps_to_requirement or new_entity. Populated on the list, empty on a single fetch.

processingContextobject | null

{ clarifications, extractionHistory, pendingClarification }. pendingClarification is set when nima asked a clarifying question in the source thread and no answer has arrived.

verdictstring | null

The extraction outcome: actionable, noise, extraction_failed, timeout, budget_exceeded, source_deleted, duplicate or legacy_pre_triage, or manual_dismiss when a person dismissed it. Null while still in flight.

dismissReasonstring | null

Why the signal was dismissed or failed.

attachmentsarray

{ index, type, fileName, mimeType, transcription, imageRole, proxyUrl }, where type is image, video or file and imageRole is feature_context, bug_demo, evidence, unrelated or null. Populated on a single fetch, empty on the list.

sourceDocumentobject

{ available, tool }. available is true when the source is stored in nima (manual, webhook, web_performance) or the signal carries a source reference, so the source endpoint has something to fetch. It makes no live call, so a true can still come back unavailable there.

accountsarray

CRM accounts the signal is attributed to. Filled on the list, get and create routes: [] when none matched. Absent only if the account lookup failed.

createdAtISO 8601

UTC.

updatedAtISO 8601

UTC.

Signal status values

A signal moves pending, processing, extracting, ready, and ends in one of four terminal states. Filter by these with status.

ValueMeaning
pendingWritten, not yet picked up.
processingBeing worked on.
extractingClaims are being pulled out of it.
readyFully processed and available for grouping into a beacon.
mergedFolded into a duplicate by a person. Terminal, and not a delivery outcome.
dismissedThrown away, by nima as noise or by a person. Terminal.
failedProcessing gave up. Terminal.
resolvedThe spec this signal fed shipped. Terminal.
classifiedLegacy. An old intermediate state that nima no longer writes; a sweep at startup fails any row still in it.
waiting_for_responseDeprecated. Nothing writes it any more; processingContext.pendingClarification replaced it. Old rows were migrated.

Signal classification values

The classification is one of bug, feature_request, spec_drift, customer_feedback, performance, documentation, question or noise. The extractor itself assigns bug, feature_request or spec_drift; the other values are valid for filtering and for a PM override.

Signal origin values

The origin is internal for your own team or systems, external for someone outside the workspace who is not an identified customer, and customer for an identified customer. A person can override it. When nobody has, nima infers it from the source:

SourceInferred origin
hubspot, or any channel starting hubspot:customer
githubexternal
slack, manual, granola, posthog, sentry, linear, clickup, webhook, web_performanceinternal

Four producers set the origin themselves instead: a Granola meeting with an outside attendee arrives as customer, a Linear customer request as customer, a ClickUp task with a customer-named custom field as customer, and a HubSpot customer-voice record as customer when its account is a customer or partner and external otherwise (or whatever origin its recipe pins, internal or external). The hubspot row above applies only to HubSpot signals that carry no stored origin.

Signal source values

The source field says which integration produced the signal.

ValueWhere it comes from
slackA message or thread in a monitored channel.
githubAn issue, pull request or comment.
manualTyped by a person, or filed through create.
hubspotA customer-voice record from HubSpot: a ticket, a survey comment, a deal won or lost reason, or the text of a meeting, call, email or note. It is not a record of a CRM object change. HubSpot ingestion is rolling out; ask the nima team.
granolaA meeting note summary. Transcripts are never stored.
posthogOne error-tracking issue, not one exception.
sentryOne unresolved issue.
linearAn issue, comment or customer request.
clickupA task, or one comment on a task.
webhookA JSON payload posted to one of your webhooks.
web_performanceAvailability and latency alerts. A settled contract with no producer wired yet, so no signal carries it today.

Signal error envelope and retry hints

Every error on this page uses the standard envelope. details is present only below 500, and incidentId only at 500 and above.

{
  "statusCode": 400,
  "error": "VALIDATION_ERROR",
  "message": "filter is not a valid filter expression",
  "details": { "issues": [{ "path": "conditions.0.operator", "message": "Invalid option" }] },
  "timestamp": "2026-09-22T11:47:05.173Z",
  "path": "/v1/signals"
}

A 429 carries Retry-After in seconds, and that header is the only retry hint you get: the body has no equivalent field. Responses that were not blocked carry X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset, so you can slow down before you hit the wall. Throttles count per calling IP address, not per key, so several keys behind one address share one budget and rotating a key does not buy a fresh one.

Full rules in Errors and Scopes and permissions.