Skip to main content

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:

CallBodyWhat happens
ExecuteFilter fields, no cursorA new search runs against the corpus and returns its first page. Charged.
Continuecursor (and optionally limit) onlyThe 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.

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

FieldTypeNotes
lookback_daysintegerRequired 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_iduuid | nullRestrict to one event type. Omit (or send null) to search every type.
involved_entity_idsuuid[]Canonical entity ids that must be involved. Up to 500.
involved_entity_list_idsuuid[]Entity lists whose members count as involved. Up to 50.
required_source_news_outlet_idsuuid[]Only count reports from these outlets. Up to 200.
min_reportsintegerMinimum distinct outlets reporting the event, 1–100. Defaults to 1.
role_filtersobject[]Up to 10 predicates on the event's structured roles — see below.
limitintegerPage size, 1–50. Defaults to 20. A larger value is rejected, not clamped.
cursorstringA 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:

kindAdditional fields
amount_gtemin_value (number), unit (string, optional)
amount_ltemax_value (number), unit (string, optional)
date_afterafter_iso (ISO 8601 timestamp)
date_beforebefore_iso (ISO 8601 timestamp)
entity_incanonical_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

FieldMeaning
dataThe events on this page.
has_moretrue if another page is reachable. Branch on this, never on total_count.
next_cursorThe cursor to send back for the next page, or null when has_more is false.
execution_idThe 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_countThe exact corpus-wide match count for the filter. See the caveat below.

Event fields

FieldMeaning
idThe event's canonical id. Stable across merges — store this one.
event_type.code_nameThe event type's stable code name.
sentenceA one-line generated summary of the event, or null when none exists.
happened_atWhen the event itself occurred, as reported. null when unknown. A date-like string, not a normalized timestamp.
first_report_dateWhen 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_dateWhen 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_countHow many reports matched this event, counting reports received up to the time of the search.
distinct_outlet_countHow many distinct outlets reported it — this is the number min_reports is compared against. Same cut-off as matched_report_count.
involved_entitiesEvery 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_days selects 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_date is the earliest such date among the event's reports and last_report_date the 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_date to 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_date advances when new coverage arrives. first_report_date can 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_count and distinct_outlet_count only 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:

  1. A reachability ceiling. Each execution exposes a bounded number of results; matches beyond that ceiling can't be reached by any cursor.
  2. 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_count is 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

StatusProblem typeWhat it means here
400validation-failedTwo 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.
402insufficient-creditsThe balance can't cover the execution. Nothing was charged.
403missing-scopeThe key lacks EVENTS_READ.
404not-foundThe cursor's execution doesn't exist or isn't yours. The two are deliberately indistinguishable.
409idempotency-in-flightAnother request with the same Idempotency-Key is running — or a previous one crashed. See below.
410search-execution-expiredThe cursor's execution passed its retention window. Re-run the search (charged).
429rate-limitedPer-key budget exhausted. Continuation pages count against it too.
502upstream-errorThe 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 409 idempotency-in-flight indefinitely instead of executing, because "crashed before it charged" and "charged, then crashed" are indistinguishable from the outside. Retry with a fresh Idempotency-Key. A retry loop that keeps re-sending the same key on a 409 will never make progress.
  • A 402 or 400 is 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, for 402, 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.