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 -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
}'
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. |
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.
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) |
{
"kind": "amount_gte",
"role_code_name": "DEAL_VALUE",
"min_value": 50000000,
"unit": "USD"
}
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",
"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. |
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 happened_at when it is present and you need the date of the event
itself rather than of its coverage. It is often null, and it is a date-like
string rather than a normalized timestamp, which is why first_report_date is
the ordering key.
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 -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 }'
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.