Searching events
POST /v1/events/search searches Evinor's historical event corpus. Where a
sensor tells you about events as they happen, a search tells
you about events that already happened, using the same filter vocabulary.
This is the only billed endpoint in the API: running a search charges your account's credit balance. Paging through results you've already paid for does not charge again.
- Scope required:
EVENTS_READ(see Authentication). - Method and path:
POST https://api.evinor.ai/v1/events/search. - Request body: JSON. There are no query parameters.
Building for an agent rather than an application? This endpoint is also exposed
over the Model Context Protocol as the search_events and
search_events_continue tools — same key, same scope, same credits, same
problem types. See the MCP server.
Execute or continue
One endpoint serves two different calls, distinguished by whether the body
carries a cursor:
| Call | Body | What happens |
|---|---|---|
| Execute | Filter fields, no cursor | A new search runs against the corpus and returns its first page. Charged. |
| Continue | cursor (and optionally limit) only | The next page of the execution that cursor came from. Not re-charged. |
The two are mutually exclusive. Sending any filter field alongside a cursor
returns 400 validation-failed rather than
silently ignoring it — a continuation page always re-uses the filter recorded on
the original execution, so a filter sent with a cursor could never take effect.
What is charged
A cursor-less call is charged a flat cost per execution, regardless of how many events match. A cursor-bearing call is not re-charged — it reads an execution your account has already paid for. (It is not free of work: the engine re-runs from the filter recorded on the execution, so it still costs you a request against your rate-limit budget and takes real time. It just isn't billed a second time.)
When the balance can't cover an execution, the request is refused with 402
insufficient-credits before the search
runs — a refused search is never charged.
Executing a search
- curl
- TypeScript
curl -X POST https://api.evinor.ai/v1/events/search \
-H "Authorization: Bearer evnr_live_your_key_here" \
-H "Idempotency-Key: 3f1c9a2e-6b4d-4e2a-9f77-0c53412fd0e1" \
-H "Content-Type: application/json" \
-d '{
"event_type_id": "0f3c7a41-5b21-4f8e-9c0a-2d6e18b4c9a3",
"involved_entity_ids": ["7c2e1b90-3d44-4a6f-b8e2-9a5c0d71f346"],
"min_reports": 2,
"lookback_days": 90,
"limit": 20
}'
import { Evinor } from '@evinor/sdk';
const evinor = new Evinor(); // reads EVINOR_API_KEY
// Billed. The SDK never retries this call automatically.
const { data: page } = await evinor.events.search(
{
event_type_id: '0f3c7a41-5b21-4f8e-9c0a-2d6e18b4c9a3',
involved_entity_ids: ['7c2e1b90-3d44-4a6f-b8e2-9a5c0d71f346'],
min_reports: 2,
lookback_days: 90,
limit: 20,
},
{ idempotencyKey: '3f1c9a2e-6b4d-4e2a-9f77-0c53412fd0e1' }
);
Request fields
| Field | Type | Notes |
|---|---|---|
lookback_days | integer | Required when cursor is omitted. One of 7, 30, 90, 180, 365. Every search is windowed: it returns events first reported within that many days — see Ordering events in time. |
event_type_id | uuid | null | Restrict to one event type. Omit (or send null) to search every type. |
involved_entity_ids | uuid[] | Canonical entity ids that must be involved. Up to 500. |
involved_entity_list_ids | uuid[] | Entity lists whose members count as involved. Up to 50. |
required_source_news_outlet_ids | uuid[] | Only count reports from these outlets. Up to 200. |
report_source_kind | string | ANY (default), ARTICLES_ONLY, USER_REPORTS_ONLY, or REPRESENTED_ONLY — which reports count toward a match. See Report source. |
represented_entity_ids | uuid[] | With REPRESENTED_ONLY: only reports submitted on behalf of one of these entities. Up to 500. |
represented_entity_list_ids | uuid[] | With REPRESENTED_ONLY: only reports submitted on behalf of a member of one of these entity lists. Up to 50. |
min_reports | integer | Minimum distinct outlets reporting the event, 1–100. Defaults to 1. |
role_filters | object[] | Up to 10 predicates on the event's structured roles — see below. |
limit | integer | Page size, 1–50. Defaults to 20. A larger value is rejected, not clamped. |
cursor | string | A next_cursor from a previous response. Selects the continuation call. |
Any unrecognized field returns 400
validation-failed.
Report source
report_source_kind narrows which reports can match, with the same meaning as
on a sensor filter:
ANY— every report. Omitting the field is the same as sendingANY.ARTICLES_ONLY— news-article reports only.USER_REPORTS_ONLY— reports submitted by users only.REPRESENTED_ONLY— user reports submitted on behalf of an entity, optionally narrowed byrepresented_entity_ids/represented_entity_list_ids(both empty = on behalf of any entity). "On behalf of" is the reporter's own statement — it is not a verification.
The two represented_* fields are accepted only with REPRESENTED_ONLY. Like
every filter field, all three are rejected alongside a cursor.
Role filters
Each entry in role_filters is tagged with a kind and names the role it
applies to with role_code_name:
kind | Additional fields |
|---|---|
amount_gte | min_value (number), unit (string, optional) |
amount_lte | max_value (number), unit (string, optional) |
date_after | after_iso (ISO 8601 timestamp) |
date_before | before_iso (ISO 8601 timestamp) |
entity_in | canonical_ids (uuid[], optional), entity_list_ids (uuid[], optional) |
text_contains | terms (string[], 1–20) |
category_in | values (string[], 1–100) |
{
"kind": "amount_gte",
"role_code_name": "DEAL_VALUE",
"min_value": 50000000,
"unit": "USD"
}
text_contains and category_in are not enabled yetBoth kinds are accepted by the request schema but are rejected as invalid until their rollout completes. Filters that already hold them are read back as documented below.
text_contains
Matches when the role's text value contains any of the terms as a whole
word or a whole phrase. Matching ignores case, accents and punctuation: both the
value and each term are lower-cased, accents are stripped, and every run of
characters that is not a letter or digit becomes a single space. So "data"
and "head of data" match "Head of Data", but "data" does not match
"Update", and "ai" does not match "Chair".
{
"kind": "text_contains",
"role_code_name": "ROLE_TITLE",
"terms": ["chief technology officer", "CTO"]
}
Known limits:
- Scripts written without spaces (Chinese, Japanese, Thai) have no word boundaries, so a term matches only the whole value.
- Hyphens and other punctuation become spaces:
"co-founder"is matched by"co founder"or"co-founder", but not by"cofounder".
category_in
Matches when the role's value is exactly one of values. Use it on
CATEGORICAL roles; their vocabulary is listed in each role's
allowed_values on GET /v1/event-types.
{
"kind": "category_in",
"role_code_name": "STAGE",
"values": ["SEED", "SERIES_A"]
}
Limits
| Limit | Value |
|---|---|
Characters per text_contains term, after normalization | 64 |
Terms per text_contains filter | 20 |
Terms across all text_contains filters of one filter set | 40 |
Values per category_in filter | 100 |
Characters per category_in value | 128 |
The "filter set" is one search request, or one beat clause. A term that is
only punctuation (for example "!!!") is rejected: it has no letters or digits
left to match.
The response
{
"data": [
{
"id": "9d41b7e2-0c58-4b1a-8f37-2ae6c05d9134",
"event_type": { "code_name": "ACQUISITION" },
"sentence": "Northwind Corp agreed to acquire Contoso for $1.2B.",
"happened_at": "2026-06-14",
"event_date": {
"value": "2026-06-14T00:00:00.000Z",
"source": "STATED",
"precision": "DAY"
},
"first_report_date": "2026-06-14T16:05:00.000Z",
"last_report_date": "2026-06-19T08:30:00.000Z",
"matched_report_count": 7,
"distinct_outlet_count": 4,
"involved_entities": [
{
"id": "7c2e1b90-3d44-4a6f-b8e2-9a5c0d71f346",
"name": "Northwind Corp",
"type": "ORGANIZATION"
},
{
"id": "3b8f6a14-5e2d-4c91-a7b0-1d4e9c62f805",
"name": "Contoso",
"type": "ORGANIZATION"
}
]
}
],
"has_more": true,
"next_cursor": "OWQ0MWI3ZTItMGM1OC00YjFhOjIw",
"execution_id": "c0a8012e-8b3e-4c77-9f21-6b1d0e2a4f55",
"total_count": 143
}
Envelope
| Field | Meaning |
|---|---|
data | The events on this page. |
has_more | true if another page is reachable. Branch on this, never on total_count. |
next_cursor | The cursor to send back for the next page, or null when has_more is false. |
execution_id | The id of the (already charged) execution these results belong to. Useful for correlating your charge; the cursor already carries it, so you don't need to send it back. |
total_count | The exact corpus-wide match count for the filter. See the caveat below. |
Event fields
| Field | Meaning |
|---|---|
id | The event's canonical id. Stable across merges — store this one. |
event_type.code_name | The event type's stable code name. |
sentence | A one-line generated summary of the event, or null when none exists. |
happened_at | When the event itself occurred, as reported. null when unknown. A date-like string, not a normalized timestamp. |
event_date | When the event itself occurred, with its provenance: { value, source, precision }, or null when no report gives a date. See The event date. |
first_report_date | When the event was first reported — the publish time of its earliest report, as an ISO 8601 UTC timestamp. Never null. This is the date lookback_days windows on and the order results come back in — see Ordering events in time. |
last_report_date | When the event was most recently reported — the publish time of its latest report, as an ISO 8601 UTC timestamp. Never null. A freshness signal: later than first_report_date when coverage continued. Not a sort key. |
matched_report_count | How many reports matched this event, counting reports received up to the time of the search. |
distinct_outlet_count | How many distinct outlets reported it — this is the number min_reports is compared against. Same cut-off as matched_report_count. |
involved_entities | Every entity involved in the event — its role participants (such as the acquirer and the target) and the entities its reports mention — each { id, name, type }, listed by canonical id with no duplicates and participants first. type may be null. Empty array when there are none. See Involved entities. |
Every field above is always present. Nullable fields are sent as an explicit
null rather than omitted, so the shape is uniform across pages.
Involved entities
involved_entities is the same set of entities the entity filters —
involved_entity_ids, involved_entity_list_ids, and the entity_in role
filter — match an event on. It covers all of the event's reports, not only the
ones your filters matched. So you can check a result against your filter
locally: an event returned for an entity lists that entity.
- Ids are surviving canonical ids. When two entities have been merged into one, only the surviving entity is listed, once. If you filtered on the id of an entity that was later merged away, the event lists the surviving entity's id instead, so compare against the survivor.
- A deleted entity is not listed, even if a filter on its id matched the event.
- The list is current. Like the dates, it describes the event as Evinor knows it when the page is served, so it can gain entities between pages (for example when two duplicate events are merged into one).
Ordering events in time
Results are ordered by first_report_date, newest first, with ties broken
by id. Page order is a timeline of when events were first reported, and it
continues across pages: page two picks up where page one left off.
- The window uses the same date.
lookback_daysselects events first reported within that many days before the search ran. An event first reported earlier is not returned, even when an article inside the window covers it again, so a recap or explainer doesn't pull an old event into a short lookback. - How a report is dated. A report's date is its source's publish time. When
the source carries no usable publish time, or claims one more than a day after
Evinor received the report, the time Evinor received the report is used
instead.
first_report_dateis the earliest such date among the event's reports andlast_report_datethe latest. - Both dates describe the event, not your search. They cover all of the
event's reports, including ones your filters didn't match, so they don't
change with filters. Use
last_report_dateto tell a one-off story from one that is still developing. - Dates are current; counts are as of the search. Continuation pages re-run
the search, and the dates on them describe the event as Evinor knows it at
that moment.
last_report_dateadvances when new coverage arrives.first_report_datecan move earlier (two duplicate events merged into one, or an earlier-published report arriving late) or later (the earliest report removed), which can occasionally move an event across the window's edge or to a different position between pages.matched_report_countanddistinct_outlet_countonly count reports received up to the time of the original search, so reports that arrive later never change them.
Prefer event_date when you need the date of the event itself rather than of
its coverage. It is null more rarely than happened_at, but it can still be
null, and it can be an estimate, which is why first_report_date is the
ordering key.
The event date
event_date answers "when did this event happen, and how sure is Evinor?":
| Field | Meaning |
|---|---|
value | An ISO 8601 UTC timestamp. For MONTH or null precision it is the first instant of the month, so only its year and month are meaningful. |
source | STATED when a report explicitly stated the date. ESTIMATED when it was inferred from a report's text (for example "last December") because no report stated one. |
precision | DAY or MONTH. A stated date is always DAY. null means the estimate's precision is unspecified: treat it like MONTH and never show or compare it as a day. |
It is derived from the event's featured report, then its first report, and any
stated date wins over any estimate. An estimate is not a stated fact: an agent
that acts on dates should filter on source.
How it relates to the other dates:
happened_atis stated-only. It is the featured report's stated date, verbatim, and is unchanged byevent_date. Whenevent_date.sourceisSTATEDand the featured report stated a date,event_date.valueis that same date normalized to UTC.event_datecan be set whilehappened_atisnull: when only the first report stated a date, or when the date is an estimate.first_report_dateis about coverage, not the event. It is when the event was first reported, and it is nevernull. An event can be reported long after it happened, soevent_dateis usually earlier thanfirst_report_date, and it is not whatlookback_dayswindows on.- It is current. Like the other dates,
event_datedescribes the event as Evinor knows it when the page is served, so a later report can change it.
What the response deliberately excludes
This is an allowlist, not a subset that will grow on request. Article bodies and source URLs, report links, media, event-type internals (extraction and classification guidance), per-role parsed values, and confidence scores are excluded by design and won't be added. Build against the fields documented above.
Paging
Send the previous response's next_cursor back as cursor, with no filter
fields, until has_more is false:
- curl
- TypeScript
curl -X POST https://api.evinor.ai/v1/events/search \
-H "Authorization: Bearer evnr_live_your_key_here" \
-H "Content-Type: application/json" \
-d '{ "cursor": "OWQ0MWI3ZTItMGM1OC00YjFhOjIw", "limit": 20 }'
// Not re-charged. Retried automatically on a retryable failure.
const { data: next } = await evinor.events.continue('OWQ0MWI3ZTItMGM1OC00YjFhOjIw', { limit: 20 });
// Or let the SDK walk every page: one billed execution, free continuations.
for await (const event of evinor.events.searchAll({ lookback_days: 90 })) {
console.log(event.id);
}
In pseudocode:
page = POST /v1/events/search { filter…, lookback_days: 90 } # charged
process(page.data)
while page.has_more:
page = POST /v1/events/search { cursor: page.next_cursor } # not re-charged
process(page.data)
Cursors are opaque. next_cursor is a token to echo back, not a structure
to parse or build — it encodes the execution id internally, which is why a
continuation call needs nothing else. (The execution id is also returned in
plain text as execution_id if you want to log it.)
Unlike Evinor's list endpoints, the cursor travels in the request
body as cursor, not as a starting_after query parameter.
total_count can exceed what you can retrieve
total_count is the exact number of events matching your filter across the
corpus. The number of events you can actually page through may be smaller,
for two reasons:
- A reachability ceiling. Each execution exposes a bounded number of results; matches beyond that ceiling can't be reached by any cursor.
- Dropped edges. An event whose underlying record can no longer be resolved
— deleted or merged away since the corpus was indexed — is omitted from
data.total_countis computed before that resolution and doesn't know about it.
Both are expected behavior, not a bug. Use has_more to decide whether to keep
paging, and treat total_count as a corpus-wide estimate of reach rather than a
promise of retrievable rows.
Errors and retries
| Status | Problem type | What it means here |
|---|---|---|
400 | validation-failed | Two kinds. Malformed request: an unknown field, a limit above 50, a missing lookback_days, a malformed cursor, or filter fields sent alongside a cursor — these are rejected before anything is charged or claimed. Filter your account can't run: an id in involved_entity_list_ids that isn't one of your lists, or a watchlist selection too large to expand — fix the referenced field(s) and resend. Both kinds mean the request as written will never succeed — don't retry it unmodified. |
402 | insufficient-credits | The balance can't cover the execution. Nothing was charged. |
403 | missing-scope | The key lacks EVENTS_READ. |
404 | not-found | The cursor's execution doesn't exist or isn't yours. The two are deliberately indistinguishable. |
409 | idempotency-in-flight | Another request with the same Idempotency-Key is running — or a previous one crashed. See below. |
410 | search-execution-expired | The cursor's execution passed its retention window. Re-run the search (charged). |
429 | rate-limited | Per-key budget exhausted. Continuation pages count against it too. |
502 | upstream-error | The search could not be completed, and whether it was charged before failing can't be determined from here. Retry once; if it persists, contact support with the request_id rather than looping on fresh keys. |
A 502 on an execute call is deliberately ambiguous about billing: the request
may have been charged before it failed. The endpoint therefore keeps your
Idempotency-Key claimed, so a same-key retry returns 409 rather than
executing again. Treat a repeating 502 as a support question, not as something
to loop on with fresh keys — each fresh key is a new, separately-billable
execution.
A 400 is the opposite case: the endpoint can prove nothing was charged (the
request is rejected before the search runs, or an upstream check that ran
proves the refusal happened before the charge point), so once you've fixed
your request you can retry it with the same Idempotency-Key — see
Idempotency on a billed endpoint below.
Idempotency on a billed endpoint
An execute call is a POST with a real side effect — a charge — so send an
Idempotency-Key and retry with it. Its guarantee here is at-most-once, and
it is stricter than on Evinor's other POSTs:
- A completed request replays its stored response (same
execution_id,Idempotency-Replayed: true) and is not charged again. - A request that crashed mid-flight is not reclaimable. Retrying with the
same key returns
409idempotency-in-flightindefinitely instead of executing, because "crashed before it charged" and "charged, then crashed" are indistinguishable from the outside. Retry with a freshIdempotency-Key. A retry loop that keeps re-sending the same key on a409will never make progress. - A
402or400is the exception: both are provable pre-charge refusals, so the key is released — a request rejected for malformed shape never claims a key at all, and one rejected for a filter your account can't run (an inaccessible list, a watchlist selection too large to expand) releases the key it claimed. Either way, the same key works again once you've fixed the request (and, for402, topped up). - Continuation calls are not billed and behave like every other endpoint — a crashed page can be retried with the same key.
See Idempotency for the general rules and how they differ.