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.
| Method | Path | Scope | MCP tool |
|---|---|---|---|
GET | /v1/signals | signals:read | search_signals |
GET | /v1/signals/facets | signals:read | none |
GET | /v1/signals/{id} | signals:read | get_signal |
GET | /v1/signals/{id}/source | sources:read | get_signal_source |
POST | /v1/signals | signals:write | create_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
/v1/signalssignals:readMCP toolsearch_signalsGET /v1/signals returns the signals in your workspace, filtered, sorted and
paged.
searchstringFull-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.
searchModeenumword or prefix. Defaults to word. prefix matches words that start with
each search term.
statusstringComma-separated list of statuses.
?status=ready,resolved.
classificationstringOne classification. A list is not split here.
sourcestringOne source. A list is not split here.
impactstringComma-separated list of low, medium, high.
groupedbooleantrue returns only signals sitting in a live beacon, false only signals
that are not.
inSpecbooleantrue returns only signals cited by a live spec.
sortByenumpriority, newest or volume. Defaults to priority. volume sorts by
sourceCount, the number of source reports behind the signal. priority
breaks ties by newest first.
sortOrderenumasc or desc. Defaults to desc. Ignored when sortBy is priority,
which is always descending.
groupByenumimpact, product_area, classification or none. Defaults to none.
Changes the response shape. See Grouped signal lists.
pageintegerDefaults to 1.
limitintegerDefaults to 20. Clamped to 1 to 100.
filterJSON stringThe 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.
| Field | Type |
|---|---|
status, classification, impact, source, origin | enum |
search | full-text search |
content, pmNotes | text |
productArea, stakeholder | string array |
priorityScore, sourceCount, reactionCount, confidence | number |
createdAt, updatedAt | date |
inBeacon, hasSpec | relation |
| Type | Operators | Value |
|---|---|---|
| enum | in, not_in, is_set, is_not_set | array of the field's own values |
| full-text search | matches, not_matches | string, tokenised |
| text | contains, not_contains, is_set, is_not_set | string, case-insensitive substring |
| string array | has_any, has_all, is_set, is_not_set | array of strings |
| number | eq, neq, gt, gte, lt, lte, between | number, or a pair for between |
| date | gt, gte, lt, lte, between, in_last, is_set, is_not_set | ISO 8601 UTC string, or { "amount": n, "unit": "day" | "week" | "month" } for in_last |
| relation | exists, not_exists | omit 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
| Status | error | When |
|---|---|---|
400 | VALIDATION_ERROR | filter is not a single JSON string, is not valid JSON, fails the schema, or exceeds a filter limit. details names the issue. |
400 | VALIDATION_ERROR | The offset passes 3000 rows. |
401 | TOKEN_INVALID | Missing, malformed, revoked or expired key. |
403 | INSUFFICIENT_PERMISSIONS | The key does not carry signals:read. details.requiredScopes says what it needed. |
429 | RATE_LIMITED | 100 requests per 60 seconds from one address. |
Count signals by field
/v1/signals/facetssignals:readGET /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.
fieldsstringComma-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 stringSame 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
| Status | error | When |
|---|---|---|
400 | VALIDATION_ERROR | fields names something that is not a facet, or is not a single comma-separated string. |
400 | VALIDATION_ERROR | filter is malformed, same rules as the list. |
401 | TOKEN_INVALID | Missing, malformed, revoked or expired key. |
403 | INSUFFICIENT_PERMISSIONS | The key does not carry signals:read. |
429 | RATE_LIMITED | 100 requests per 60 seconds from one address. |
503 | SERVICE_UNAVAILABLE | The count failed for a reason other than the budget. Retryable. |
Get a signal
/v1/signals/{id}signals:readMCP toolget_signalGET /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.
iduuidrequiredThe 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
| Status | error | When |
|---|---|---|
401 | TOKEN_INVALID | Missing, malformed, revoked or expired key. |
403 | INSUFFICIENT_PERMISSIONS | The key does not carry signals:read. |
404 | RESOURCE_NOT_FOUND | No such signal in this workspace. A signal in another workspace is a 404, never a 403. |
429 | RATE_LIMITED | 100 requests per 60 seconds from one address. |
500 | INTERNAL_SERVER_ERROR | {id} is not a UUID. |
Get the original document behind a signal
/v1/signals/{id}/sourcesources:readMCP toolget_signal_sourceGET /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.
iduuidrequiredThe signal id, in the path.
cursorstringAn opaque nextCursor from a previous page of the same signal and the same
part.
partenumsummary 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.
availability | Meaning |
|---|---|
live | Fetched from the source system just now. content is set. |
stored | nima 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. |
unavailable | Nothing was fetched. content is null and reason says why. |
reason | Meaning |
|---|---|
missing_reference | Ingested before nima recorded source references. Nothing to rebuild from. |
integration_disconnected | The integration is gone, or its token no longer works. |
integration_mismatch | The 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_source | Deleted, retention-expired or merged away upstream. |
access_denied_at_source | The bot left the channel, the repository was uninstalled, or the token lost the org. |
rate_limited | Either the vendor said so, or nima's own per-workspace budget for that source is spent for this minute. |
source_error | Anything else the source returned. |
workspace_pending_deletion | The workspace is on its deletion hold. Nothing is fetched. |
source_terms_pending | The 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
| Status | error | When |
|---|---|---|
400 | VALIDATION_ERROR | The cursor is invalid, belongs to another signal or part, or the document changed while paging. Restart without a cursor. |
401 | TOKEN_INVALID | Missing, malformed, revoked or expired key. |
403 | INSUFFICIENT_PERMISSIONS | The key does not carry sources:read, or the key's owner is not an owner or admin. |
404 | RESOURCE_NOT_FOUND | No such signal in this workspace. |
429 | RATE_LIMITED | 300 requests per 10 seconds from one address. This is the transport throttle, not the per-source budget above. |
500 | INTERNAL_SERVER_ERROR | {id} is not a UUID. |
Create a signal
/v1/signalssignals:writeMCP toolcreate_signalPOST /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.
contentstringrequiredThe feedback in the reporter's own words. Maximum 10,000 characters.
sourceLabelstringWhere 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.
contentBlocksobjectA 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
| Status | error | When |
|---|---|---|
400 | Bad Request | The body failed validation. See the note below. |
401 | TOKEN_INVALID | Missing, malformed, revoked or expired key. |
403 | INSUFFICIENT_PERMISSIONS | The key does not carry signals:write. |
429 | RATE_LIMITED | 300 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.
| Route | What it does |
|---|---|
POST /v1/signals/{id}/dismiss | Dismisses a signal. Records the verdict manual_dismiss and an optional reason. A signal that is already dismissed is a 400. |
POST /v1/signals/{id}/restore | Returns a dismissed signal to ready. |
POST /v1/signals/{id}/merge | Folds the signal into a target signal as a duplicate. Manual only. |
POST /v1/signals/{id}/unmerge | Reverses a merge. |
POST /v1/signals/bulk-dismiss, bulk-restore, bulk-merge | The 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/batch | Overrides classification, impact, origin, pmNotes, stakeholders, product areas or contentBlocks. It cannot write status. |
DELETE /v1/signals/{id}, POST /v1/signals/batch-delete | Deletes signals. |
GET /v1/signals/{id}/events | The signal's event history. |
GET /v1/signals/{id}/attachments/{index} | The attachment proxy behind proxyUrl. |
GET /v1/signals/{id}/related, GET /v1/signals/{id}/documents | Similar signals, and the specs that cite this signal. |
POST /v1/signals/filter/ai | Turns 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/health | Aggregate 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.
iduuidThe signal id.
organizationIduuidThe workspace. Always your own.
sourcestringSee Signal source values.
sourceIdstringThe id in the source system: a Slack message timestamp, an issue id, a generated UUID for a manual signal.
sourceChannelstringWhere inside the source: a Slack channel name, a GitHub owner/repo,
sentry:<org>/<project>, linear:<workspace>/<team>, or the sourceLabel
you sent.
contentstringThe 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.
previewstringThe first 280 characters of content.
confidencenumber0 to 1. How sure the classifier was. A manual signal is written with 1
and extraction replaces it with the classifier's own value.
statusstringSee Signal status values.
classificationstring | nullSee Signal classification values. Null until classified.
impactstring | nulllow, medium or high. Null until classified.
originstringinternal, external or customer. Never null. See
Signal origin values.
metadataobjectFree-form, shaped by the producer. Carries sourceRef (the reference used by
the source endpoint) and whatever else that
integration recorded.
extractedIntentstring | nullWhat nima decided the signal is asking for, in one line.
extractedStakeholderstring[] | nullPeople or accounts affected.
extractedProductAreastring[] | nullProduct areas touched.
contentBlocksobject | nullRich editor document, set only for manual signals created with one.
mergedIntoIdstring | nullThe signal this one was folded into. Set means this signal is a duplicate and will not appear in lists.
sourceCountnumberHow 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 | nullFree text a PM attached to the signal.
priorityScorenumberSet 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.
linkedEntitiesarrayKnowledge-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 | nullThe 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 | nullWhy 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.
accountsarrayCRM 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 8601UTC.
updatedAtISO 8601UTC.
Signal status values
A signal moves pending, processing, extracting, ready, and ends in one
of four terminal states. Filter by these with status.
| Value | Meaning |
|---|---|
pending | Written, not yet picked up. |
processing | Being worked on. |
extracting | Claims are being pulled out of it. |
ready | Fully processed and available for grouping into a beacon. |
merged | Folded into a duplicate by a person. Terminal, and not a delivery outcome. |
dismissed | Thrown away, by nima as noise or by a person. Terminal. |
failed | Processing gave up. Terminal. |
resolved | The spec this signal fed shipped. Terminal. |
classified | Legacy. An old intermediate state that nima no longer writes; a sweep at startup fails any row still in it. |
waiting_for_response | Deprecated. 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:
| Source | Inferred origin |
|---|---|
hubspot, or any channel starting hubspot: | customer |
github | external |
slack, manual, granola, posthog, sentry, linear, clickup, webhook, web_performance | internal |
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.
| Value | Where it comes from |
|---|---|
slack | A message or thread in a monitored channel. |
github | An issue, pull request or comment. |
manual | Typed by a person, or filed through create. |
hubspot | A 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. |
granola | A meeting note summary. Transcripts are never stored. |
posthog | One error-tracking issue, not one exception. |
sentry | One unresolved issue. |
linear | An issue, comment or customer request. |
clickup | A task, or one comment on a task. |
webhook | A JSON payload posted to one of your webhooks. |
web_performance | Availability 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.
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.
Beacons
List, read and count beacons with an API key, and see which routes promote, hold, dismiss and regroup them, which need a signed-in session.